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}