repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

starfx / query
Vlad · 14 Nov 24

thunk.ts

  1import { ActionContext, API_ACTION_PREFIX, takeEvery } from "../action.ts";
  2import { compose } from "../compose.ts";
  3import { Callable, ensure, Ok, Operation, Signal } from "../deps.ts";
  4import { keepAlive, supervise } from "../fx/mod.ts";
  5import { IdContext } from "../store/store.ts";
  6import { createKey } from "./create-key.ts";
  7import { isFn, isObject } from "./util.ts";
  8
  9import type { ActionWithPayload, AnyAction, Next, Payload } from "../types.ts";
 10import type {
 11  CreateAction,
 12  CreateActionPayload,
 13  CreateActionWithPayload,
 14  Middleware,
 15  MiddlewareCo,
 16  Supervisor,
 17  ThunkCtx,
 18} from "./types.ts";
 19export interface ThunksApi<Ctx extends ThunkCtx> {
 20  use: (fn: Middleware<Ctx>) => void;
 21  routes: () => Middleware<Ctx>;
 22  bootup: Callable<void>;
 23  register: Callable<void>;
 24  reset: () => void;
 25
 26  /**
 27   * Name only
 28   */
 29  create(name: string): CreateAction<Ctx>;
 30  create<P>(
 31    name: string,
 32  ): CreateActionWithPayload<Omit<Ctx, "payload"> & Payload<P>, P>;
 33
 34  /**
 35   * Name and options
 36   */
 37  create(name: string, req: { supervisor?: Supervisor }): CreateAction<Ctx>;
 38  create<P>(
 39    name: string,
 40    req: { supervisor?: Supervisor },
 41  ): CreateActionWithPayload<Omit<Ctx, "payload"> & Payload<P>, P>;
 42
 43  /**
 44   * Name and middleware
 45   */
 46  create(name: string, fn: MiddlewareCo<Ctx>): CreateAction<Ctx>;
 47  create<Gtx extends Ctx = Ctx>(
 48    name: string,
 49    fn: MiddlewareCo<Gtx>,
 50  ): CreateAction<Gtx>;
 51  create<P>(
 52    name: string,
 53    fn: MiddlewareCo<Omit<Ctx, "payload"> & Payload<P>>,
 54  ): CreateActionWithPayload<Omit<Ctx, "payload"> & Payload<P>, P>;
 55  create<P, Gtx extends Ctx = Ctx>(
 56    name: string,
 57    fn: MiddlewareCo<Gtx>,
 58  ): CreateActionWithPayload<Gtx, P>;
 59
 60  /*
 61   * Name, options, and middleware
 62   */
 63  create(
 64    name: string,
 65    req: { supervisor?: Supervisor },
 66    fn: MiddlewareCo<Ctx>,
 67  ): CreateAction<Ctx>;
 68  create<Gtx extends Ctx = Ctx>(
 69    name: string,
 70    req: { supervisor?: Supervisor },
 71    fn: MiddlewareCo<Gtx>,
 72  ): CreateAction<Gtx>;
 73  create<P>(
 74    name: string,
 75    req: { supervisor?: Supervisor },
 76    fn: MiddlewareCo<Omit<Ctx, "payload"> & Payload<P>>,
 77  ): CreateActionWithPayload<Omit<Ctx, "payload"> & Payload<P>, P>;
 78  create<P, Gtx extends Ctx = Ctx>(
 79    name: string,
 80    req: { supervisor?: Supervisor },
 81    fn: MiddlewareCo<Gtx>,
 82  ): CreateActionWithPayload<Gtx, P>;
 83}
 84
 85let id = 0;
 86
 87/**
 88 * Creates a middleware pipeline.
 89 *
 90 * @remarks
 91 * This middleware pipeline is almost exactly like koa's middleware system.
 92 * See {@link https://koajs.com}
 93 *
 94 * @example
 95 * ```ts
 96 * import { createThunks } from 'starfx';
 97 *
 98 * const thunks = createThunks();
 99 * thunks.use(function* (ctx, next) {
100 *   console.log('beginning');
101 *   yield* next();
102 *   console.log('end');
103 * });
104 * thunks.use(thunks.routes());
105 *
106 * const doit = thunks.create('do-something', function*(ctx, next) {
107 *   console.log('middle');
108 *   yield* next();
109 *   console.log('middle end');
110 * });
111 *
112 * // ...
113 *
114 * store.dispatch(doit());
115 * // beginning
116 * // middle
117 * // middle end
118 * // end
119 * ```
120 */
121export function createThunks<Ctx extends ThunkCtx = ThunkCtx<any>>(
122  {
123    supervisor = takeEvery,
124  }: {
125    supervisor?: Supervisor;
126  } = { supervisor: takeEvery },
127): ThunksApi<Ctx> {
128  let signal: Signal<AnyAction, void> | undefined = undefined;
129  let storeId: number | undefined = undefined;
130  const middleware: Middleware<Ctx>[] = [];
131  const visors: { [key: string]: Callable<unknown> } = {};
132  const middlewareMap: { [key: string]: Middleware<Ctx> } = {};
133  let dynamicMiddlewareMap: { [key: string]: Middleware<Ctx> } = {};
134  const actionMap: {
135    [key: string]: CreateActionWithPayload<Ctx, any>;
136  } = {};
137  const thunkId = id++;
138
139  const storeMap = new Map<number, Signal<AnyAction, void>>();
140
141  function* defaultMiddleware(_: Ctx, next: Next) {
142    yield* next();
143  }
144
145  const createType = (post: string) => `${API_ACTION_PREFIX}${post}`;
146
147  function* onApi<P extends CreateActionPayload>(
148    action: ActionWithPayload<P>,
149  ): Operation<Ctx> {
150    const { name, key, options } = action.payload;
151    const actionFn = actionMap[name];
152    const ctx = {
153      action,
154      name,
155      key,
156      payload: options,
157      actionFn,
158      result: Ok(undefined),
159    } as unknown as Ctx;
160    const fn = compose(middleware);
161    yield* fn(ctx);
162    return ctx;
163  }
164
165  function create(name: string, ...args: any[]) {
166    if (Object.hasOwn(visors, name)) {
167      const msg =
168        `[${name}] already exists, do you have two thunks with the same name?`;
169      console.warn(msg);
170    }
171
172    const type = createType(name);
173    const action = (payload?: any) => {
174      return { type, payload };
175    };
176    let req = null;
177    let fn = null;
178    if (args.length === 2) {
179      req = args[0];
180      fn = args[1];
181    }
182
183    if (args.length === 1) {
184      if (isFn(args[0]) || Array.isArray(args[0])) {
185        fn = args[0];
186      } else {
187        req = args[0];
188      }
189    }
190
191    if (req && !isObject(req)) {
192      throw new Error("Options must be an object");
193    }
194
195    if (fn && Array.isArray(fn)) {
196      fn = compose(fn);
197    }
198
199    if (fn && !isFn(fn)) {
200      throw new Error("Middleware must be a function");
201    }
202
203    middlewareMap[name] = fn || defaultMiddleware;
204
205    const tt = req ? (req as any).supervisor : supervisor;
206    function* curVisor() {
207      yield* tt(type, onApi);
208    }
209
210    visors[name] = curVisor;
211
212    // If signal is already referenced, register immediately, otherwise defer
213    for (const [storeId, storeSignal] of storeMap.entries()) {
214      storeSignal.send({
215        type: `${API_ACTION_PREFIX}REGISTER_THUNK_${storeId}_${thunkId}`,
216        payload: curVisor,
217      });
218    }
219
220    const errMsg =
221      `[${name}] is being called before its thunk has been registered. ` +
222      "Run `store.run(thunks.register)` where `thunks` is the name of your `createThunks` or `createApi` variable.";
223
224    const actionFn = (options?: Ctx["payload"]) => {
225      if (!signal) {
226        console.warn(errMsg);
227      }
228      const key = createKey(name, options);
229      return action({ name, key, options });
230    };
231    actionFn.run = (action?: unknown): Operation<Ctx> => {
232      if (action && Object.hasOwn(action, "type")) {
233        return onApi(action as ActionWithPayload<CreateActionPayload>);
234      }
235      return onApi(actionFn(action));
236    };
237    actionFn.use = (fn: Middleware<Ctx>) => {
238      const cur = middlewareMap[name];
239      if (cur) {
240        dynamicMiddlewareMap[name] = compose([cur, fn]);
241      } else {
242        dynamicMiddlewareMap[name] = fn;
243      }
244    };
245    actionFn.toString = () => type;
246    actionFn._success = {};
247    actionFn._error = {};
248    actionMap[name] = actionFn;
249
250    return actionFn;
251  }
252
253  function* watcher(action: ActionWithPayload<Callable<unknown>>) {
254    yield* supervise(action.payload)();
255  }
256
257  function* register() {
258    storeId = yield* IdContext;
259    if (storeId && storeMap.has(storeId)) {
260      console.warn("This thunk instance is already registered.");
261      return;
262    }
263
264    signal = yield* ActionContext;
265    storeMap.set(storeId, signal);
266
267    yield* ensure(function* () {
268      if (storeId) {
269        storeMap.delete(storeId);
270      }
271    });
272
273    // Register any thunks created after signal is available
274    yield* keepAlive(Object.values(visors));
275
276    // Spawn a watcher for further thunk matchingPairs
277    yield* takeEvery(
278      `${API_ACTION_PREFIX}REGISTER_THUNK_${storeId}_${thunkId}`,
279      watcher as any,
280    );
281  }
282
283  function routes() {
284    function* router(ctx: Ctx, next: Next) {
285      const match = dynamicMiddlewareMap[ctx.name] || middlewareMap[ctx.name];
286      if (!match) {
287        yield* next();
288        return;
289      }
290
291      const result = yield* match(ctx, next);
292      return result;
293    }
294
295    return router;
296  }
297
298  function resetMdw() {
299    dynamicMiddlewareMap = {};
300  }
301
302  return {
303    use: (fn: Middleware<Ctx>) => {
304      middleware.push(fn);
305    },
306    create,
307    routes,
308    /**
309     * @deprecated use `register()` instead
310     */
311    bootup: register,
312    reset: resetMdw,
313    register,
314  };
315}