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}