repos / starfx

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

starfx / query
Eric Bower · 16 Aug 24

thunk.ts

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