@mostra/react 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +16 -2
- package/dist/form.d.ts +20 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +259 -0
- package/dist/form.js.map +1 -0
- package/dist/hooks-Dh_l8KqJ.js +2737 -0
- package/dist/hooks-Dh_l8KqJ.js.map +1 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +199 -0
- package/dist/index.js.map +1 -0
- package/dist/mount-Bpg0M5ln.js +516 -0
- package/dist/mount-Bpg0M5ln.js.map +1 -0
- package/dist/router.d.ts +28 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +52 -0
- package/dist/router.js.map +1 -0
- package/package.json +117 -3
- package/src/form.ts +326 -0
- package/src/hooks.ts +530 -0
- package/src/index.ts +75 -0
- package/src/mount.ts +78 -0
- package/src/router.ts +84 -0
- package/src/runtime/boot.ts +306 -0
- package/src/runtime/bridge-limits.ts +30 -0
- package/src/runtime/errors.ts +53 -0
- package/src/runtime/journal.ts +219 -0
- package/src/runtime/server-client.ts +745 -0
- package/src/runtime/store.ts +1641 -0
- package/src/runtime/transport.ts +342 -0
- package/src/runtime/verify-probe.ts +67 -0
- package/src/server-hooks.ts +291 -0
- package/src/server-reference.ts +54 -0
- package/src/shell-bridge.ts +212 -0
- package/src/storage.ts +165 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's server side (D54, D56; WP-08): plain calls to `"use server"` functions, durable action
|
|
3
|
+
* runs, and live sources, over the store's transport. Runs and sources are tracked per page: one
|
|
4
|
+
* record per run and per source however many hooks show it, fed by the stream's ephemeral
|
|
5
|
+
* messages and read again whenever a message may have been missed (a gap, a reconnect, a renewal),
|
|
6
|
+
* so what a page shows always converges on what the server holds.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
type AnyFunction,
|
|
10
|
+
type Batches,
|
|
11
|
+
type CallLine,
|
|
12
|
+
deserializeAll,
|
|
13
|
+
type EphemeralMessage,
|
|
14
|
+
type Frame,
|
|
15
|
+
isFinalRunStatus,
|
|
16
|
+
type LiveState,
|
|
17
|
+
type LiveUpdate,
|
|
18
|
+
RemoteError,
|
|
19
|
+
type RunDelta,
|
|
20
|
+
type RunProgress,
|
|
21
|
+
type RunState,
|
|
22
|
+
type RunStatus,
|
|
23
|
+
serializeAll,
|
|
24
|
+
ValueReader,
|
|
25
|
+
type WireError,
|
|
26
|
+
} from "@mostra/protocol";
|
|
27
|
+
import { MostraError, targetUnavailable } from "./errors.ts";
|
|
28
|
+
import { NetworkError, type Transport } from "./transport.ts";
|
|
29
|
+
|
|
30
|
+
/** How often a page renews its interest in the runs and sources it shows (the lease is a minute). */
|
|
31
|
+
export const RENEW_MS = 20_000;
|
|
32
|
+
|
|
33
|
+
/** How many times a run's start is retried after the network failed, with the same client key. */
|
|
34
|
+
const START_ATTEMPTS = 6;
|
|
35
|
+
/** How many runs' early updates a page keeps, and how many updates of each (see `#keepEarly`). */
|
|
36
|
+
const EARLY_RUNS = 64;
|
|
37
|
+
const EARLY_DELTAS_PER_RUN = 32;
|
|
38
|
+
|
|
39
|
+
/** The function ID a server reference carries, or `undefined` for any other function. */
|
|
40
|
+
export function serverFunctionId(fn: unknown): string | undefined {
|
|
41
|
+
const id = (fn as { mostraServerFunctionId?: unknown } | null | undefined)
|
|
42
|
+
?.mostraServerFunctionId;
|
|
43
|
+
return typeof id === "string" ? id : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A random client key: a run's request idempotency (a retried POST joins its own run). */
|
|
47
|
+
function clientKey(): string {
|
|
48
|
+
const bytes = new Uint8Array(16);
|
|
49
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
50
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The error a failed or cancelled run, or call, rejects with. */
|
|
54
|
+
function failure(error: WireError | undefined, fallback: string): MostraError {
|
|
55
|
+
return new MostraError(error?.code ?? fallback, error?.message ?? "The action failed.");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a page knows of one run. */
|
|
59
|
+
export interface RunView {
|
|
60
|
+
readonly runId: string;
|
|
61
|
+
readonly fnId: string;
|
|
62
|
+
readonly key?: string;
|
|
63
|
+
readonly status: RunStatus;
|
|
64
|
+
readonly reason?: string;
|
|
65
|
+
readonly progress?: RunProgress;
|
|
66
|
+
readonly log: readonly string[];
|
|
67
|
+
readonly error?: WireError;
|
|
68
|
+
readonly finishedAt?: string;
|
|
69
|
+
/** The result, once it succeeded (an async iterable result: the iterable). */
|
|
70
|
+
readonly data?: unknown;
|
|
71
|
+
/** Items an async iterable result yielded so far. */
|
|
72
|
+
readonly partial: readonly unknown[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface TrackedRun {
|
|
76
|
+
view: RunView;
|
|
77
|
+
batches: Frame[][];
|
|
78
|
+
reader: ValueReader;
|
|
79
|
+
rootDecoded: boolean;
|
|
80
|
+
settled: Promise<unknown>;
|
|
81
|
+
resolve(value: unknown): void;
|
|
82
|
+
reject(error: unknown): void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a page knows of one live source. */
|
|
86
|
+
export interface LiveView {
|
|
87
|
+
readonly key: string;
|
|
88
|
+
readonly loading: boolean;
|
|
89
|
+
readonly data?: unknown;
|
|
90
|
+
readonly revision?: string;
|
|
91
|
+
readonly at?: string;
|
|
92
|
+
readonly error?: WireError;
|
|
93
|
+
/** Append mode: the values kept, oldest first, at most the largest window asked for. */
|
|
94
|
+
readonly ring: readonly unknown[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface TrackedLive {
|
|
98
|
+
view: LiveView;
|
|
99
|
+
watchers: Map<number, number | undefined>;
|
|
100
|
+
/** Revision numbers decoded or decoding, so an older value never replaces a newer one. */
|
|
101
|
+
newest: number;
|
|
102
|
+
ringRevisions: number[];
|
|
103
|
+
/**
|
|
104
|
+
* Every read and update of this source applies in the order it arrived, one at a time: values
|
|
105
|
+
* decode asynchronously, and two in flight must never land out of order.
|
|
106
|
+
*/
|
|
107
|
+
applying: Promise<void>;
|
|
108
|
+
/** The newest value in latest mode still waiting its turn; a newer one replaces it. */
|
|
109
|
+
pending?: LiveUpdate;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Options for {@link ServerClient}. */
|
|
113
|
+
export interface ServerClientOptions {
|
|
114
|
+
readonly transport?: Transport;
|
|
115
|
+
/** The page's view (D104), in live mode. */
|
|
116
|
+
readonly viewId?: string;
|
|
117
|
+
readonly timers?: {
|
|
118
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
119
|
+
clearTimeout(handle: unknown): void;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Plain calls, action runs and live sources for one page. */
|
|
124
|
+
export class ServerClient {
|
|
125
|
+
readonly #options: ServerClientOptions;
|
|
126
|
+
readonly #runs = new Map<string, TrackedRun>();
|
|
127
|
+
readonly #live = new Map<string, TrackedLive>();
|
|
128
|
+
readonly #listeners = new Set<() => void>();
|
|
129
|
+
#version = 0;
|
|
130
|
+
#nextWatcher = 1;
|
|
131
|
+
#renewTimer: unknown;
|
|
132
|
+
#liveFlush: Promise<void> | undefined;
|
|
133
|
+
#liveRefresh = new Set<string>();
|
|
134
|
+
#notifyScheduled = false;
|
|
135
|
+
/** Plain calls whose value hasn't arrived yet. */
|
|
136
|
+
#callsInFlight = 0;
|
|
137
|
+
/**
|
|
138
|
+
* Updates for runs the page doesn't track yet: a fast run's end can arrive on the stream before
|
|
139
|
+
* the answer that starts it, and is applied when that answer is (bounded, oldest dropped).
|
|
140
|
+
*/
|
|
141
|
+
readonly #early = new Map<string, RunDelta[]>();
|
|
142
|
+
|
|
143
|
+
constructor(options: ServerClientOptions) {
|
|
144
|
+
this.#options = options;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Subscription ───────────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
/** Calls `listener` after every change; returns the function that stops. */
|
|
150
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
151
|
+
this.#listeners.add(listener);
|
|
152
|
+
return () => this.#listeners.delete(listener);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/** A number that changes with every change, for `useSyncExternalStore`. */
|
|
156
|
+
version = (): number => this.#version;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Server work the page is waiting on: plain calls whose value hasn't arrived, runs that haven't
|
|
160
|
+
* ended, and live sources a component shows that have no first value yet. `verify`'s field
|
|
161
|
+
* probe reads it, so the runner judges the page once what it loads at mount has arrived (Opus
|
|
162
|
+
* review finding 1).
|
|
163
|
+
*/
|
|
164
|
+
pending(): number {
|
|
165
|
+
let count = this.#callsInFlight;
|
|
166
|
+
for (const tracked of this.#runs.values())
|
|
167
|
+
if (!isFinalRunStatus(tracked.view.status)) count += 1;
|
|
168
|
+
for (const tracked of this.#live.values())
|
|
169
|
+
if (tracked.view.loading && tracked.watchers.size > 0) count += 1;
|
|
170
|
+
return count;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Notes a change. Listeners hear about it once per animation frame (FAILURE_MODEL §6), or in a
|
|
175
|
+
* microtask where a page has no frames to draw (a hidden tab, outside a browser), so a source
|
|
176
|
+
* producing hundreds of values a second renders at most once a frame.
|
|
177
|
+
*/
|
|
178
|
+
#changed(): void {
|
|
179
|
+
this.#version += 1;
|
|
180
|
+
if (this.#notifyScheduled) return;
|
|
181
|
+
this.#notifyScheduled = true;
|
|
182
|
+
const notify = (): void => {
|
|
183
|
+
this.#notifyScheduled = false;
|
|
184
|
+
for (const listener of [...this.#listeners]) listener();
|
|
185
|
+
};
|
|
186
|
+
const visible =
|
|
187
|
+
typeof document === "undefined" ? false : document.visibilityState === "visible";
|
|
188
|
+
if (visible && typeof requestAnimationFrame === "function") requestAnimationFrame(notify);
|
|
189
|
+
else queueMicrotask(notify);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#transport(what: string): Transport & Required<Pick<Transport, "call">> {
|
|
193
|
+
const transport = this.#options.transport;
|
|
194
|
+
if (!transport?.call || this.#options.viewId === undefined) throw targetUnavailable(what);
|
|
195
|
+
return transport as Transport & Required<Pick<Transport, "call">>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
get #viewId(): string {
|
|
199
|
+
return this.#options.viewId ?? "";
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Plain calls (D54) ──────────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Calls a server function: no record, the result as soon as its first batch arrives (an async
|
|
206
|
+
* iterable result keeps yielding as later batches come).
|
|
207
|
+
*/
|
|
208
|
+
async call(fnId: string, args: readonly unknown[]): Promise<unknown> {
|
|
209
|
+
const transport = this.#transport(`Calling the server function ${fnId}`);
|
|
210
|
+
const batches = await serializeAll([...args], {
|
|
211
|
+
mode: "argument",
|
|
212
|
+
serverFunctionId: (fn: AnyFunction) => serverFunctionId(fn),
|
|
213
|
+
});
|
|
214
|
+
const reader = new ValueReader({ serverFunction: (id) => this.reference(id) });
|
|
215
|
+
const lines = transport
|
|
216
|
+
.call(fnId, { viewId: this.#viewId, args: batches })
|
|
217
|
+
[Symbol.asyncIterator]();
|
|
218
|
+
let ended: Extract<CallLine, { end: unknown }>["end"] | undefined;
|
|
219
|
+
const pump = async (): Promise<void> => {
|
|
220
|
+
for (;;) {
|
|
221
|
+
const next = await lines.next();
|
|
222
|
+
if (next.done) break;
|
|
223
|
+
const line = next.value;
|
|
224
|
+
if ("batch" in line) reader.push(line.batch);
|
|
225
|
+
else ended = line.end;
|
|
226
|
+
}
|
|
227
|
+
if (ended?.status === "failed") throw failure(ended.error, "MOSTRA_ACTION_THREW");
|
|
228
|
+
if (!ended) throw new NetworkError("The call's answer ended early.");
|
|
229
|
+
};
|
|
230
|
+
const pumped = pump();
|
|
231
|
+
// The value as soon as it exists; a failure before it rejects the call.
|
|
232
|
+
this.#callsInFlight += 1;
|
|
233
|
+
try {
|
|
234
|
+
const root = await Promise.race([reader.root, pumped.then(() => reader.root)]);
|
|
235
|
+
pumped.catch((error: unknown) => reader.fail(error));
|
|
236
|
+
return root;
|
|
237
|
+
} finally {
|
|
238
|
+
this.#callsInFlight -= 1;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The client stub of a server function a result refers to. */
|
|
243
|
+
reference(id: string): AnyFunction {
|
|
244
|
+
const fn = ((...args: unknown[]) => this.call(id, args)) as AnyFunction;
|
|
245
|
+
Object.defineProperty(fn, "mostraServerFunctionId", { value: id, enumerable: true });
|
|
246
|
+
return fn;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Action runs (D24, D54) ─────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** What the page knows of a run. */
|
|
252
|
+
run(runId: string): RunView | undefined {
|
|
253
|
+
return this.#runs.get(runId)?.view;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Starts a run, or joins the one its key names (AC-03). The request carries a client key, so a
|
|
258
|
+
* retry after a dropped answer joins the run it started instead of starting another.
|
|
259
|
+
*/
|
|
260
|
+
async startRun(fnId: string, args: readonly unknown[], key?: string): Promise<RunView> {
|
|
261
|
+
const transport = this.#transport("Running actions");
|
|
262
|
+
const batches: Batches = await serializeAll([...args], {
|
|
263
|
+
mode: "argument",
|
|
264
|
+
serverFunctionId: (fn: AnyFunction) => serverFunctionId(fn),
|
|
265
|
+
});
|
|
266
|
+
const ck = clientKey();
|
|
267
|
+
let delay = 500;
|
|
268
|
+
for (let attempt = 1; ; attempt++) {
|
|
269
|
+
try {
|
|
270
|
+
const state = await (transport.startRun as NonNullable<Transport["startRun"]>)({
|
|
271
|
+
viewId: this.#viewId,
|
|
272
|
+
fnId,
|
|
273
|
+
args: batches,
|
|
274
|
+
...(key !== undefined ? { key } : {}),
|
|
275
|
+
clientKey: ck,
|
|
276
|
+
});
|
|
277
|
+
return this.#track(state);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (!(error instanceof NetworkError) || attempt >= START_ATTEMPTS) throw error;
|
|
280
|
+
await new Promise((resolve) => this.#setTimeout(() => resolve(undefined), delay));
|
|
281
|
+
delay = Math.min(delay * 2, 8_000);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Finds runs by ID, or by function and key (a reloaded page re-attaching, AC-02). */
|
|
287
|
+
async attach(input: {
|
|
288
|
+
readonly runIds?: readonly string[];
|
|
289
|
+
readonly keyed?: readonly { readonly fnId: string; readonly key: string }[];
|
|
290
|
+
}): Promise<RunView[]> {
|
|
291
|
+
const transport = this.#transport("Running actions");
|
|
292
|
+
// What the page holds of each run already isn't sent again (Opus review 14).
|
|
293
|
+
const held = (input.runIds ?? []).flatMap((runId) => {
|
|
294
|
+
const tracked = this.#runs.get(runId);
|
|
295
|
+
return tracked && (tracked.view.log.length > 0 || tracked.batches.length > 0)
|
|
296
|
+
? [{ runId, log: tracked.view.log.length, batches: tracked.batches.length }]
|
|
297
|
+
: [];
|
|
298
|
+
});
|
|
299
|
+
const { runs } = await (transport.attachRuns as NonNullable<Transport["attachRuns"]>)({
|
|
300
|
+
viewId: this.#viewId,
|
|
301
|
+
...(input.runIds && input.runIds.length > 0 ? { runIds: input.runIds } : {}),
|
|
302
|
+
...(input.keyed && input.keyed.length > 0 ? { keyed: input.keyed } : {}),
|
|
303
|
+
...(held.length > 0 ? { held } : {}),
|
|
304
|
+
});
|
|
305
|
+
return runs.map((state) => this.#track(state));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Cancels a run (AC-04): its `abortSignal` fires on the server. */
|
|
309
|
+
async cancel(runId: string): Promise<void> {
|
|
310
|
+
const transport = this.#transport("Cancelling actions");
|
|
311
|
+
const state = await (transport.cancelRun as NonNullable<Transport["cancelRun"]>)(
|
|
312
|
+
runId,
|
|
313
|
+
this.#viewId,
|
|
314
|
+
);
|
|
315
|
+
this.#track(state);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Resolves with the run's result once it succeeded; rejects when it failed or was cancelled. */
|
|
319
|
+
settled(runId: string): Promise<unknown> {
|
|
320
|
+
const tracked = this.#runs.get(runId);
|
|
321
|
+
if (!tracked) return Promise.reject(new MostraError("MOSTRA_ACTION_RUN_NOT_FOUND", runId));
|
|
322
|
+
return tracked.settled;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
#newRun(state: RunState): TrackedRun {
|
|
326
|
+
let resolve!: (value: unknown) => void;
|
|
327
|
+
let reject!: (error: unknown) => void;
|
|
328
|
+
const settled = new Promise<unknown>((res, rej) => {
|
|
329
|
+
resolve = res;
|
|
330
|
+
reject = rej;
|
|
331
|
+
});
|
|
332
|
+
settled.catch(() => {});
|
|
333
|
+
return {
|
|
334
|
+
view: {
|
|
335
|
+
runId: state.runId,
|
|
336
|
+
fnId: state.fnId,
|
|
337
|
+
...(state.key !== undefined ? { key: state.key } : {}),
|
|
338
|
+
status: state.status,
|
|
339
|
+
log: [],
|
|
340
|
+
partial: [],
|
|
341
|
+
},
|
|
342
|
+
batches: [],
|
|
343
|
+
reader: new ValueReader({ serverFunction: (id) => this.reference(id) }),
|
|
344
|
+
rootDecoded: false,
|
|
345
|
+
settled,
|
|
346
|
+
resolve,
|
|
347
|
+
reject,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Merges a full run state: the server's account wins. */
|
|
352
|
+
#track(state: RunState): RunView {
|
|
353
|
+
let tracked = this.#runs.get(state.runId);
|
|
354
|
+
if (!tracked) {
|
|
355
|
+
tracked = this.#newRun(state);
|
|
356
|
+
this.#runs.set(state.runId, tracked);
|
|
357
|
+
}
|
|
358
|
+
this.#merge(tracked, {
|
|
359
|
+
runId: state.runId,
|
|
360
|
+
status: state.status,
|
|
361
|
+
...(state.reason !== undefined ? { reason: state.reason } : {}),
|
|
362
|
+
...(state.progress ? { progress: state.progress } : {}),
|
|
363
|
+
logFrom: state.logFrom ?? 0,
|
|
364
|
+
log: state.log,
|
|
365
|
+
batchFrom: state.batchFrom ?? 0,
|
|
366
|
+
batches: state.batches,
|
|
367
|
+
...(state.error ? { error: state.error } : {}),
|
|
368
|
+
...(state.finishedAt ? { finishedAt: state.finishedAt } : {}),
|
|
369
|
+
});
|
|
370
|
+
// What the stream said of it before this answer arrived (a fast run's end).
|
|
371
|
+
const early = this.#early.get(state.runId);
|
|
372
|
+
if (early) {
|
|
373
|
+
this.#early.delete(state.runId);
|
|
374
|
+
const current = tracked;
|
|
375
|
+
if (!early.every((delta) => this.#merge(current, delta)))
|
|
376
|
+
void this.attach({ runIds: [state.runId] }).catch(() => {});
|
|
377
|
+
}
|
|
378
|
+
this.#renewSoon();
|
|
379
|
+
return tracked.view;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Applies a run delta; returns `false` when it skipped something the page doesn't have. */
|
|
383
|
+
#merge(tracked: TrackedRun, delta: RunDelta): boolean {
|
|
384
|
+
const view = tracked.view;
|
|
385
|
+
if (delta.logFrom > view.log.length || delta.batchFrom > tracked.batches.length) return false;
|
|
386
|
+
// A final state is final: a late "running" never takes it back.
|
|
387
|
+
if (isFinalRunStatus(view.status) && !isFinalRunStatus(delta.status)) return true;
|
|
388
|
+
const log = [...view.log, ...delta.log.slice(view.log.length - delta.logFrom)];
|
|
389
|
+
const fresh = delta.batches.slice(tracked.batches.length - delta.batchFrom);
|
|
390
|
+
for (const batch of fresh) {
|
|
391
|
+
tracked.batches.push([...batch]);
|
|
392
|
+
try {
|
|
393
|
+
tracked.reader.push(batch);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
tracked.reader.fail(error);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
tracked.view = {
|
|
399
|
+
...view,
|
|
400
|
+
status: delta.status,
|
|
401
|
+
...(delta.reason !== undefined ? { reason: delta.reason } : {}),
|
|
402
|
+
...(delta.progress ? { progress: delta.progress } : {}),
|
|
403
|
+
log,
|
|
404
|
+
...(delta.error ? { error: delta.error } : {}),
|
|
405
|
+
...(delta.finishedAt ? { finishedAt: delta.finishedAt } : {}),
|
|
406
|
+
};
|
|
407
|
+
if (fresh.length > 0 && !tracked.rootDecoded) this.#decodeRoot(tracked);
|
|
408
|
+
if (isFinalRunStatus(delta.status)) this.#settle(tracked);
|
|
409
|
+
this.#changed();
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#decodeRoot(tracked: TrackedRun): void {
|
|
414
|
+
tracked.rootDecoded = true;
|
|
415
|
+
void tracked.reader.root.then(
|
|
416
|
+
(root) => {
|
|
417
|
+
tracked.view = { ...tracked.view, data: root };
|
|
418
|
+
this.#changed();
|
|
419
|
+
// An async iterable result: its items as they come, for `partial`.
|
|
420
|
+
if (root !== null && typeof root === "object" && Symbol.asyncIterator in root) {
|
|
421
|
+
void (async () => {
|
|
422
|
+
try {
|
|
423
|
+
for await (const item of root as AsyncIterable<unknown>) {
|
|
424
|
+
tracked.view = { ...tracked.view, partial: [...tracked.view.partial, item] };
|
|
425
|
+
this.#changed();
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
// The run's own state says what went wrong.
|
|
429
|
+
}
|
|
430
|
+
})();
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
() => {},
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Settles the run's promise once it reached a final state. */
|
|
438
|
+
#settle(tracked: TrackedRun): void {
|
|
439
|
+
const { status, error } = tracked.view;
|
|
440
|
+
if (status === "succeeded") {
|
|
441
|
+
if (tracked.batches.length === 0) {
|
|
442
|
+
tracked.resolve(undefined);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (!tracked.rootDecoded) this.#decodeRoot(tracked);
|
|
446
|
+
tracked.reader.root.then(tracked.resolve, tracked.reject);
|
|
447
|
+
// Every batch is in: whatever they never settled won't be.
|
|
448
|
+
queueMicrotask(() => {
|
|
449
|
+
if (!tracked.reader.complete) {
|
|
450
|
+
tracked.reader.fail(new RemoteError({ message: "The result ended early." }));
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const ended =
|
|
456
|
+
status === "cancelled"
|
|
457
|
+
? (error ?? { code: "MOSTRA_ACTION_CANCELLED", message: "The run was cancelled." })
|
|
458
|
+
: error;
|
|
459
|
+
tracked.reader.fail(new RemoteError(ended ?? { message: "The action failed." }));
|
|
460
|
+
tracked.reject(
|
|
461
|
+
failure(ended, status === "cancelled" ? "MOSTRA_ACTION_CANCELLED" : "MOSTRA_ACTION_THREW"),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ── Live sources (D56) ─────────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
/** What the page knows of a live source. */
|
|
468
|
+
liveView(key: string): LiveView | undefined {
|
|
469
|
+
return this.#live.get(key)?.view;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Shows a live source: reads it now and keeps it produced while the page shows it. Returns the
|
|
474
|
+
* function that stops showing it.
|
|
475
|
+
*/
|
|
476
|
+
watchLive(key: string, window?: number): () => void {
|
|
477
|
+
let tracked = this.#live.get(key);
|
|
478
|
+
if (!tracked) {
|
|
479
|
+
tracked = {
|
|
480
|
+
view: { key, loading: true, ring: [] },
|
|
481
|
+
watchers: new Map(),
|
|
482
|
+
newest: 0,
|
|
483
|
+
ringRevisions: [],
|
|
484
|
+
applying: Promise.resolve(),
|
|
485
|
+
};
|
|
486
|
+
this.#live.set(key, tracked);
|
|
487
|
+
}
|
|
488
|
+
const id = this.#nextWatcher++;
|
|
489
|
+
tracked.watchers.set(id, window);
|
|
490
|
+
this.#flushLive();
|
|
491
|
+
return () => {
|
|
492
|
+
tracked.watchers.delete(id);
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Asks the source's producer to load again now (`useLive`'s `refresh()`). */
|
|
497
|
+
refreshLive(key: string): void {
|
|
498
|
+
this.#liveRefresh.add(key);
|
|
499
|
+
this.#flushLive();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#windowOf(tracked: TrackedLive): number | undefined {
|
|
503
|
+
let largest: number | undefined;
|
|
504
|
+
for (const window of tracked.watchers.values()) {
|
|
505
|
+
if (window !== undefined) largest = Math.max(largest ?? 0, window);
|
|
506
|
+
}
|
|
507
|
+
return largest;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Reads every shown source in one request, coalesced within a tick. */
|
|
511
|
+
#flushLive(): void {
|
|
512
|
+
if (this.#liveFlush) return;
|
|
513
|
+
this.#liveFlush = Promise.resolve().then(async () => {
|
|
514
|
+
this.#liveFlush = undefined;
|
|
515
|
+
const shown = [...this.#live.values()].filter((tracked) => tracked.watchers.size > 0);
|
|
516
|
+
if (shown.length === 0) return;
|
|
517
|
+
const refresh = this.#liveRefresh;
|
|
518
|
+
this.#liveRefresh = new Set();
|
|
519
|
+
let transport: Transport;
|
|
520
|
+
try {
|
|
521
|
+
transport = this.#transport("Live data");
|
|
522
|
+
} catch (error) {
|
|
523
|
+
for (const tracked of shown) {
|
|
524
|
+
const diagnostic = error as MostraError;
|
|
525
|
+
tracked.view = {
|
|
526
|
+
...tracked.view,
|
|
527
|
+
loading: false,
|
|
528
|
+
error: { message: diagnostic.message, code: diagnostic.code },
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
this.#changed();
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
const { sources } = await (transport.live as NonNullable<Transport["live"]>)({
|
|
536
|
+
viewId: this.#viewId,
|
|
537
|
+
keys: shown.map((tracked) => {
|
|
538
|
+
const window = this.#windowOf(tracked);
|
|
539
|
+
return {
|
|
540
|
+
key: tracked.view.key,
|
|
541
|
+
...(window !== undefined ? { window } : {}),
|
|
542
|
+
...(refresh.has(tracked.view.key) ? { refresh: true } : {}),
|
|
543
|
+
};
|
|
544
|
+
}),
|
|
545
|
+
});
|
|
546
|
+
for (const source of sources) await this.#applyLiveState(source);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
for (const tracked of shown) {
|
|
549
|
+
if (tracked.view.revision !== undefined) continue;
|
|
550
|
+
tracked.view = {
|
|
551
|
+
...tracked.view,
|
|
552
|
+
...(error instanceof MostraError
|
|
553
|
+
? { loading: false, error: { message: error.message, code: error.code } }
|
|
554
|
+
: {}),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
this.#changed();
|
|
558
|
+
}
|
|
559
|
+
this.#renewSoon();
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Applies `apply` to a source after everything that arrived for it before. */
|
|
564
|
+
#inOrder(key: string, apply: (tracked: TrackedLive) => Promise<void>): Promise<void> {
|
|
565
|
+
const tracked = this.#live.get(key);
|
|
566
|
+
if (!tracked) return Promise.resolve();
|
|
567
|
+
const next = tracked.applying.then(() => apply(tracked)).catch(() => {});
|
|
568
|
+
tracked.applying = next;
|
|
569
|
+
return next;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
#applyLiveState(state: LiveState): Promise<void> {
|
|
573
|
+
return this.#inOrder(state.key, (tracked) => this.#applyLiveStateNow(tracked, state));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
#applyLiveUpdate(update: LiveUpdate): Promise<void> {
|
|
577
|
+
const tracked = this.#live.get(update.key);
|
|
578
|
+
if (!tracked) return Promise.resolve();
|
|
579
|
+
// In latest mode only the newest value shows: one still waiting its turn is replaced, never
|
|
580
|
+
// decoded (Opus review 12). Append mode keeps every value for its ring.
|
|
581
|
+
if (update.value !== undefined && this.#windowOf(tracked) === undefined) {
|
|
582
|
+
const waiting = tracked.pending;
|
|
583
|
+
// Never for an older one arriving late.
|
|
584
|
+
if (!waiting || Number(update.revision ?? 0) > Number(waiting.revision ?? 0)) {
|
|
585
|
+
tracked.pending = update;
|
|
586
|
+
}
|
|
587
|
+
if (waiting) return tracked.applying;
|
|
588
|
+
return this.#inOrder(update.key, async (current) => {
|
|
589
|
+
const next = current.pending;
|
|
590
|
+
delete current.pending;
|
|
591
|
+
if (next) await this.#applyLiveUpdateNow(current, next);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return this.#inOrder(update.key, (current) => this.#applyLiveUpdateNow(current, update));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
async #applyLiveStateNow(tracked: TrackedLive, state: LiveState): Promise<void> {
|
|
598
|
+
const revision = Number(state.revision ?? 0);
|
|
599
|
+
if (state.ring) {
|
|
600
|
+
const values = await Promise.all(state.ring.map((entry) => decode(entry.value)));
|
|
601
|
+
const revisions = state.ring.map((entry) => Number(entry.revision));
|
|
602
|
+
// A read answered after newer updates arrived keeps them: the ring only moves forward.
|
|
603
|
+
const last = revisions.at(-1) ?? 0;
|
|
604
|
+
const kept = tracked.ringRevisions.flatMap((held, index) =>
|
|
605
|
+
held > last ? [{ revision: held, value: tracked.view.ring[index] }] : [],
|
|
606
|
+
);
|
|
607
|
+
const window = this.#windowOf(tracked);
|
|
608
|
+
const merged = [...values, ...kept.map((entry) => entry.value)];
|
|
609
|
+
const mergedRevisions = [...revisions, ...kept.map((entry) => entry.revision)];
|
|
610
|
+
tracked.ringRevisions =
|
|
611
|
+
window === undefined ? mergedRevisions : mergedRevisions.slice(-window);
|
|
612
|
+
tracked.view = {
|
|
613
|
+
...tracked.view,
|
|
614
|
+
ring: window === undefined ? merged : merged.slice(-window),
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
if (state.value && revision >= tracked.newest) {
|
|
618
|
+
tracked.newest = revision;
|
|
619
|
+
const data = await decode(state.value);
|
|
620
|
+
if (revision < tracked.newest) return;
|
|
621
|
+
tracked.view = {
|
|
622
|
+
...tracked.view,
|
|
623
|
+
data,
|
|
624
|
+
...(state.revision !== undefined ? { revision: state.revision } : {}),
|
|
625
|
+
...(state.at !== undefined ? { at: state.at } : {}),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
tracked.view = {
|
|
629
|
+
...tracked.view,
|
|
630
|
+
loading: false,
|
|
631
|
+
...(state.error ? { error: state.error } : {}),
|
|
632
|
+
};
|
|
633
|
+
if (!state.error && tracked.view.error) {
|
|
634
|
+
const { error: _cleared, ...rest } = tracked.view;
|
|
635
|
+
tracked.view = rest;
|
|
636
|
+
}
|
|
637
|
+
this.#changed();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async #applyLiveUpdateNow(tracked: TrackedLive, update: LiveUpdate): Promise<void> {
|
|
641
|
+
if (update.error && !update.value) {
|
|
642
|
+
tracked.view = { ...tracked.view, loading: false, error: update.error };
|
|
643
|
+
this.#changed();
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
const revision = Number(update.revision ?? 0);
|
|
647
|
+
if (!update.value || revision <= tracked.newest) return;
|
|
648
|
+
// A value skipped (a dropped message): read the source again, ring included.
|
|
649
|
+
const skipped =
|
|
650
|
+
tracked.newest > 0 && revision > tracked.newest + 1 && this.#windowOf(tracked) !== undefined;
|
|
651
|
+
tracked.newest = revision;
|
|
652
|
+
const data = await decode(update.value);
|
|
653
|
+
const window = this.#windowOf(tracked);
|
|
654
|
+
const ring =
|
|
655
|
+
window === undefined ? tracked.view.ring : [...tracked.view.ring, data].slice(-window);
|
|
656
|
+
if (window !== undefined)
|
|
657
|
+
tracked.ringRevisions = [...tracked.ringRevisions, revision].slice(-window);
|
|
658
|
+
const { error: _cleared, ...rest } = tracked.view;
|
|
659
|
+
tracked.view = {
|
|
660
|
+
...rest,
|
|
661
|
+
loading: false,
|
|
662
|
+
data,
|
|
663
|
+
ring,
|
|
664
|
+
revision: String(revision),
|
|
665
|
+
...(update.at !== undefined ? { at: update.at } : {}),
|
|
666
|
+
};
|
|
667
|
+
this.#changed();
|
|
668
|
+
if (skipped) this.#flushLive();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ── The stream ─────────────────────────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
/** One ephemeral message from the page's stream. */
|
|
674
|
+
receive(message: EphemeralMessage): void {
|
|
675
|
+
if (message.kind === "run") {
|
|
676
|
+
const tracked = this.#runs.get(message.run.runId);
|
|
677
|
+
if (!tracked) {
|
|
678
|
+
this.#keepEarly(message.run);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!this.#merge(tracked, message.run))
|
|
682
|
+
void this.attach({ runIds: [message.run.runId] }).catch(() => {});
|
|
683
|
+
} else {
|
|
684
|
+
void this.#applyLiveUpdate(message.live);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** Keeps an update for a run the page will track once its start is answered. */
|
|
689
|
+
#keepEarly(delta: RunDelta): void {
|
|
690
|
+
const kept = this.#early.get(delta.runId);
|
|
691
|
+
if (kept) {
|
|
692
|
+
if (kept.length < EARLY_DELTAS_PER_RUN) kept.push(delta);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
if (this.#early.size >= EARLY_RUNS) {
|
|
696
|
+
const oldest = this.#early.keys().next().value;
|
|
697
|
+
if (oldest !== undefined) this.#early.delete(oldest);
|
|
698
|
+
}
|
|
699
|
+
this.#early.set(delta.runId, [delta]);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** The stream came back after a gap: read again what may have been missed. */
|
|
703
|
+
resync(): void {
|
|
704
|
+
const open = [...this.#runs.values()].filter(
|
|
705
|
+
(tracked) => !isFinalRunStatus(tracked.view.status),
|
|
706
|
+
);
|
|
707
|
+
if (open.length > 0) {
|
|
708
|
+
void this.attach({ runIds: open.map((tracked) => tracked.view.runId) }).catch(() => {});
|
|
709
|
+
}
|
|
710
|
+
this.#flushLive();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/** Renews interest in open runs and shown sources before their lease ends. */
|
|
714
|
+
#renewSoon(): void {
|
|
715
|
+
if (this.#renewTimer !== undefined) return;
|
|
716
|
+
this.#renewTimer = this.#setTimeout(() => {
|
|
717
|
+
this.#renewTimer = undefined;
|
|
718
|
+
const open = [...this.#runs.values()].filter(
|
|
719
|
+
(tracked) => !isFinalRunStatus(tracked.view.status),
|
|
720
|
+
);
|
|
721
|
+
const shown = [...this.#live.values()].some((tracked) => tracked.watchers.size > 0);
|
|
722
|
+
if (open.length > 0 || shown) {
|
|
723
|
+
this.resync();
|
|
724
|
+
this.#renewSoon();
|
|
725
|
+
}
|
|
726
|
+
}, RENEW_MS);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#setTimeout(fn: () => void, ms: number): unknown {
|
|
730
|
+
return this.#options.timers ? this.#options.timers.setTimeout(fn, ms) : setTimeout(fn, ms);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Stops renewing (the page is going away, or a test ends). */
|
|
734
|
+
stop(): void {
|
|
735
|
+
if (this.#renewTimer === undefined) return;
|
|
736
|
+
if (this.#options.timers) this.#options.timers.clearTimeout(this.#renewTimer);
|
|
737
|
+
else clearTimeout(this.#renewTimer as ReturnType<typeof setTimeout>);
|
|
738
|
+
this.#renewTimer = undefined;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** A serialized live value, read back. */
|
|
743
|
+
function decode(value: Batches): Promise<unknown> {
|
|
744
|
+
return deserializeAll(value);
|
|
745
|
+
}
|