@jarenjs/app 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tasks.js ADDED
@@ -0,0 +1,299 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The host half of the async-task convention (docs/TASKS.md).
4
+ *
5
+ * The convention has two halves and this module is deliberately only one
6
+ * of them: **correctness lives in state** — a task slot's monotonic `id`
7
+ * and the completion action's guard reject stale responses — while
8
+ * **cancellation and concurrency live here**, as an optimization that
9
+ * stops wasting the wire. An aborted fetch may already have resolved and
10
+ * its dispatch may already be queued, so a host that only aborts is
11
+ * still wrong; the state-side guard is the guarantee.
12
+ *
13
+ * No timers, no state beyond the per-slot records, no dependencies
14
+ * (`AbortController` is platform).
15
+ */
16
+
17
+ import { toError, isErrorSafely, safeErrorMessage } from './errors.js';
18
+
19
+ /**
20
+ * Read a rejection value's `name` without trusting it: `typeof` is
21
+ * untrappable, the property read is guarded — a revoked proxy or a
22
+ * throwing accessor classifies as "not an AbortError".
23
+ * @param {unknown} err
24
+ * @returns {string | null}
25
+ */
26
+ function safeName(err) {
27
+ if (err === null || (typeof err !== 'object' && typeof err !== 'function')) return null;
28
+ try {
29
+ const name = /** @type {any} */ (err).name;
30
+ return typeof name === 'string' ? name : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The `{ id, error }` payload string for a rejection, TOTAL for every
39
+ * value: primitives stringify verbatim (the established payload
40
+ * contract); Errors project their message through the safe accessor;
41
+ * objects, functions and symbols go through the normalizer — no host
42
+ * `toString`/`Symbol.toPrimitive` is ever invoked.
43
+ * @param {unknown} err
44
+ * @returns {string}
45
+ */
46
+ function rejectionText(err) {
47
+ if (isErrorSafely(err)) return safeErrorMessage(err);
48
+ if (err === null
49
+ || (typeof err !== 'object' && typeof err !== 'function' && typeof err !== 'symbol')) {
50
+ return String(err);
51
+ }
52
+ return toError(err).message;
53
+ }
54
+
55
+ /**
56
+ * The host's task function, typically wrapping `fetch`. A synchronous
57
+ * return is allowed — the effect settles every result through one
58
+ * uniform promise boundary either way.
59
+ * @callback TaskRun
60
+ * @param {any} props - The effect's `with` value, verbatim.
61
+ * @param {AbortSignal} signal - Aborted when the slot's concurrency mode
62
+ * supersedes this task, when the host cancels the slot, or when the
63
+ * effect is disposed; pass it to `fetch` (or ignore it — the
64
+ * state-side id guard stays correct either way).
65
+ * @returns {any | PromiseLike<any>} The JSON result, or a promise of it.
66
+ */
67
+
68
+ /**
69
+ * Per-slot concurrency modes (APP-FORMAT §9.2):
70
+ *
71
+ * - `"switch"` (default) — starting a task aborts the slot's
72
+ * in-flight predecessor; the newest request wins.
73
+ * - `"exhaust"` — while the slot has an in-flight task, new starts are
74
+ * ignored entirely (nothing dispatched) — the mode for a
75
+ * non-idempotent commit where a double-click must not double-run.
76
+ * - `"concat"` — new starts queue and run one after another, in
77
+ * order — only for deliberately ordered commands.
78
+ * - `"parallel"` — every start runs concurrently; the consumer owns
79
+ * the merge rule.
80
+ *
81
+ * Whatever the mode, correctness stays visible in JSON state: the task
82
+ * slot's monotonic `id` and the completion action's guard remain the
83
+ * authority on which response may land.
84
+ * @typedef {'switch' | 'exhaust' | 'concat' | 'parallel'} TaskMode
85
+ */
86
+
87
+ /**
88
+ * @typedef {Object} TaskEffectOptions
89
+ * @property {TaskMode} [mode] - The per-slot concurrency mode
90
+ * (default `"switch"`).
91
+ */
92
+
93
+ /**
94
+ * The effect handler returned by {@link createTaskEffect}, with its
95
+ * host-side controls.
96
+ * @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
97
+ * cancel: (slot?: string) => void,
98
+ * cancelAll: () => void,
99
+ * dispose: () => void,
100
+ * }} TaskEffect
101
+ */
102
+
103
+ /**
104
+ * Package `run` as a registered effect handler implementing the
105
+ * async-task convention. The effect's `with` props (all JSON):
106
+ *
107
+ * - `id` (REQUIRED) — the task identity, echoed back verbatim in the
108
+ * completion payload for the state-side guard;
109
+ * - `done` (REQUIRED) — the action dispatched on settle;
110
+ * - `fail` (OPTIONAL, string) — the action for rejections; absent,
111
+ * rejections dispatch `done` with `{ id, error }` instead of
112
+ * `{ id, result }` — one completion action guarding on
113
+ * `$payload.error` is the query-friendliest shape;
114
+ * - `slot` (OPTIONAL, string) — the concurrency key, default `""`;
115
+ * what a new start does to the slot's in-flight task is the
116
+ * effect's `mode` (see {@link TaskMode});
117
+ * - anything else `run` needs (a URL, a query, ...).
118
+ *
119
+ * Settlement, exactly: `run` is invoked through a uniform promise
120
+ * boundary, so a synchronous throw and a non-promise return settle
121
+ * through the same path as a rejection/resolution. A resolution
122
+ * dispatches `done` with `{ id, result }`; an abort rejection
123
+ * (`err.name === "AbortError"`) dispatches **nothing** — a superseded
124
+ * task is dead by design, its successor's dispatch carries the story;
125
+ * any other rejection dispatches `fail ?? done` with `{ id, error }`
126
+ * where `error` is a string, never an Error object — JSON only crosses
127
+ * the boundary. After `dispose()` no settlement dispatches anything.
128
+ * A malformed `id`/`done`/`fail`/`slot` is a host programming error:
129
+ * the handler throws a `TypeError`, which the loop reports as `JA2007`.
130
+ * Settlement is TOTAL for every rejection value (hostile accessors,
131
+ * revoked proxies included) and never creates an unhandled rejection
132
+ * from framework code; a settlement dispatch that itself throws (a
133
+ * rethrowing error sink surfacing at the dispatch boundary) is
134
+ * re-raised on its own microtask so the host's global error handling
135
+ * observes it.
136
+ *
137
+ * Host-side controls on the returned handler:
138
+ *
139
+ * - `cancel(slot)` — abort the slot's in-flight task(s) and discard
140
+ * its queued (`concat`) starts;
141
+ * - `cancelAll()` — `cancel` for every slot;
142
+ * - `dispose()` — terminal: `cancelAll()` plus a permanent guard —
143
+ * late settlements can no longer dispatch, and new starts are
144
+ * ignored. Idempotent. `app.destroy()` calls it automatically for
145
+ * every registered handler exposing it.
146
+ *
147
+ * @example
148
+ * createApp(doc, {
149
+ * effects: {
150
+ * http: createTaskEffect((props, signal) =>
151
+ * fetch(props.url, { signal }).then((r) => r.json())),
152
+ * },
153
+ * });
154
+ *
155
+ * @param {TaskRun} run
156
+ * @param {TaskEffectOptions} [options]
157
+ * @returns {TaskEffect}
158
+ */
159
+ export function createTaskEffect(run, options = {}) {
160
+ const mode = options.mode ?? 'switch';
161
+ if (mode !== 'switch' && mode !== 'exhaust' && mode !== 'concat' && mode !== 'parallel') {
162
+ throw new TypeError(`createTaskEffect: unknown mode '${String(mode)}'`);
163
+ }
164
+
165
+ /**
166
+ * Per-slot bookkeeping: the in-flight controllers and, for `concat`,
167
+ * the pending starts.
168
+ * @type {Map<string, { active: Set<AbortController>, queue: Array<[any, (name: string, payload?: any) => void]> }>}
169
+ */
170
+ const slots = new Map();
171
+ let disposed = false;
172
+
173
+ /** @param {string} slot */
174
+ function slotRecord(slot) {
175
+ let record = slots.get(slot);
176
+ if (record === undefined) {
177
+ record = { active: new Set(), queue: [] };
178
+ slots.set(slot, record);
179
+ }
180
+ return record;
181
+ }
182
+
183
+ /**
184
+ * Launch one task on a slot through the uniform promise boundary.
185
+ * @param {{ active: Set<AbortController>, queue: Array<[any, (name: string, payload?: any) => void]> }} record
186
+ * @param {any} props
187
+ * @param {(name: string, payload?: any) => void} dispatch
188
+ */
189
+ function launch(record, props, dispatch) {
190
+ const controller = new AbortController();
191
+ record.active.add(controller);
192
+
193
+ /** Release this task's controller and, for `concat`, start the next. */
194
+ const settle = () => {
195
+ record.active.delete(controller);
196
+ if (mode === 'concat' && !disposed && record.active.size === 0 && record.queue.length > 0) {
197
+ const next = /** @type {[any, (name: string, payload?: any) => void]} */ (record.queue.shift());
198
+ launch(record, next[0], next[1]);
199
+ }
200
+ };
201
+
202
+ // the uniform boundary: a synchronous throw from `run` and a
203
+ // non-promise return settle exactly like a rejection/resolution
204
+ new Promise((resolve) => { resolve(run(props, controller.signal)); }).then(
205
+ (result) => {
206
+ settle();
207
+ if (disposed) return;
208
+ try {
209
+ dispatch(props.done, { id: props.id, result });
210
+ }
211
+ catch (thrown) {
212
+ queueMicrotask(() => { throw thrown; });
213
+ }
214
+ },
215
+ (err) => {
216
+ settle();
217
+ if (disposed) return;
218
+ // abort classification is TOTAL and classifies the REJECTION
219
+ // VALUE only (`safeName` guards the read): the signal state
220
+ // must not suppress — a superseded task's non-abort failure
221
+ // still dispatches by contract, and only the state-side id
222
+ // guard rejects it. A hostile value never breaks settlement.
223
+ if (safeName(err) === 'AbortError') return;
224
+ const error = rejectionText(err);
225
+ // a settlement dispatch that itself throws (a rethrowing error
226
+ // sink surfacing at the dispatch boundary) must not become an
227
+ // unobservable promise rejection: it is re-raised on its own
228
+ // microtask so the host's global error handling observes it
229
+ try {
230
+ dispatch(props.fail ?? props.done, { id: props.id, error });
231
+ }
232
+ catch (thrown) {
233
+ queueMicrotask(() => { throw thrown; });
234
+ }
235
+ });
236
+ }
237
+
238
+ /** @type {any} */
239
+ const taskEffect = function taskEffect(props, dispatch) {
240
+ if (props === null || typeof props !== 'object'
241
+ || props.id === undefined || typeof props.done !== 'string') {
242
+ throw new TypeError(
243
+ 'createTaskEffect: the effect props must carry an "id" and a "done" action name');
244
+ }
245
+ if (props.fail !== undefined && typeof props.fail !== 'string') {
246
+ throw new TypeError('createTaskEffect: "fail" must be an action name');
247
+ }
248
+ if (props.slot !== undefined && typeof props.slot !== 'string') {
249
+ throw new TypeError('createTaskEffect: "slot" must be a string');
250
+ }
251
+ if (disposed) return;
252
+ const record = slotRecord(props.slot ?? '');
253
+
254
+ if (record.active.size > 0) {
255
+ if (mode === 'exhaust') return;
256
+ if (mode === 'concat') {
257
+ record.queue.push([props, dispatch]);
258
+ return;
259
+ }
260
+ if (mode === 'switch') {
261
+ for (const controller of record.active) controller.abort();
262
+ record.active.clear();
263
+ }
264
+ // parallel: fall through, the new task joins the slot
265
+ }
266
+ launch(record, props, dispatch);
267
+ };
268
+
269
+ /**
270
+ * Abort a slot's in-flight task(s) and discard its queued starts.
271
+ * Aborted settlements dispatch nothing (the AbortError rule).
272
+ * @param {string} [slot]
273
+ */
274
+ taskEffect.cancel = function cancel(slot = '') {
275
+ const record = slots.get(slot);
276
+ if (record === undefined) return;
277
+ record.queue.length = 0;
278
+ for (const controller of record.active) controller.abort();
279
+ record.active.clear();
280
+ };
281
+
282
+ /** `cancel` every slot. */
283
+ taskEffect.cancelAll = function cancelAll() {
284
+ for (const slot of slots.keys()) taskEffect.cancel(slot);
285
+ };
286
+
287
+ /**
288
+ * Terminal: cancel everything and permanently prevent both new starts
289
+ * and late-settlement dispatches. Idempotent.
290
+ */
291
+ taskEffect.dispose = function dispose() {
292
+ if (disposed) return;
293
+ disposed = true;
294
+ taskEffect.cancelAll();
295
+ slots.clear();
296
+ };
297
+
298
+ return taskEffect;
299
+ }