@mostra/worker 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/src/worker.ts ADDED
@@ -0,0 +1,722 @@
1
+ /**
2
+ * The worker's main thread (ARCHITECTURE §7, D124): loads one artifact version's server bundle and
3
+ * runs its server functions and live producers, each call inside the request context of
4
+ * `@mostra/server` (D19 seam 3). It speaks the worker channel (`@mostra/protocol`'s `worker.ts`)
5
+ * through its channel thread, never sees a cookie, a token or a raw browser request, and gets
6
+ * secrets only by asking for them by name.
7
+ */
8
+ import {
9
+ type Dirent,
10
+ type FSWatcher,
11
+ readdirSync,
12
+ realpathSync,
13
+ statSync,
14
+ watch as watchFiles,
15
+ } from "node:fs";
16
+ import { readFile } from "node:fs/promises";
17
+ import { isBuiltin, registerHooks } from "node:module";
18
+ import { join, matchesGlob, relative, resolve, sep } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { Worker } from "node:worker_threads";
21
+ import {
22
+ type Batches,
23
+ createDiagnostic,
24
+ deserializeAll,
25
+ encodeLine,
26
+ type Frame,
27
+ type LiveSourceInfo,
28
+ RpcError,
29
+ RpcPeer,
30
+ SerializationError,
31
+ serializeAll,
32
+ ValueWriter,
33
+ WORKER_CHANNEL_REVISION,
34
+ WORKER_METHODS,
35
+ type WorkerCallOutcome,
36
+ WorkerCallParams,
37
+ WorkerStartParams,
38
+ type WorkerStartResult,
39
+ wireError,
40
+ } from "@mostra/protocol";
41
+ import {
42
+ currentCall,
43
+ installServerHost,
44
+ type LiveSourceOptions,
45
+ liveSourceOptions,
46
+ parseDuration,
47
+ runWithContext,
48
+ type StateSnapshot,
49
+ } from "@mostra/server/host";
50
+ import { createSharedState, SECRET_SLOT, sharedViews } from "./shared.ts";
51
+
52
+ /** How the worker is started. */
53
+ export interface WorkerOptions {
54
+ /** `fd:<n>` (a pipe the launcher passed) or `unix:<path>` (a socket to connect to). */
55
+ readonly channel: string;
56
+ /** The version's directory, which the launcher resolved from the version reference. */
57
+ readonly versionDir: string;
58
+ /** The artifact's source directory, when it still exists: `watch` globs are relative to it. */
59
+ readonly sourceDir?: string;
60
+ /**
61
+ * The `node_modules` the version's packages resolve from (D135): the layout of the version's
62
+ * lockfile, or the warm set's. The server bundle keeps every package import external, so the
63
+ * packages run from their own directories: native addons load, and files a package reads
64
+ * beside itself are there.
65
+ */
66
+ readonly modules?: string;
67
+ }
68
+
69
+ type AnyFunction = (...args: unknown[]) => unknown;
70
+
71
+ /** The longest wait for a secret: the host port's 30 s and a margin. */
72
+ const SECRET_WAIT_MS = 40_000;
73
+
74
+ /** How often the event loop proves it turns, for the watchdog (D62). */
75
+ const BEAT_MS = 100;
76
+
77
+ const ownExtension = import.meta.url.endsWith(".ts") ? ".ts" : ".js";
78
+
79
+ /** Whether `specifier` names a package (`react`, `@scope/name/sub`), not a path, URL or built-in. */
80
+ function isPackageSpecifier(specifier: string): boolean {
81
+ if (specifier.startsWith(".") || specifier.startsWith("/") || specifier.startsWith("#")) {
82
+ return false;
83
+ }
84
+ if (/^[a-z][a-z0-9+.-]*:/i.test(specifier)) return false; // node:, file:, data:, https:
85
+ return !isBuiltin(specifier);
86
+ }
87
+
88
+ /**
89
+ * Resolves the version's imports (D135): `@mostra/server` is this worker's own copy, so they share
90
+ * context; every other package the server bundle imports resolves from `modules`, the layout the
91
+ * version was built against, as though the bundle sat beside it. Imports inside packages resolve
92
+ * as Node resolves them, from the packages' own directories.
93
+ */
94
+ function resolveVersionImports(versionDir: string, modules: string | undefined): void {
95
+ const entries = {
96
+ "@mostra/server": import.meta.resolve("@mostra/server"),
97
+ "@mostra/server/host": import.meta.resolve("@mostra/server/host"),
98
+ } as Record<string, string>;
99
+ // The bundle's imports name it by its real path (Node resolves symlinks), the launcher's
100
+ // argument perhaps through one (macOS's /tmp): either form is the version.
101
+ const versionUrls = [resolve(versionDir), realpathSync(versionDir)].map(
102
+ (dir) => `${pathToFileURL(dir).href}/`,
103
+ );
104
+ const layoutParent =
105
+ modules === undefined
106
+ ? undefined
107
+ : pathToFileURL(join(modules, "..", "mostra-server.mjs")).href;
108
+ registerHooks({
109
+ resolve(specifier, context, next) {
110
+ const url = entries[specifier];
111
+ if (url) return { url, shortCircuit: true };
112
+ if (
113
+ layoutParent !== undefined &&
114
+ versionUrls.some((url) => context.parentURL?.startsWith(url) === true) &&
115
+ isPackageSpecifier(specifier)
116
+ ) {
117
+ return next(specifier, { ...context, parentURL: layoutParent });
118
+ }
119
+ return next(specifier, context);
120
+ },
121
+ });
122
+ }
123
+
124
+ interface Call {
125
+ readonly controller: AbortController;
126
+ readonly pending: Set<Promise<unknown>>;
127
+ writer?: ValueWriter;
128
+ }
129
+
130
+ /** Runs a worker until its channel closes. */
131
+ export async function runWorker(options: WorkerOptions): Promise<void> {
132
+ resolveVersionImports(options.versionDir, options.modules);
133
+ const shared = createSharedState();
134
+ const views = sharedViews(shared);
135
+ const thread = new Worker(new URL(`./channel-thread${ownExtension}`, import.meta.url), {
136
+ workerData: { channel: options.channel, shared },
137
+ });
138
+ thread.unref();
139
+
140
+ const beat = (): void => {
141
+ Atomics.store(views.beat, 0, BigInt(Date.now()));
142
+ Atomics.store(views.beat, 1, BigInt(process.memoryUsage().heapUsed));
143
+ };
144
+ beat();
145
+ setInterval(beat, BEAT_MS);
146
+
147
+ const calls = new Map<string, Call>();
148
+ const functions = new Map<string, AnyFunction>();
149
+ const ids = new Map<AnyFunction, string>();
150
+ const producers = new Map<string, Producer>();
151
+ const sources = new Map<string, LiveSourceOptions<unknown>>();
152
+
153
+ const shutdown = (code: number): never => {
154
+ for (const producer of producers.values()) producer.stop();
155
+ try {
156
+ // Children of server code die with the worker (it leads its own process group).
157
+ process.kill(-process.pid, "SIGTERM");
158
+ } catch {
159
+ // Not a group leader (a launcher that runs the worker elsewhere): only this process ends.
160
+ }
161
+ process.exit(code);
162
+ };
163
+
164
+ const peer: RpcPeer = new RpcPeer({
165
+ send(message) {
166
+ thread.postMessage({ type: "line", line: encodeLine(message) });
167
+ },
168
+ onRequest(method, params) {
169
+ switch (method) {
170
+ case WORKER_METHODS.start:
171
+ return start(params);
172
+ case WORKER_METHODS.call:
173
+ return call(params);
174
+ case WORKER_METHODS.liveStart:
175
+ return liveStart(params);
176
+ case WORKER_METHODS.liveStop: {
177
+ const key = String((params as { key?: unknown }).key);
178
+ producers.get(key)?.stop();
179
+ producers.delete(key);
180
+ return {};
181
+ }
182
+ case WORKER_METHODS.stop:
183
+ setImmediate(() => shutdown(0));
184
+ return {};
185
+ default:
186
+ throw new RpcError(
187
+ createDiagnostic("MOSTRA_HOST_METHOD_UNKNOWN", {
188
+ message: `The worker serves no method ${method}.`,
189
+ }),
190
+ );
191
+ }
192
+ },
193
+ onNotification(method, params) {
194
+ if (method === WORKER_METHODS.cancel) {
195
+ const { callId, reason } = params as { callId?: string; reason?: string };
196
+ const entry = callId === undefined ? undefined : calls.get(callId);
197
+ if (entry) {
198
+ const error = new Error(reason ?? "The run was cancelled.");
199
+ (error as Error & { code: string }).code = "MOSTRA_ACTION_CANCELLED";
200
+ error.name = "AbortError";
201
+ entry.controller.abort(error);
202
+ }
203
+ } else if (method === WORKER_METHODS.liveRefresh) {
204
+ producers.get(String((params as { key?: unknown }).key))?.refresh();
205
+ }
206
+ },
207
+ onInternalError(error) {
208
+ process.stderr.write(`mostra worker: ${(error as Error)?.stack ?? String(error)}\n`);
209
+ },
210
+ });
211
+
212
+ thread.on("message", (message: { type: string; line?: string }) => {
213
+ if (message.type === "line" && message.line !== undefined) peer.receiveLine(message.line);
214
+ else if (message.type === "closed") shutdown(0);
215
+ });
216
+ thread.on("error", (error) => {
217
+ process.stderr.write(`mostra worker: the channel failed: ${error.message}\n`);
218
+ shutdown(1);
219
+ });
220
+ thread.on("exit", () => shutdown(0));
221
+
222
+ // ── @mostra/server's host ────────────────────────────────────────────────────────────────
223
+
224
+ const track = (callId: string | undefined, promise: Promise<unknown>): void => {
225
+ const entry = callId === undefined ? undefined : calls.get(callId);
226
+ const settled = promise.catch((error: unknown) => {
227
+ const line = `${(error as Error)?.message ?? String(error)}`;
228
+ peer.notify(WORKER_METHODS.log, {
229
+ ...(callId === undefined ? {} : { callId }),
230
+ line: `Mostra couldn't record it: ${line}`,
231
+ });
232
+ });
233
+ if (entry) {
234
+ entry.pending.add(settled);
235
+ void settled.finally(() => entry.pending.delete(settled));
236
+ }
237
+ };
238
+
239
+ installServerHost({
240
+ secret(name) {
241
+ Atomics.store(views.control, SECRET_SLOT.state, 1);
242
+ thread.postMessage({ type: "secret", name });
243
+ const outcome = Atomics.wait(views.control, SECRET_SLOT.state, 1, SECRET_WAIT_MS);
244
+ const length = Atomics.load(views.control, SECRET_SLOT.length);
245
+ const status = Atomics.load(views.control, SECRET_SLOT.status);
246
+ const text = new TextDecoder().decode(views.secretBytes.slice(0, length));
247
+ Atomics.store(views.control, SECRET_SLOT.state, 0);
248
+ if (outcome === "timed-out") {
249
+ throw Object.assign(new Error(`Reading the secret ${name} timed out.`), {
250
+ code: "MOSTRA_SECRET_MISSING",
251
+ });
252
+ }
253
+ if (status !== 0) {
254
+ const failure = JSON.parse(text) as { code: string; message: string };
255
+ throw Object.assign(new Error(failure.message), { code: failure.code });
256
+ }
257
+ return text;
258
+ },
259
+ progress(callId, p) {
260
+ peer.notify(WORKER_METHODS.progress, { callId, ...p });
261
+ },
262
+ log(callId, line) {
263
+ peer.notify(WORKER_METHODS.log, { ...(callId === undefined ? {} : { callId }), line });
264
+ },
265
+ signal(callId, name, payload) {
266
+ track(
267
+ callId,
268
+ peer.request(WORKER_METHODS.signal, {
269
+ ...(callId === undefined ? {} : { callId }),
270
+ name,
271
+ ...(payload === undefined ? {} : { payload }),
272
+ }),
273
+ );
274
+ },
275
+ emit(callId, type, payload) {
276
+ track(
277
+ callId,
278
+ peer.request(WORKER_METHODS.emit, {
279
+ ...(callId === undefined ? {} : { callId }),
280
+ type,
281
+ ...(payload === undefined ? {} : { payload }),
282
+ }),
283
+ );
284
+ },
285
+ async stateGet(stateIds) {
286
+ return (await peer.request(WORKER_METHODS.stateGet, {
287
+ ...(stateIds === undefined ? {} : { ids: [...stateIds] }),
288
+ })) as StateSnapshot;
289
+ },
290
+ async statePatch(callId, ops, writeOptions) {
291
+ return (await peer.request(WORKER_METHODS.statePatch, {
292
+ ...(callId === undefined ? {} : { callId }),
293
+ ops: [...ops],
294
+ ...writeOptions,
295
+ })) as never;
296
+ },
297
+ });
298
+
299
+ // Console output inside a call also goes to its run's log.
300
+ for (const level of ["log", "info", "warn", "error", "debug"] as const) {
301
+ const original = console[level].bind(console);
302
+ console[level] = (...args: unknown[]) => {
303
+ original(...args);
304
+ const active = currentCall();
305
+ if (active) {
306
+ const line = args
307
+ .map((arg) => (typeof arg === "string" ? arg : inspectLine(arg)))
308
+ .join(" ");
309
+ peer.notify(WORKER_METHODS.log, { callId: active.callId, line });
310
+ }
311
+ };
312
+ }
313
+
314
+ // ── Requests ─────────────────────────────────────────────────────────────────────────────
315
+
316
+ async function start(raw: unknown): Promise<WorkerStartResult> {
317
+ const params = WorkerStartParams.parse(raw);
318
+ const manifest = JSON.parse(
319
+ await readFile(join(options.versionDir, "manifest.json"), "utf8"),
320
+ ) as { outputDigest?: string };
321
+ if (manifest.outputDigest !== params.version.outputDigest) {
322
+ throw new RpcError(
323
+ createDiagnostic("MOSTRA_HOME_INTEGRITY", {
324
+ message: `The version directory doesn't hold ${params.version.versionId}: its output digest differs.`,
325
+ }),
326
+ );
327
+ }
328
+ const entry = resolve(options.versionDir, params.entry);
329
+ if (!entry.startsWith(`${resolve(options.versionDir)}${sep}`)) {
330
+ throw new RpcError(
331
+ createDiagnostic("MOSTRA_HOME_INTEGRITY", {
332
+ message: "The server entry leaves the version.",
333
+ }),
334
+ );
335
+ }
336
+ const bundle = (await import(pathToFileURL(entry).href)) as {
337
+ functions?: Record<string, unknown>;
338
+ live?: Record<string, unknown>;
339
+ };
340
+ for (const [id, fn] of Object.entries(bundle.functions ?? {})) {
341
+ if (typeof fn !== "function") continue;
342
+ functions.set(id, fn as AnyFunction);
343
+ ids.set(fn as AnyFunction, id);
344
+ }
345
+ const live: LiveSourceInfo[] = [];
346
+ for (const [key, value] of Object.entries(bundle.live ?? {})) {
347
+ const source = liveSourceOptions(value);
348
+ if (!source) continue;
349
+ sources.set(key, source);
350
+ live.push(describeSource(key, source));
351
+ }
352
+ return { revision: WORKER_CHANNEL_REVISION, functions: [...functions.keys()], live };
353
+ }
354
+
355
+ async function call(raw: unknown): Promise<WorkerCallOutcome> {
356
+ const params = WorkerCallParams.parse(raw);
357
+ const fn = functions.get(params.fnId);
358
+ if (!fn) {
359
+ return {
360
+ status: "failed",
361
+ error: {
362
+ code: "MOSTRA_ACTION_UNKNOWN_FUNCTION",
363
+ message: `This version has no server function ${params.fnId}.`,
364
+ },
365
+ };
366
+ }
367
+ const entry: Call = { controller: new AbortController(), pending: new Set() };
368
+ calls.set(params.callId, entry);
369
+ try {
370
+ let args: unknown;
371
+ try {
372
+ // A page's arguments arrive whole and are read in argument mode: no symbols or
373
+ // iterables, bounded bigints, and every promise in them settled (Opus review 1, 13).
374
+ args = await deserializeAll(params.args, {
375
+ mode: "argument",
376
+ serverFunction: (id) => serverFunction(id),
377
+ });
378
+ } catch (error) {
379
+ return { status: "failed", error: errorOf(error, "MOSTRA_ACTION_UNSERIALIZABLE") };
380
+ }
381
+ if (!Array.isArray(args)) {
382
+ return {
383
+ status: "failed",
384
+ error: { code: "MOSTRA_ACTION_UNSERIALIZABLE", message: "The arguments aren't a list." },
385
+ };
386
+ }
387
+ const context = {
388
+ kind: params.context.kind,
389
+ callId: params.callId,
390
+ dryRun: params.context.dryRun,
391
+ signal: entry.controller.signal,
392
+ ...(params.context.runId === undefined ? {} : { runId: params.context.runId }),
393
+ ...(params.context.viewer === undefined ? {} : { viewer: params.context.viewer }),
394
+ } satisfies Parameters<typeof runWithContext>[0];
395
+ let result: unknown;
396
+ try {
397
+ result = await runWithContext(context, () => fn(...(args as unknown[])));
398
+ } catch (error) {
399
+ await settlePending(entry);
400
+ if (entry.controller.signal.aborted && isAbort(error, entry.controller.signal)) {
401
+ return { status: "cancelled" };
402
+ }
403
+ return { status: "failed", error: errorOf(error, "MOSTRA_ACTION_THREW") };
404
+ }
405
+ const writer = new ValueWriter({
406
+ mode: "result",
407
+ serverFunctionId: (candidate) => ids.get(candidate as AnyFunction),
408
+ onBatch: (batch) =>
409
+ peer.notify(WORKER_METHODS.batch, { callId: params.callId, batch: batch as Frame[] }),
410
+ });
411
+ entry.writer = writer;
412
+ const stop = (): void => writer.abort();
413
+ try {
414
+ await runWithContext(context, async () => {
415
+ await writer.write(result);
416
+ // The action returned, so it ran to its end (FAILURE_MODEL §6, AC-04): a cancel from
417
+ // here on stops only the parts of the result still streaming, and the run is
418
+ // `cancelled` only if it cut one short.
419
+ if (entry.controller.signal.aborted) writer.abort();
420
+ else entry.controller.signal.addEventListener("abort", stop, { once: true });
421
+ await writer.done;
422
+ });
423
+ } catch (error) {
424
+ await settlePending(entry);
425
+ return {
426
+ status: "failed",
427
+ error: errorOf(
428
+ error,
429
+ error instanceof SerializationError ? error.code : "MOSTRA_ACTION_THREW",
430
+ ),
431
+ };
432
+ }
433
+ await settlePending(entry);
434
+ return { status: writer.cutShort ? "cancelled" : "succeeded" };
435
+ } finally {
436
+ calls.delete(params.callId);
437
+ }
438
+ }
439
+
440
+ async function settlePending(entry: Call): Promise<void> {
441
+ while (entry.pending.size > 0) await Promise.all([...entry.pending]);
442
+ }
443
+
444
+ function serverFunction(id: string): AnyFunction {
445
+ const fn = functions.get(id);
446
+ if (!fn) throw new SerializationError(`names no server function of this version (${id}).`);
447
+ return fn;
448
+ }
449
+
450
+ function liveStart(raw: unknown): Record<string, never> {
451
+ const key = String((raw as { key?: unknown }).key);
452
+ if (producers.has(key)) return {};
453
+ const source = sources.get(key);
454
+ if (!source) {
455
+ throw new RpcError(
456
+ createDiagnostic("MOSTRA_LIVE_SOURCE_FAILED", {
457
+ message: `live.ts defines no live source ${key}.`,
458
+ }),
459
+ );
460
+ }
461
+ const producer = new Producer(key, source, options.sourceDir, peer);
462
+ producers.set(key, producer);
463
+ producer.start();
464
+ return {};
465
+ }
466
+
467
+ // The worker lives until its channel closes (`shutdown`).
468
+ await new Promise<never>(() => {});
469
+ }
470
+
471
+ function inspectLine(value: unknown): string {
472
+ try {
473
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
474
+ } catch {
475
+ return String(value);
476
+ }
477
+ }
478
+
479
+ function isAbort(error: unknown, signal: AbortSignal): boolean {
480
+ return error === signal.reason || (error as Error)?.name === "AbortError";
481
+ }
482
+
483
+ function errorOf(
484
+ error: unknown,
485
+ fallback: string,
486
+ ): { code: string; message: string; stack?: string } {
487
+ const code = (error as { code?: unknown })?.code;
488
+ const message = error instanceof Error ? error.message : String(error);
489
+ const stack = error instanceof Error ? error.stack : undefined;
490
+ return {
491
+ code: typeof code === "string" && /^MOSTRA_[A-Z]+_[A-Z0-9_]+$/.test(code) ? code : fallback,
492
+ message,
493
+ ...(stack === undefined ? {} : { stack }),
494
+ };
495
+ }
496
+
497
+ function describeSource(key: string, source: LiveSourceOptions<unknown>): LiveSourceInfo {
498
+ const watch =
499
+ source.watch === undefined
500
+ ? undefined
501
+ : typeof source.watch === "string"
502
+ ? [source.watch]
503
+ : [...source.watch];
504
+ return {
505
+ key,
506
+ load: typeof source.load === "function",
507
+ stream: typeof source.stream === "function",
508
+ always: source.always === true,
509
+ ...(source.refresh === undefined ? {} : { refreshMs: parseDuration(source.refresh) }),
510
+ ...(watch === undefined ? {} : { watch }),
511
+ ...(source.ring === undefined ? {} : { ring: source.ring }),
512
+ };
513
+ }
514
+
515
+ /** How often watched files are compared, besides file events. */
516
+ const WATCH_POLL_MS = 2000;
517
+
518
+ /** Directories a watch never descends into. */
519
+ const UNWATCHED = new Set(["node_modules", ".git", ".mostra"]);
520
+
521
+ /** The sizes and change times of the files under `dir` that `matches`, as one string. */
522
+ function fingerprintOf(dir: string, matches: (path: string) => boolean): string {
523
+ const parts: string[] = [];
524
+ const walk = (relativeDir: string, depth: number): void => {
525
+ if (depth > 16) return;
526
+ let entries: Dirent[];
527
+ try {
528
+ entries = readdirSync(join(dir, relativeDir), { withFileTypes: true });
529
+ } catch {
530
+ return;
531
+ }
532
+ for (const entry of entries) {
533
+ const path = relativeDir ? `${relativeDir}/${entry.name}` : entry.name;
534
+ if (entry.isDirectory()) {
535
+ if (!UNWATCHED.has(entry.name)) walk(path, depth + 1);
536
+ } else if (matches(path)) {
537
+ try {
538
+ const stats = statSync(join(dir, path));
539
+ parts.push(`${path}:${stats.size}:${stats.mtimeMs}`);
540
+ } catch {
541
+ // Removed while walking: the next comparison sees it.
542
+ }
543
+ }
544
+ }
545
+ };
546
+ walk("", 0);
547
+ return parts.sort().join("|");
548
+ }
549
+
550
+ /** One running live source: its loads, refreshes, file watching and stream (D56). */
551
+ class Producer {
552
+ readonly #key: string;
553
+ readonly #source: LiveSourceOptions<unknown>;
554
+ readonly #sourceDir: string | undefined;
555
+ readonly #peer: RpcPeer;
556
+ #controller: AbortController | undefined;
557
+ #timer: ReturnType<typeof setInterval> | undefined;
558
+ #watcher: FSWatcher | undefined;
559
+ #poll: ReturnType<typeof setInterval> | undefined;
560
+ #debounce: ReturnType<typeof setTimeout> | undefined;
561
+ #loading = false;
562
+ #again = false;
563
+
564
+ constructor(
565
+ key: string,
566
+ source: LiveSourceOptions<unknown>,
567
+ sourceDir: string | undefined,
568
+ peer: RpcPeer,
569
+ ) {
570
+ this.#key = key;
571
+ this.#source = source;
572
+ this.#sourceDir = sourceDir;
573
+ this.#peer = peer;
574
+ }
575
+
576
+ start(): void {
577
+ if (this.#controller) return;
578
+ const controller = new AbortController();
579
+ this.#controller = controller;
580
+ if (this.#source.load) {
581
+ this.refresh();
582
+ if (this.#source.refresh) {
583
+ this.#timer = setInterval(() => this.refresh(), parseDuration(this.#source.refresh));
584
+ }
585
+ if (this.#source.watch !== undefined) this.#watch();
586
+ }
587
+ if (this.#source.stream) void this.#stream(controller.signal);
588
+ }
589
+
590
+ stop(): void {
591
+ this.#controller?.abort();
592
+ this.#controller = undefined;
593
+ if (this.#timer !== undefined) clearInterval(this.#timer);
594
+ this.#timer = undefined;
595
+ if (this.#debounce !== undefined) clearTimeout(this.#debounce);
596
+ this.#watcher?.close();
597
+ this.#watcher = undefined;
598
+ if (this.#poll !== undefined) clearInterval(this.#poll);
599
+ this.#poll = undefined;
600
+ }
601
+
602
+ /** Loads again now; a load already running is followed by one more. */
603
+ refresh(): void {
604
+ const load = this.#source.load;
605
+ if (!load || !this.#controller) return;
606
+ if (this.#loading) {
607
+ this.#again = true;
608
+ return;
609
+ }
610
+ this.#loading = true;
611
+ void (async () => {
612
+ try {
613
+ do {
614
+ this.#again = false;
615
+ try {
616
+ await this.#publish(await load());
617
+ } catch (error) {
618
+ this.#fail(error);
619
+ }
620
+ } while (this.#again && this.#controller);
621
+ } finally {
622
+ this.#loading = false;
623
+ }
624
+ })();
625
+ }
626
+
627
+ #watch(): void {
628
+ const globs =
629
+ typeof this.#source.watch === "string"
630
+ ? [this.#source.watch]
631
+ : [...(this.#source.watch ?? [])];
632
+ const dir = this.#sourceDir;
633
+ if (!dir) {
634
+ this.#fail(
635
+ Object.assign(
636
+ new Error(
637
+ `The live source ${this.#key} watches files, but its artifact's source directory is gone.`,
638
+ ),
639
+ { code: "MOSTRA_LIVE_SOURCE_FAILED" },
640
+ ),
641
+ );
642
+ return;
643
+ }
644
+ const patterns = globs.map((glob) => glob.replace(/^\.\//, ""));
645
+ const matches = (path: string): boolean =>
646
+ patterns.some((pattern) => matchesGlob(path, pattern));
647
+ const changed = (): void => {
648
+ if (this.#debounce !== undefined) clearTimeout(this.#debounce);
649
+ this.#debounce = setTimeout(() => this.refresh(), 100);
650
+ };
651
+ try {
652
+ this.#watcher = watchFiles(dir, { recursive: true }, (_event, name) => {
653
+ if (name === null) return;
654
+ const path = relative(dir, resolve(dir, name.toString())).split(sep).join("/");
655
+ if (matches(path)) changed();
656
+ });
657
+ } catch (error) {
658
+ this.#fail(error);
659
+ return;
660
+ }
661
+ // File events can be dropped (macOS coalesces and delays them), so the matching files'
662
+ // sizes and times are also compared every two seconds: a change is never missed for long.
663
+ let fingerprint = fingerprintOf(dir, matches);
664
+ this.#poll = setInterval(() => {
665
+ const next = fingerprintOf(dir, matches);
666
+ if (next !== fingerprint) {
667
+ fingerprint = next;
668
+ changed();
669
+ }
670
+ }, WATCH_POLL_MS);
671
+ }
672
+
673
+ async #stream(signal: AbortSignal): Promise<void> {
674
+ const stream = this.#source.stream;
675
+ if (!stream) return;
676
+ let backoff = 1_000;
677
+ while (!signal.aborted) {
678
+ try {
679
+ for await (const value of stream(signal)) {
680
+ if (signal.aborted) return;
681
+ await this.#publish(value);
682
+ backoff = 1_000;
683
+ }
684
+ return;
685
+ } catch (error) {
686
+ if (signal.aborted) return;
687
+ this.#fail(error);
688
+ await new Promise((resolve) => setTimeout(resolve, backoff));
689
+ backoff = Math.min(backoff * 2, 60_000);
690
+ }
691
+ }
692
+ }
693
+
694
+ async #publish(value: unknown): Promise<void> {
695
+ if (!this.#controller) return;
696
+ let batches: Batches;
697
+ try {
698
+ batches = await serializeAll(value, { mode: "argument" });
699
+ } catch (error) {
700
+ this.#fail(error);
701
+ return;
702
+ }
703
+ this.#peer.notify(WORKER_METHODS.liveValue, {
704
+ key: this.#key,
705
+ value: batches as unknown as Batches,
706
+ } as never);
707
+ }
708
+
709
+ #fail(error: unknown): void {
710
+ const failure = wireError(error);
711
+ this.#peer.notify(WORKER_METHODS.liveValue, {
712
+ key: this.#key,
713
+ error: {
714
+ message: failure.message,
715
+ code:
716
+ failure.code && /^MOSTRA_/.test(failure.code)
717
+ ? failure.code
718
+ : "MOSTRA_LIVE_SOURCE_FAILED",
719
+ },
720
+ });
721
+ }
722
+ }