@nimbus-sh/fabric 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.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +487 -0
  3. package/dist/alarms.d.ts +134 -0
  4. package/dist/alarms.d.ts.map +1 -0
  5. package/dist/alarms.js +214 -0
  6. package/dist/bindings.d.ts +316 -0
  7. package/dist/bindings.d.ts.map +1 -0
  8. package/dist/bindings.js +678 -0
  9. package/dist/ctx-exports.d.ts +47 -0
  10. package/dist/ctx-exports.d.ts.map +1 -0
  11. package/dist/ctx-exports.js +54 -0
  12. package/dist/facet-image-store.d.ts +112 -0
  13. package/dist/facet-image-store.d.ts.map +1 -0
  14. package/dist/facet-image-store.js +181 -0
  15. package/dist/fanout-pool.d.ts +223 -0
  16. package/dist/fanout-pool.d.ts.map +1 -0
  17. package/dist/fanout-pool.js +368 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25 -0
  21. package/dist/inner-do-registry.d.ts +41 -0
  22. package/dist/inner-do-registry.d.ts.map +1 -0
  23. package/dist/inner-do-registry.js +51 -0
  24. package/dist/launch-journal.d.ts +170 -0
  25. package/dist/launch-journal.d.ts.map +1 -0
  26. package/dist/launch-journal.js +154 -0
  27. package/dist/launch-pacer.d.ts +173 -0
  28. package/dist/launch-pacer.d.ts.map +1 -0
  29. package/dist/launch-pacer.js +193 -0
  30. package/dist/loader-ledger.d.ts +57 -0
  31. package/dist/loader-ledger.d.ts.map +1 -0
  32. package/dist/loader-ledger.js +91 -0
  33. package/dist/loader-pool.d.ts +315 -0
  34. package/dist/loader-pool.d.ts.map +1 -0
  35. package/dist/loader-pool.js +666 -0
  36. package/dist/process-fabric.d.ts +524 -0
  37. package/dist/process-fabric.d.ts.map +1 -0
  38. package/dist/process-fabric.js +388 -0
  39. package/dist/process-host.d.ts +132 -0
  40. package/dist/process-host.d.ts.map +1 -0
  41. package/dist/process-host.js +444 -0
  42. package/dist/vendor/errors.d.ts +24 -0
  43. package/dist/vendor/errors.d.ts.map +1 -0
  44. package/dist/vendor/errors.js +46 -0
  45. package/dist/vendor/serialize.d.ts +3 -0
  46. package/dist/vendor/serialize.d.ts.map +1 -0
  47. package/dist/vendor/serialize.js +25 -0
  48. package/dist/vendor/types.d.ts +69 -0
  49. package/dist/vendor/types.d.ts.map +1 -0
  50. package/dist/vendor/types.js +4 -0
  51. package/dist/workerd-facet-host.d.ts +207 -0
  52. package/dist/workerd-facet-host.d.ts.map +1 -0
  53. package/dist/workerd-facet-host.js +508 -0
  54. package/dist/ws-hibernation-config.d.ts +73 -0
  55. package/dist/ws-hibernation-config.d.ts.map +1 -0
  56. package/dist/ws-hibernation-config.js +93 -0
  57. package/package.json +62 -0
  58. package/src/alarms.ts +275 -0
  59. package/src/bindings.ts +871 -0
  60. package/src/ctx-exports.ts +77 -0
  61. package/src/facet-image-store.ts +196 -0
  62. package/src/fanout-pool.ts +503 -0
  63. package/src/index.ts +26 -0
  64. package/src/inner-do-registry.ts +58 -0
  65. package/src/launch-journal.ts +229 -0
  66. package/src/launch-pacer.ts +231 -0
  67. package/src/loader-ledger.ts +112 -0
  68. package/src/loader-pool.ts +984 -0
  69. package/src/process-fabric.ts +729 -0
  70. package/src/process-host.ts +566 -0
  71. package/src/vendor/errors.ts +56 -0
  72. package/src/vendor/serialize.ts +37 -0
  73. package/src/vendor/types.ts +75 -0
  74. package/src/workerd-facet-host.ts +694 -0
  75. package/src/ws-hibernation-config.ts +123 -0
@@ -0,0 +1,729 @@
1
+ /**
2
+ * process-fabric.ts — the resident-process scheduler, and the process half of
3
+ * the substrate it runs on.
4
+ *
5
+ * Every long-lived process Nimbus runs — node servers, python/ruby socket
6
+ * servers, an agent TUI and its headless server — runs as a **DO Facet**:
7
+ * a named child actor whose class comes from a dynamic worker, opened by
8
+ * `openResidentFacet` in `workerd-facet-host.ts`.
9
+ *
10
+ * ctx.facets.get(`proc-${pid}`, () => ({
11
+ * class: env.LOADER.get(workerKey, buildConfig)
12
+ * .getDurableObjectClass('NimbusProcess'),
13
+ * }))
14
+ *
15
+ * There is ONE process implementation. What varies is WHOSE `ctx` and `env`
16
+ * that call runs against — the user's own session DO, or a sibling DO acting
17
+ * as a host — and that choice is a single deployment-wide config value read in
18
+ * `process-host.ts`. Nothing here, and nothing above here, branches on
19
+ * which program is running: no spawn site picks its own substrate, and no
20
+ * program name, mode or payload size reaches the selection.
21
+ *
22
+ * What each substrate costs, all of it measured on the production
23
+ * compatibility shape (see `process-host.ts` for the operator-facing
24
+ * version of this table):
25
+ *
26
+ * facet — spawn 8-16 ms warm. Memory independent: its OWN ~208 MiB
27
+ * envelope, identical whether the coordinator holds 0 or 128 MiB,
28
+ * with 1,664 MiB live across 8 facets + parent. CPU SHARED with
29
+ * its siblings, because facets are separate isolates inside one
30
+ * actor thread: awaiting I/O yields that thread completely (a
31
+ * sibling's RPC latency while a facet parks on a socket, on stdin
32
+ * or on an outbound call is indistinguishable from idle) but
33
+ * sustained CPU stalls every sibling for its full duration —
34
+ * a python HTTP server at 32-way saturation held siblings under
35
+ * 1.06 s (p50 231 ms), an attached full-screen TUI held them at the
36
+ * 77 ms idle baseline, and a deliberate 9,956 ms CPU burn stalled
37
+ * them for 9,966 ms.
38
+ * peer — spawn 242-359 ms, because every spawn pays a DO create plus a
39
+ * SQLite open. Memory AND CPU both independent: the process runs
40
+ * in a different workerd process, verified per placement rather
41
+ * than assumed (see `_place` in `process-host.ts`).
42
+ *
43
+ * Both give the process its own SQLite. Neither changes what the process is:
44
+ * the runner, the boot spec, the class name, the writer handshake and the
45
+ * lifecycle contract are the same code either way.
46
+ *
47
+ * The facet's SUPERVISOR binding is minted for the COORDINATOR's doId, so
48
+ * every syscall — VFS read/write, stdout/stderr frames, stdin pump,
49
+ * registerPort, loopback HTTP — lands on the user's session DO wherever the
50
+ * process runs. Because that binding is minted by an actor rather than by a
51
+ * stateless entrypoint, it lives as long as the process does; nothing has to
52
+ * hold a call open to keep it alive.
53
+ *
54
+ * Boot specs
55
+ * ──────────
56
+ * A resident process boots from one of two specs, and in both cases the module
57
+ * map is assembled LAZILY inside the loader's cache-miss callback — so the
58
+ * artifact sources are materialized only when the facet actually starts, and
59
+ * only for as long as the load takes:
60
+ *
61
+ * staged — an embedder-defined stage spec; the registered
62
+ * {@link StagedBootAssembler} fetches the artifact sources
63
+ * (Nimbus's staged artifacts come from ASSETS).
64
+ * code — a generated module map (node / python / ruby runners). Fixed-size
65
+ * module text rides inline; anything sized by the user's disk is
66
+ * named BY VFS PATH and read through the injected disk reader. A
67
+ * ruby server's `ruby+stdlib.wasm` alone is 34.3 MiB and a node
68
+ * facet's disk snapshot reached 44 MB for pi.
69
+ *
70
+ * By-path is what lets a boot spec reach EITHER substrate. Inline, pi's node
71
+ * snapshot serialized to 44,252,709 bytes and died at workerd's 32 MiB RPC
72
+ * ceiling the moment it had to cross to a peer; named by path it sends zero
73
+ * bytes, and the host reads them off the coordinator's own disk through the
74
+ * `ResidentDiskReader` it was given.
75
+ */
76
+
77
+ import { z } from 'zod/v4';
78
+ import type { RouteableFacetTarget } from '@nimbus-sh/core/runtime/os-contracts.js';
79
+
80
+ /**
81
+ * The class every generated resident runner exports. One name for every
82
+ * runtime: the fabric names it unconditionally, so nothing about which program
83
+ * is running reaches this module.
84
+ */
85
+ export const RESIDENT_PROCESS_CLASS = 'NimbusProcess';
86
+
87
+ /**
88
+ * Runner contract for `startProcess()`. A property of the generated runner,
89
+ * not of placement:
90
+ *
91
+ * lifetime — the call is held open for the process's whole life and settles
92
+ * only at exit (an attached TUI or its held-open server, attached-TTY node).
93
+ * boot — the call returns a boot payload once the process is up and the
94
+ * facet stays resident as the coordinator's named child actor
95
+ * (node servers, the python/ruby socket runners).
96
+ */
97
+ export type StartContract = 'lifetime' | 'boot';
98
+
99
+ /**
100
+ * A generated module map. Only bounded, fixed-size module text rides inline;
101
+ * anything whose size is a function of the user's disk is named by VFS path
102
+ * and read when the facet loads, so the bytes are transient rather than
103
+ * resident in the coordinator's heap.
104
+ */
105
+ export const ResidentCodeSpecSchema = z.object({
106
+ compatibilityDate: z.string().min(1),
107
+ compatibilityFlags: z.array(z.string()),
108
+ mainModule: z.string().min(1),
109
+ /**
110
+ * Inline modules: fixed-size generated source, plus small wasm sidecars that
111
+ * come from the worker's own ASSETS rather than the user's disk.
112
+ */
113
+ modules: z.record(z.string(), z.union([z.string(), z.object({ wasm: z.instanceof(ArrayBuffer) })])),
114
+ /**
115
+ * Module name → absolute VFS path of a wasm image to materialize at load.
116
+ * This is how the big user-installed runtimes travel: ruby's
117
+ * interpreter+stdlib image alone is 34.3 MiB.
118
+ */
119
+ vfsWasmModules: z.record(z.string(), z.string()).optional(),
120
+ /**
121
+ * Module name → absolute VFS path of a GENERATED module source, read as
122
+ * UTF-8 at load. The same by-path posture as `vfsWasmModules`, for module
123
+ * text whose size is a function of the user's disk.
124
+ *
125
+ * A node facet carries a snapshot of that disk, and it is the largest thing
126
+ * Nimbus generates: pi's is 3096 cells and inline it serialized to
127
+ * 44,252,709 bytes. That text cannot be rebuilt from the user's files at
128
+ * load time either — two thirds of the cells are esbuild ESM→CJS output, and
129
+ * the manifest and metadata members are walks of the tree rather than files
130
+ * in it. So the generator materializes its output in the content-addressed
131
+ * image store below and the spec names it.
132
+ */
133
+ vfsTextModules: z.record(z.string(), z.string()).optional(),
134
+ });
135
+
136
+ export type ResidentCodeSpec = z.infer<typeof ResidentCodeSpecSchema>;
137
+
138
+ /**
139
+ * The boot-spec union, with the staged arm's payload validated by the
140
+ * embedder's own stage schema. The fabric defines the SHAPE of the union —
141
+ * `staged` boots assemble through the registered {@link StagedBootAssembler},
142
+ * `code` boots through {@link residentLoaderConfig} — but what a stage IS
143
+ * belongs to whoever registered the assembler, so the schema is composed
144
+ * rather than fixed. The embedder parses with this at its RPC trust boundary;
145
+ * the assembler re-validates at use either way.
146
+ */
147
+ export function residentBootSpecSchema<Stage extends z.ZodType>(stageSchema: Stage) {
148
+ return z.discriminatedUnion('kind', [
149
+ z.object({ kind: z.literal('staged'), stage: stageSchema }),
150
+ z.object({ kind: z.literal('code'), code: ResidentCodeSpecSchema }),
151
+ ]);
152
+ }
153
+
154
+ export type ResidentBootSpec =
155
+ | { kind: 'staged'; stage: unknown }
156
+ | { kind: 'code'; code: ResidentCodeSpec };
157
+
158
+ // ── Staged boots ────────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Assemble a complete Worker Loader config from a staged-artifact spec. The
162
+ * embedder supplies this: a stage names artifact sources only the embedder
163
+ * knows how to fetch (Nimbus's largest staged artifact is a ~23 MB module map from
164
+ * ASSETS), and the assembler runs inside the loader's cache-miss callback so
165
+ * those sources are materialized only while the facet actually loads.
166
+ * `env` is whichever hosting actor's env the facet is opened with.
167
+ */
168
+ export type StagedBootAssembler = (
169
+ env: unknown,
170
+ stage: unknown,
171
+ ) => Promise<object>;
172
+
173
+ let _stagedBootAssembler: StagedBootAssembler | null = null;
174
+
175
+ /** Registered once at composition time, first-write-wins. */
176
+ export function setStagedBootAssembler(assembler: StagedBootAssembler): void {
177
+ if (_stagedBootAssembler) return;
178
+ _stagedBootAssembler = assembler;
179
+ }
180
+
181
+ export function requireStagedBootAssembler(): StagedBootAssembler {
182
+ if (!_stagedBootAssembler) {
183
+ throw new Error(
184
+ 'fabric: no staged-boot assembler registered; a \'staged\' boot spec '
185
+ + 'cannot be assembled without one (setStagedBootAssembler)',
186
+ );
187
+ }
188
+ return _stagedBootAssembler;
189
+ }
190
+
191
+ // ── Boot-image store ────────────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Where a generated module source is materialized so a boot spec can name it.
195
+ *
196
+ * Outside any user working tree on purpose. The passes that build a node
197
+ * facet's snapshot enumerate the process's cwd, so an image written under one
198
+ * would be swept into the next snapshot — and that snapshot is what produced
199
+ * the image, so each spawn would grow the thing it just wrote.
200
+ *
201
+ * Kernel-owned and world-readable: the generator writes as CRED_KERNEL, and
202
+ * every process reads through a supervisor binding that enforces its own
203
+ * credential. Mode 0644 is what makes the read succeed for any process by
204
+ * construction rather than by a privilege carve-out in the permission layer,
205
+ * and leaves the bytes beyond reach of the user whose program they encode.
206
+ */
207
+ export const FACET_IMAGE_DIR = 'var/lib/nimbus/facet-images';
208
+
209
+ /**
210
+ * An image is named by the SHA-256 of its own bytes, so its name IS its
211
+ * integrity check and a stale image is not something to invalidate but
212
+ * something that cannot be addressed: different generated text is a different
213
+ * path.
214
+ *
215
+ * What that actually dedups, measured on a deployed worker rather than
216
+ * assumed: a RESTART resolves to the image already there, because the fabric
217
+ * replays one unchanged boot spec. Two separate spawns of the same tool do
218
+ * NOT, whenever the generated text carries anything per-process: an
219
+ * attached-TTY spawn bakes `NIMBUS_CP_CHILD_PID` into `__NIMBUS_ARGS`, so `pi`
220
+ * twice wrote two images (2c3a90ad… then c5b74f1a…). A spawn with no attached
221
+ * TTY has no pid in its args and does dedup. Lifting argv/env/pid out of the
222
+ * generated text into `startArgs` would make every image per-PROGRAM and
223
+ * shareable across spawns and sessions; the sweep bounds the store either way.
224
+ */
225
+ export async function facetImageDigest(source: string): Promise<string> {
226
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
227
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
228
+ }
229
+
230
+ export function facetImagePath(digest: string): string {
231
+ return `/${FACET_IMAGE_DIR}/${digest}.js`;
232
+ }
233
+
234
+ /**
235
+ * The digest an image path claims, for the reader's verify-on-read. Content
236
+ * addressing only holds if the bytes are checked against the name they were
237
+ * fetched under; without that a truncated or overwritten image boots as
238
+ * silently-wrong code, which in a facet surfaces as an unattributable
239
+ * "Cannot find module" a long way from the corruption.
240
+ */
241
+ export function facetImagePathDigest(path: string): string | null {
242
+ const match = /(?:^|\/)([0-9a-f]{64})\.js$/.exec(path);
243
+ return match ? match[1] : null;
244
+ }
245
+
246
+ // ── Module-map assembly ─────────────────────────────────────────────────────
247
+
248
+ /**
249
+ * Reads the members a boot spec named by path off the SESSION's disk — the
250
+ * coordinator's, always, whichever substrate is doing the reading.
251
+ *
252
+ * The session supplies it, because it owns the filesystem and the credential
253
+ * the kernel reads its own image store with; the fabric never learns either.
254
+ * A host that runs inside the coordinator answers synchronously off the local
255
+ * VFS; a host that runs elsewhere answers over the supervisor RPC. That is the
256
+ * whole of the difference, and it is why the return type is widened rather
257
+ * than the reader duplicated.
258
+ */
259
+ export interface ResidentDiskReader {
260
+ readFile(path: string): Uint8Array | Promise<Uint8Array>;
261
+ }
262
+
263
+ /**
264
+ * Complete a resident-process module map: read every member the spec named by
265
+ * path, verifying each generated image against the digest its own path claims.
266
+ * Runs inside the loader's cache-miss callback, so the bytes exist only for
267
+ * the duration of the load.
268
+ */
269
+ export async function residentLoaderConfig(
270
+ spec: ResidentCodeSpec,
271
+ disk: ResidentDiskReader,
272
+ ): Promise<Record<string, unknown>> {
273
+ const resolved: Record<string, string | { wasm: ArrayBuffer }> = {};
274
+ for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
275
+ const bytes = await disk.readFile(path);
276
+ resolved[moduleName] = {
277
+ wasm: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
278
+ };
279
+ }
280
+ for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
281
+ resolved[moduleName] = await readFacetImage(disk, path);
282
+ }
283
+ return {
284
+ compatibilityDate: spec.compatibilityDate,
285
+ compatibilityFlags: spec.compatibilityFlags,
286
+ mainModule: spec.mainModule,
287
+ modules: { ...spec.modules, ...resolved },
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Read one content-addressed facet image and verify it against the digest its
293
+ * path claims. Content addressing is only a guarantee if the bytes are checked
294
+ * against the name they arrived under: an image that was truncated, or
295
+ * replaced by something the generator never wrote, would otherwise be loaded
296
+ * as the program and fail somewhere inside it with no way back to the cause.
297
+ */
298
+ async function readFacetImage(disk: ResidentDiskReader, path: string): Promise<string> {
299
+ const expected = facetImagePathDigest(path);
300
+ if (!expected) {
301
+ throw new Error(`Nimbus: '${path}' is not a content-addressed facet image path`);
302
+ }
303
+ const source = new TextDecoder().decode(await disk.readFile(path));
304
+ const actual = await facetImageDigest(source);
305
+ if (actual !== expected) {
306
+ throw new Error(
307
+ `Nimbus: facet image '${path}' does not match its digest (read ${actual}); `
308
+ + 'the image store is corrupt and the process cannot boot from it',
309
+ );
310
+ }
311
+ return source;
312
+ }
313
+
314
+ // ── The hosting substrate ───────────────────────────────────────────────────
315
+
316
+ /**
317
+ * The identity a resident process's SUPERVISOR binding is minted for. Always
318
+ * the COORDINATOR's — a process hosted somewhere else still reads and writes
319
+ * the user's disk, and still reports to the user's process table.
320
+ */
321
+ export interface ResidentSupervisorProps {
322
+ doId: string;
323
+ pid: number;
324
+ writerId: string;
325
+ }
326
+
327
+ /** Everything a host needs to run one process. Substrate-free by construction. */
328
+ export interface ProcessHostParams {
329
+ /** Supervisor-assigned pid of the process entry on the coordinator. */
330
+ pid: number;
331
+ /** Keyed dynamic-worker identity (`nimbus-process:${doId}:${pid}`). */
332
+ workerKey: string;
333
+ /** What the process boots from. */
334
+ boot: ResidentBootSpec;
335
+ /** Binds the facet-local append sequence to this concrete incarnation. */
336
+ writerId: string;
337
+ /** Forwarded verbatim to the runner's startProcess. */
338
+ startArgs: unknown;
339
+ }
340
+
341
+ /**
342
+ * One resident process, as its coordinator sees it. Identical in meaning on
343
+ * every substrate — that identity IS the abstraction, so a divergence here is
344
+ * a bug rather than a documented difference.
345
+ */
346
+ export interface HostedProcess {
347
+ /**
348
+ * The runner's startProcess payload. The runner is started as part of
349
+ * opening the host, so this is a handle on that one boot — awaiting it twice
350
+ * is safe and never re-starts anything. A `lifetime` runner settles it at
351
+ * exit; a host that dies before then rejects it.
352
+ */
353
+ readonly started: Promise<unknown>;
354
+ /**
355
+ * Rejects if the HOST dies under a process that is already up — the one
356
+ * failure a substrate can suffer that the process itself never reports.
357
+ *
358
+ * It is not symmetric, and pretending otherwise is what leaks a process. A
359
+ * facet dies only with the Durable Object that owns it, which takes the
360
+ * coordinator and this handle with it, so there is nothing to observe and
361
+ * this never settles; its death shows up at the next use, loudly. A peer can
362
+ * die on its own, the held host leg says so, and throwing that away would
363
+ * leave a `boot`-contract process routing to a corpse until someone killed
364
+ * it by hand.
365
+ */
366
+ readonly lost: Promise<never>;
367
+ /** Inbound HTTP for the process's registered ports. */
368
+ handleHttpRequest(request: Request): Promise<Response>;
369
+ /**
370
+ * Inbound WebSocket upgrade, on fetch semantics for every hop. A 101
371
+ * Response owns a live socket, which the parts-based RPC path cannot carry.
372
+ */
373
+ handleWebSocketRequest(request: Request): Promise<Response>;
374
+ /**
375
+ * Idempotent teardown. Settles only once the process is actually gone —
376
+ * on a remote host that is a round trip, and the writer identity this
377
+ * incarnation holds may not be retired before it completes.
378
+ */
379
+ release(): Promise<void>;
380
+ /** Human-readable placement, for the NIMBUS_DEBUG process-log line. */
381
+ describe(): string;
382
+ }
383
+
384
+ /**
385
+ * How a whole session-filesystem image can reach a process on a substrate, and
386
+ * what stops it.
387
+ *
388
+ * This is the one place the two substrates are NOT interchangeable, so it is
389
+ * stated rather than smoothed over. Everything else about a process is the
390
+ * same code either way; this is not, and an operator flipping the config is
391
+ * changing it.
392
+ */
393
+ export interface ProcessImageDelivery {
394
+ /**
395
+ * Whether the hosting actor can hand a process its whole SQLite by
396
+ * copy-on-write, present before the process's first instruction.
397
+ *
398
+ * `same-object` — possible in principle: the host and the source live in one
399
+ * Durable Object, which is the only scope `ctx.facets.clone` works in.
400
+ * Measured on production workerd at 18-31 ms for a 45.73 MB pi-shaped
401
+ * corpus and 34-54 ms for 1 GB — flat across a 256x size range, because
402
+ * nothing is copied.
403
+ * `impossible` — and not for want of an implementation. Clone is
404
+ * same-Durable-Object, bookmarks are same-Durable-Object, and workerd
405
+ * exposes no `VACUUM INTO`, no `ATTACH` and no `sqlite3_backup` to reach
406
+ * across one. A peer-hosted process can only ever receive an image
407
+ * through `moduleCeilingBytes` below, or by streaming it.
408
+ *
409
+ * Reachable in PRODUCTION but not from a type checker or `wrangler dev`, and
410
+ * the difference is worth stating precisely because inferring one from the
411
+ * other is how a wrong claim gets written down. `@cloudflare/workers-types`
412
+ * 4.20260605.1 declares `get`/`abort`/`delete` and no `clone`, and the pinned
413
+ * workerd is 1.20260603.1 — but the deployed runtime is Cloudflare's, not the
414
+ * one wrangler bundles, and there it is present and works: enumerating the
415
+ * binding on a live Worker at this repo's own compatibility_date returns
416
+ * `["abort","clone","constructor","delete","get"]`, and a clone into a
417
+ * destination of a DIFFERENT class had all 500 seeded files readable from the
418
+ * destination's CONSTRUCTOR. No compat-date gate. So calling it is a
419
+ * lockfile-and-types problem, not a platform one.
420
+ *
421
+ * The hazard that comes with it, measured rather than assumed: ANY `src`
422
+ * that does not resolve to a populated facet — a typo, a name not created
423
+ * yet, not merely the obvious `''`/`'.'`/`'/'` — silently EMPTIES the
424
+ * destination and reports success. Validation has to be positive on both
425
+ * ends: the source exists and is populated before, the destination is
426
+ * non-empty after. A blocklist of bad names would pass a typo straight
427
+ * through and wipe a process's filesystem while returning ok. Enforced by
428
+ * `cloneFacetStorage` in the workerd host, which is the one way the fabric
429
+ * calls clone.
430
+ */
431
+ readonly reflink: 'same-object' | 'impossible';
432
+ /**
433
+ * Bytes one process's whole module map may carry — the channel that does
434
+ * work today, on both substrates, because the loader runs on whichever actor
435
+ * hosts the facet. Enforced where the map is assembled, since the loader's
436
+ * own refusal names no member.
437
+ */
438
+ readonly moduleCeilingBytes: number;
439
+ /**
440
+ * Whether the process's SQLite is spent out of the SESSION's storage budget
441
+ * or its own. This cuts the opposite way from `reflink` and is why neither
442
+ * substrate simply wins: a facet shares roughly 10 GiB with the session root
443
+ * and every sibling and clone under it, with no copy-on-write credit — N
444
+ * forks of an X-byte image need X*(N+1) — and crossing it does not raise an
445
+ * error, it resets the object with "Internal error in Durable Object storage
446
+ * caused object to be reset". A peer brings its own budget per host.
447
+ */
448
+ readonly storageSharedWithSession: boolean;
449
+ }
450
+
451
+ /**
452
+ * The substrate a resident process runs on. One implementation per hosting
453
+ * mechanism, one selection for the whole deployment — see
454
+ * `process-host.ts`.
455
+ */
456
+ /**
457
+ * A one-shot's module map: every member inline.
458
+ *
459
+ * Deliberately without {@link ResidentCodeSpec}'s by-path members. A resident
460
+ * process names its large members by VFS path because the map has to reach
461
+ * whichever actor ends up hosting it; a one-shot's is assembled and consumed
462
+ * inside a single call, so a path buys nothing and a host that accepted one
463
+ * would be promising a read it never performs.
464
+ */
465
+ export interface OneShotCodeSpec {
466
+ compatibilityDate: string;
467
+ compatibilityFlags: string[];
468
+ mainModule: string;
469
+ modules: Record<string, string | { wasm: ArrayBuffer }>;
470
+ }
471
+
472
+ /**
473
+ * Everything a host needs to run one program to completion.
474
+ *
475
+ * Separate from {@link ProcessHostParams} because the two differ in whether
476
+ * anything survives the call, and every other difference follows from that: a
477
+ * one-shot has no route target, no independent death to observe and no
478
+ * residency to release. One spec carrying all of it would leave three members
479
+ * meaningless for half its uses.
480
+ */
481
+ export interface OneShotParams {
482
+ /** Supervisor-assigned pid — the identity the callback capability reports as. */
483
+ pid: number;
484
+ /**
485
+ * Binds this run's VFS appends to this concrete incarnation. Supplied by the
486
+ * caller rather than minted here so it can revoke the identity it authorised
487
+ * instead of one it has to read back.
488
+ */
489
+ writerId: string;
490
+ /**
491
+ * The module map, assembled on demand.
492
+ *
493
+ * A thunk, and that is load-bearing rather than stylistic. The map is the
494
+ * largest thing a session builds — pi's is ~23 MB — and it is dead the moment
495
+ * the loader has taken it. Building it inside the load is what keeps it out
496
+ * of the caller's frame, which would otherwise hold a second full copy of the
497
+ * program for as long as the program runs.
498
+ */
499
+ code(): Promise<OneShotCodeSpec>;
500
+ /** The invocation. Its body carries argv/env/cwd; its signal bounds the run. */
501
+ request: Request;
502
+ /**
503
+ * Called before any capability able to write as `writerId` exists, and only
504
+ * if this host can mint one at all. Granting append authority to an identity
505
+ * nothing will ever present would leave a writer live with no writer.
506
+ */
507
+ onWriterActivated(writerId: string): void;
508
+ /**
509
+ * Called once the program is loaded and about to be entered.
510
+ *
511
+ * The boundary between paying for the isolate and paying for the program.
512
+ * They are separate costs with separate fixes — a 12 s exec was once read as
513
+ * a slow load and was a fresh isolate parsing a 23 MB map — and only the host
514
+ * can see where one ends and the other begins.
515
+ */
516
+ onLoaded?(): void;
517
+ }
518
+
519
+ export interface ProcessHost {
520
+ /** What this substrate can and cannot deliver, for operators and callers. */
521
+ readonly imageDelivery: ProcessImageDelivery;
522
+ /**
523
+ * Run one program to completion and hand its response to `consume`.
524
+ *
525
+ * Scoped to the call rather than returned, because the isolate that produced
526
+ * the response must outlive the reading of its body. A host that released its
527
+ * stubs before the caller had read would sever a body still streaming, and
528
+ * one that buffered instead would hold a second copy of every result — the
529
+ * cost the thunk above exists to avoid. `consume` runs while the program's
530
+ * resources are still held; they are released as it returns.
531
+ */
532
+ runOnce<T>(params: OneShotParams, consume: (response: Response) => Promise<T>): Promise<T>;
533
+ open(params: ProcessHostParams): Promise<HostedProcess>;
534
+ }
535
+
536
+ /**
537
+ * How a caller supplies the substrate a process manager will run programs on.
538
+ *
539
+ * A factory rather than a finished {@link ProcessHost}, because the substrate
540
+ * needs the disk its processes boot from and only the manager can produce one:
541
+ * that reader answers as the credential that WROTE the boot images and
542
+ * deliberately uncached, since they are the largest files a session holds.
543
+ * Demanding a finished host would make every caller reproduce that policy, and
544
+ * a second copy of a credential rule is a second thing to keep in step.
545
+ *
546
+ * The parameters are exactly what a manager already holds, so the deployment's
547
+ * own selector (`processHostFor`) satisfies this type as it stands — the
548
+ * workerd substrate is named, not wrapped.
549
+ */
550
+ export type ProcessHostFactory = (
551
+ ctx: DurableObjectState,
552
+ env: unknown,
553
+ disk: () => ResidentDiskReader,
554
+ ) => ProcessHost;
555
+
556
+ // ── Handle ──────────────────────────────────────────────────────────────────
557
+
558
+ /**
559
+ * Resource handle for one resident process — the whole surface the kernel
560
+ * above this module sees: `booted()` for the boot payload, `done` for death,
561
+ * `kill()` for teardown, `routeTarget` for inbound HTTP. Substrate-free: the
562
+ * kernel cannot tell from it where the process is running, and never asks.
563
+ *
564
+ * `done` settles when the process ends: for a `lifetime` runner that is its
565
+ * held-open startProcess settling (resolve on exit, reject on host death);
566
+ * for a `boot` runner it is the kill that releases the host.
567
+ *
568
+ * The handle is disposable so FacetManager's existing per-pid resource
569
+ * tracking tears a process down exactly the way it releases any other
570
+ * per-process resource.
571
+ */
572
+ export class ResidentProcessHandle {
573
+ readonly done: Promise<void>;
574
+ /**
575
+ * Inbound-HTTP target for PortRegistry: the running facet's own stub. A
576
+ * facet is a child actor, so its stub stays usable in request contexts long
577
+ * after the one that created it — which is the whole reason a resident
578
+ * process can serve a port at all.
579
+ */
580
+ readonly routeTarget: RouteableFacetTarget;
581
+ #booted: () => Promise<unknown>;
582
+ #kill: () => void;
583
+ #killed = false;
584
+ #describe: () => string;
585
+
586
+ constructor(init: {
587
+ done: Promise<void>;
588
+ booted: () => Promise<unknown>;
589
+ routeTarget: RouteableFacetTarget;
590
+ kill: () => void;
591
+ describe: () => string;
592
+ }) {
593
+ this.done = init.done;
594
+ this.#booted = init.booted;
595
+ this.routeTarget = init.routeTarget;
596
+ this.#kill = init.kill;
597
+ this.#describe = init.describe;
598
+ // Symbol.dispose may be absent from older lib targets; wire defensively
599
+ // so disposeRpcResource() (which probes for it) finds the disposer.
600
+ const disposeSym = (Symbol as SymbolConstructor & { readonly dispose?: symbol }).dispose;
601
+ if (disposeSym) {
602
+ Object.defineProperty(this, disposeSym, { value: () => this.kill() });
603
+ }
604
+ }
605
+
606
+ /**
607
+ * The runner's startProcess payload. The runner is started as part of the
608
+ * spawn, so this is a handle on that one boot — awaiting it twice is safe
609
+ * and never re-starts anything. For a `lifetime` runner it settles at exit.
610
+ */
611
+ booted(): Promise<unknown> {
612
+ return this.#booted();
613
+ }
614
+
615
+ get killed(): boolean {
616
+ return this.#killed;
617
+ }
618
+
619
+ /** Human-readable placement, for the NIMBUS_DEBUG process-log line. */
620
+ describePlacement(): string {
621
+ return this.#describe();
622
+ }
623
+
624
+ /** Idempotent: abort the facet and release its isolate. */
625
+ kill(): void {
626
+ if (this.#killed) return;
627
+ this.#killed = true;
628
+ try { this.#kill(); } catch { /* best-effort teardown */ }
629
+ }
630
+ }
631
+
632
+ // ── The fabric ──────────────────────────────────────────────────────────────
633
+
634
+ export interface ResidentProcessSpawn {
635
+ /** Declared by the runner the primitive generates. */
636
+ startContract: StartContract;
637
+ /** Supervisor-assigned pid of the process entry on the coordinator. */
638
+ pid: number;
639
+ /** Keyed dynamic-worker identity (`nimbus-process:${doId}:${pid}`). */
640
+ workerKey: string;
641
+ /** What the facet boots from. */
642
+ boot: ResidentBootSpec;
643
+ /** Forwarded verbatim to the runner's startProcess. */
644
+ startArgs?: unknown;
645
+ /**
646
+ * Called before any concrete host capability can expose this writer.
647
+ * A spawn must not proceed unless the supervisor accepts the authority.
648
+ */
649
+ onWriterActivated: (writerId: string) => void;
650
+ /** Called only after the concrete host resources for this writer are revoked. */
651
+ onWriterRetired: (writerId: string) => void;
652
+ }
653
+
654
+ /** A promise that settles only when the process is killed. */
655
+ function heldUntilKilled(): { promise: Promise<void>; release: () => void } {
656
+ let release = () => {};
657
+ const promise = new Promise<void>((resolve) => { release = resolve; });
658
+ return { promise, release };
659
+ }
660
+
661
+ export class ProcessFabric {
662
+ constructor(private readonly host: ProcessHost) {}
663
+
664
+ /**
665
+ * Boot a resident process on this deployment's substrate and return its
666
+ * handle. Resolves once the process is up and its runner has been started;
667
+ * rejects on boot failure.
668
+ *
669
+ * There is no decision in here. The substrate was chosen once, for the
670
+ * deployment, and the only thing this method knows about it is the
671
+ * `ProcessHost` interface.
672
+ */
673
+ async startResidentProcess(spawn: ResidentProcessSpawn): Promise<ResidentProcessHandle> {
674
+ // The facet-local append sequence starts at one when its module evaluates.
675
+ // Bind that sequence to this concrete incarnation, then retire it only
676
+ // after the host is released; a later incarnation must use a fresh one.
677
+ const writerId = crypto.randomUUID();
678
+ spawn.onWriterActivated(writerId);
679
+
680
+ let hosted: HostedProcess;
681
+ try {
682
+ hosted = await this.host.open({
683
+ pid: spawn.pid,
684
+ workerKey: spawn.workerKey,
685
+ boot: spawn.boot,
686
+ writerId,
687
+ startArgs: spawn.startArgs,
688
+ });
689
+ } catch (error) {
690
+ spawn.onWriterRetired(writerId);
691
+ throw error;
692
+ }
693
+
694
+ const held = heldUntilKilled();
695
+ // Runs at most once, however it is reached — the lifecycle ending, a kill,
696
+ // or both. Retiring the writer twice would revoke an identity a later
697
+ // incarnation had already been granted.
698
+ let releasing: Promise<void> | null = null;
699
+ const release = (): Promise<void> => {
700
+ if (!releasing) {
701
+ releasing = (async () => {
702
+ try { await hosted.release(); } finally { spawn.onWriterRetired(writerId); }
703
+ })();
704
+ releasing.catch(() => {});
705
+ }
706
+ return releasing;
707
+ };
708
+ // `lifetime`: the runner's startProcess IS the process, so its settlement
709
+ // is the lifecycle — a host that dies under it rejects `started`.
710
+ // `boot`: the runner returns once it is up and the process stays resident
711
+ // on its host, so residency ends at a kill — or at the host dying, which
712
+ // is the same thing happening to the process without anyone asking for it.
713
+ const done = (spawn.startContract === 'lifetime'
714
+ ? hosted.started.then(() => undefined)
715
+ : hosted.started.then(() => Promise.race([held.promise, hosted.lost]))
716
+ ).finally(() => release());
717
+ done.catch(() => {});
718
+ return new ResidentProcessHandle({
719
+ done,
720
+ booted: () => hosted.started,
721
+ routeTarget: {
722
+ handleHttpRequest: (request: Request) => hosted.handleHttpRequest(request),
723
+ handleWebSocketRequest: (request: Request) => hosted.handleWebSocketRequest(request),
724
+ },
725
+ kill: () => { held.release(); void release(); },
726
+ describe: () => hosted.describe(),
727
+ });
728
+ }
729
+ }