repos / starfx

a micro-mvc framework for react apps
git clone https://github.com/neurosnap/starfx.git

starfx / src / query
Eric Bower  ·  2025-06-06

thunk.ts

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