@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,388 @@
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
+ import { z } from 'zod/v4';
77
+ /**
78
+ * The class every generated resident runner exports. One name for every
79
+ * runtime: the fabric names it unconditionally, so nothing about which program
80
+ * is running reaches this module.
81
+ */
82
+ export const RESIDENT_PROCESS_CLASS = 'NimbusProcess';
83
+ /**
84
+ * A generated module map. Only bounded, fixed-size module text rides inline;
85
+ * anything whose size is a function of the user's disk is named by VFS path
86
+ * and read when the facet loads, so the bytes are transient rather than
87
+ * resident in the coordinator's heap.
88
+ */
89
+ export const ResidentCodeSpecSchema = z.object({
90
+ compatibilityDate: z.string().min(1),
91
+ compatibilityFlags: z.array(z.string()),
92
+ mainModule: z.string().min(1),
93
+ /**
94
+ * Inline modules: fixed-size generated source, plus small wasm sidecars that
95
+ * come from the worker's own ASSETS rather than the user's disk.
96
+ */
97
+ modules: z.record(z.string(), z.union([z.string(), z.object({ wasm: z.instanceof(ArrayBuffer) })])),
98
+ /**
99
+ * Module name → absolute VFS path of a wasm image to materialize at load.
100
+ * This is how the big user-installed runtimes travel: ruby's
101
+ * interpreter+stdlib image alone is 34.3 MiB.
102
+ */
103
+ vfsWasmModules: z.record(z.string(), z.string()).optional(),
104
+ /**
105
+ * Module name → absolute VFS path of a GENERATED module source, read as
106
+ * UTF-8 at load. The same by-path posture as `vfsWasmModules`, for module
107
+ * text whose size is a function of the user's disk.
108
+ *
109
+ * A node facet carries a snapshot of that disk, and it is the largest thing
110
+ * Nimbus generates: pi's is 3096 cells and inline it serialized to
111
+ * 44,252,709 bytes. That text cannot be rebuilt from the user's files at
112
+ * load time either — two thirds of the cells are esbuild ESM→CJS output, and
113
+ * the manifest and metadata members are walks of the tree rather than files
114
+ * in it. So the generator materializes its output in the content-addressed
115
+ * image store below and the spec names it.
116
+ */
117
+ vfsTextModules: z.record(z.string(), z.string()).optional(),
118
+ });
119
+ /**
120
+ * The boot-spec union, with the staged arm's payload validated by the
121
+ * embedder's own stage schema. The fabric defines the SHAPE of the union —
122
+ * `staged` boots assemble through the registered {@link StagedBootAssembler},
123
+ * `code` boots through {@link residentLoaderConfig} — but what a stage IS
124
+ * belongs to whoever registered the assembler, so the schema is composed
125
+ * rather than fixed. The embedder parses with this at its RPC trust boundary;
126
+ * the assembler re-validates at use either way.
127
+ */
128
+ export function residentBootSpecSchema(stageSchema) {
129
+ return z.discriminatedUnion('kind', [
130
+ z.object({ kind: z.literal('staged'), stage: stageSchema }),
131
+ z.object({ kind: z.literal('code'), code: ResidentCodeSpecSchema }),
132
+ ]);
133
+ }
134
+ let _stagedBootAssembler = null;
135
+ /** Registered once at composition time, first-write-wins. */
136
+ export function setStagedBootAssembler(assembler) {
137
+ if (_stagedBootAssembler)
138
+ return;
139
+ _stagedBootAssembler = assembler;
140
+ }
141
+ export function requireStagedBootAssembler() {
142
+ if (!_stagedBootAssembler) {
143
+ throw new Error('fabric: no staged-boot assembler registered; a \'staged\' boot spec '
144
+ + 'cannot be assembled without one (setStagedBootAssembler)');
145
+ }
146
+ return _stagedBootAssembler;
147
+ }
148
+ // ── Boot-image store ────────────────────────────────────────────────────────
149
+ /**
150
+ * Where a generated module source is materialized so a boot spec can name it.
151
+ *
152
+ * Outside any user working tree on purpose. The passes that build a node
153
+ * facet's snapshot enumerate the process's cwd, so an image written under one
154
+ * would be swept into the next snapshot — and that snapshot is what produced
155
+ * the image, so each spawn would grow the thing it just wrote.
156
+ *
157
+ * Kernel-owned and world-readable: the generator writes as CRED_KERNEL, and
158
+ * every process reads through a supervisor binding that enforces its own
159
+ * credential. Mode 0644 is what makes the read succeed for any process by
160
+ * construction rather than by a privilege carve-out in the permission layer,
161
+ * and leaves the bytes beyond reach of the user whose program they encode.
162
+ */
163
+ export const FACET_IMAGE_DIR = 'var/lib/nimbus/facet-images';
164
+ /**
165
+ * An image is named by the SHA-256 of its own bytes, so its name IS its
166
+ * integrity check and a stale image is not something to invalidate but
167
+ * something that cannot be addressed: different generated text is a different
168
+ * path.
169
+ *
170
+ * What that actually dedups, measured on a deployed worker rather than
171
+ * assumed: a RESTART resolves to the image already there, because the fabric
172
+ * replays one unchanged boot spec. Two separate spawns of the same tool do
173
+ * NOT, whenever the generated text carries anything per-process: an
174
+ * attached-TTY spawn bakes `NIMBUS_CP_CHILD_PID` into `__NIMBUS_ARGS`, so `pi`
175
+ * twice wrote two images (2c3a90ad… then c5b74f1a…). A spawn with no attached
176
+ * TTY has no pid in its args and does dedup. Lifting argv/env/pid out of the
177
+ * generated text into `startArgs` would make every image per-PROGRAM and
178
+ * shareable across spawns and sessions; the sweep bounds the store either way.
179
+ */
180
+ export async function facetImageDigest(source) {
181
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(source));
182
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
183
+ }
184
+ export function facetImagePath(digest) {
185
+ return `/${FACET_IMAGE_DIR}/${digest}.js`;
186
+ }
187
+ /**
188
+ * The digest an image path claims, for the reader's verify-on-read. Content
189
+ * addressing only holds if the bytes are checked against the name they were
190
+ * fetched under; without that a truncated or overwritten image boots as
191
+ * silently-wrong code, which in a facet surfaces as an unattributable
192
+ * "Cannot find module" a long way from the corruption.
193
+ */
194
+ export function facetImagePathDigest(path) {
195
+ const match = /(?:^|\/)([0-9a-f]{64})\.js$/.exec(path);
196
+ return match ? match[1] : null;
197
+ }
198
+ /**
199
+ * Complete a resident-process module map: read every member the spec named by
200
+ * path, verifying each generated image against the digest its own path claims.
201
+ * Runs inside the loader's cache-miss callback, so the bytes exist only for
202
+ * the duration of the load.
203
+ */
204
+ export async function residentLoaderConfig(spec, disk) {
205
+ const resolved = {};
206
+ for (const [moduleName, path] of Object.entries(spec.vfsWasmModules ?? {})) {
207
+ const bytes = await disk.readFile(path);
208
+ resolved[moduleName] = {
209
+ wasm: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
210
+ };
211
+ }
212
+ for (const [moduleName, path] of Object.entries(spec.vfsTextModules ?? {})) {
213
+ resolved[moduleName] = await readFacetImage(disk, path);
214
+ }
215
+ return {
216
+ compatibilityDate: spec.compatibilityDate,
217
+ compatibilityFlags: spec.compatibilityFlags,
218
+ mainModule: spec.mainModule,
219
+ modules: { ...spec.modules, ...resolved },
220
+ };
221
+ }
222
+ /**
223
+ * Read one content-addressed facet image and verify it against the digest its
224
+ * path claims. Content addressing is only a guarantee if the bytes are checked
225
+ * against the name they arrived under: an image that was truncated, or
226
+ * replaced by something the generator never wrote, would otherwise be loaded
227
+ * as the program and fail somewhere inside it with no way back to the cause.
228
+ */
229
+ async function readFacetImage(disk, path) {
230
+ const expected = facetImagePathDigest(path);
231
+ if (!expected) {
232
+ throw new Error(`Nimbus: '${path}' is not a content-addressed facet image path`);
233
+ }
234
+ const source = new TextDecoder().decode(await disk.readFile(path));
235
+ const actual = await facetImageDigest(source);
236
+ if (actual !== expected) {
237
+ throw new Error(`Nimbus: facet image '${path}' does not match its digest (read ${actual}); `
238
+ + 'the image store is corrupt and the process cannot boot from it');
239
+ }
240
+ return source;
241
+ }
242
+ // ── Handle ──────────────────────────────────────────────────────────────────
243
+ /**
244
+ * Resource handle for one resident process — the whole surface the kernel
245
+ * above this module sees: `booted()` for the boot payload, `done` for death,
246
+ * `kill()` for teardown, `routeTarget` for inbound HTTP. Substrate-free: the
247
+ * kernel cannot tell from it where the process is running, and never asks.
248
+ *
249
+ * `done` settles when the process ends: for a `lifetime` runner that is its
250
+ * held-open startProcess settling (resolve on exit, reject on host death);
251
+ * for a `boot` runner it is the kill that releases the host.
252
+ *
253
+ * The handle is disposable so FacetManager's existing per-pid resource
254
+ * tracking tears a process down exactly the way it releases any other
255
+ * per-process resource.
256
+ */
257
+ export class ResidentProcessHandle {
258
+ done;
259
+ /**
260
+ * Inbound-HTTP target for PortRegistry: the running facet's own stub. A
261
+ * facet is a child actor, so its stub stays usable in request contexts long
262
+ * after the one that created it — which is the whole reason a resident
263
+ * process can serve a port at all.
264
+ */
265
+ routeTarget;
266
+ #booted;
267
+ #kill;
268
+ #killed = false;
269
+ #describe;
270
+ constructor(init) {
271
+ this.done = init.done;
272
+ this.#booted = init.booted;
273
+ this.routeTarget = init.routeTarget;
274
+ this.#kill = init.kill;
275
+ this.#describe = init.describe;
276
+ // Symbol.dispose may be absent from older lib targets; wire defensively
277
+ // so disposeRpcResource() (which probes for it) finds the disposer.
278
+ const disposeSym = Symbol.dispose;
279
+ if (disposeSym) {
280
+ Object.defineProperty(this, disposeSym, { value: () => this.kill() });
281
+ }
282
+ }
283
+ /**
284
+ * The runner's startProcess payload. The runner is started as part of the
285
+ * spawn, so this is a handle on that one boot — awaiting it twice is safe
286
+ * and never re-starts anything. For a `lifetime` runner it settles at exit.
287
+ */
288
+ booted() {
289
+ return this.#booted();
290
+ }
291
+ get killed() {
292
+ return this.#killed;
293
+ }
294
+ /** Human-readable placement, for the NIMBUS_DEBUG process-log line. */
295
+ describePlacement() {
296
+ return this.#describe();
297
+ }
298
+ /** Idempotent: abort the facet and release its isolate. */
299
+ kill() {
300
+ if (this.#killed)
301
+ return;
302
+ this.#killed = true;
303
+ try {
304
+ this.#kill();
305
+ }
306
+ catch { /* best-effort teardown */ }
307
+ }
308
+ }
309
+ /** A promise that settles only when the process is killed. */
310
+ function heldUntilKilled() {
311
+ let release = () => { };
312
+ const promise = new Promise((resolve) => { release = resolve; });
313
+ return { promise, release };
314
+ }
315
+ export class ProcessFabric {
316
+ host;
317
+ constructor(host) {
318
+ this.host = host;
319
+ }
320
+ /**
321
+ * Boot a resident process on this deployment's substrate and return its
322
+ * handle. Resolves once the process is up and its runner has been started;
323
+ * rejects on boot failure.
324
+ *
325
+ * There is no decision in here. The substrate was chosen once, for the
326
+ * deployment, and the only thing this method knows about it is the
327
+ * `ProcessHost` interface.
328
+ */
329
+ async startResidentProcess(spawn) {
330
+ // The facet-local append sequence starts at one when its module evaluates.
331
+ // Bind that sequence to this concrete incarnation, then retire it only
332
+ // after the host is released; a later incarnation must use a fresh one.
333
+ const writerId = crypto.randomUUID();
334
+ spawn.onWriterActivated(writerId);
335
+ let hosted;
336
+ try {
337
+ hosted = await this.host.open({
338
+ pid: spawn.pid,
339
+ workerKey: spawn.workerKey,
340
+ boot: spawn.boot,
341
+ writerId,
342
+ startArgs: spawn.startArgs,
343
+ });
344
+ }
345
+ catch (error) {
346
+ spawn.onWriterRetired(writerId);
347
+ throw error;
348
+ }
349
+ const held = heldUntilKilled();
350
+ // Runs at most once, however it is reached — the lifecycle ending, a kill,
351
+ // or both. Retiring the writer twice would revoke an identity a later
352
+ // incarnation had already been granted.
353
+ let releasing = null;
354
+ const release = () => {
355
+ if (!releasing) {
356
+ releasing = (async () => {
357
+ try {
358
+ await hosted.release();
359
+ }
360
+ finally {
361
+ spawn.onWriterRetired(writerId);
362
+ }
363
+ })();
364
+ releasing.catch(() => { });
365
+ }
366
+ return releasing;
367
+ };
368
+ // `lifetime`: the runner's startProcess IS the process, so its settlement
369
+ // is the lifecycle — a host that dies under it rejects `started`.
370
+ // `boot`: the runner returns once it is up and the process stays resident
371
+ // on its host, so residency ends at a kill — or at the host dying, which
372
+ // is the same thing happening to the process without anyone asking for it.
373
+ const done = (spawn.startContract === 'lifetime'
374
+ ? hosted.started.then(() => undefined)
375
+ : hosted.started.then(() => Promise.race([held.promise, hosted.lost]))).finally(() => release());
376
+ done.catch(() => { });
377
+ return new ResidentProcessHandle({
378
+ done,
379
+ booted: () => hosted.started,
380
+ routeTarget: {
381
+ handleHttpRequest: (request) => hosted.handleHttpRequest(request),
382
+ handleWebSocketRequest: (request) => hosted.handleWebSocketRequest(request),
383
+ },
384
+ kill: () => { held.release(); void release(); },
385
+ describe: () => hosted.describe(),
386
+ });
387
+ }
388
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * process-host.ts — the two substrates a resident process can run on, and the
3
+ * one value that picks between them.
4
+ *
5
+ * `process-fabric.ts` owns what a resident process IS. This module
6
+ * owns only where it lives, behind `ProcessHost`:
7
+ *
8
+ * facet — the process is a named child actor of the user's own session DO.
9
+ * peer — the process is a named child actor of a SIBLING session DO, and
10
+ * the coordinator reaches it over one held-open RPC.
11
+ *
12
+ * Both call the same `openResidentFacet`. The peer leg is not a second process
13
+ * implementation; it is the same call made on a different actor, which is why
14
+ * the runner, the boot spec, the class name, the writer handshake, the start
15
+ * contract and the lifecycle are shared code rather than parallel paths.
16
+ *
17
+ * Choosing between them
18
+ * ─────────────────────
19
+ * One value for the whole deployment, resolved by the embedder's config seam
20
+ * (Nimbus reads `NIMBUS_PROCESS_HOST` in the worker's own selector) and
21
+ * handed to `createProcessHost`. No spawn site chooses; no program name, mode
22
+ * or payload size reaches the choice. If a decision about a particular
23
+ * process ever appears here, the heavy/light classifier has grown back and
24
+ * should be deleted again.
25
+ *
26
+ * | | spawn | memory | CPU | SQLite |
27
+ * |----------|------------|-------------|------------|--------|
28
+ * | facet | 8-16 ms | independent | SHARED | own |
29
+ * | peer | 242-359 ms | independent | independent| own |
30
+ *
31
+ * Facet CPU is shared because facets are separate isolates inside ONE actor
32
+ * thread: awaiting I/O yields it completely (measured 0 ms of sibling impact),
33
+ * but a non-yielding loop stalls every sibling for its full duration
34
+ * (measured 6,852 ms). A peer pays ~20x the spawn cost to buy that back.
35
+ *
36
+ * What the peer leg has to do differently, and why none of it reaches the
37
+ * process
38
+ * ────────────────────────────────────────────────────────────────────────
39
+ * payloads — a whole structured-clone RPC value is capped at 32 MiB, and
40
+ * pi's node snapshot alone serializes to 44,252,709 bytes. Boot
41
+ * specs name their large members BY PATH, so what crosses is a
42
+ * path and the host reads the bytes off the coordinator's disk in
43
+ * 4 MiB ranges through the supervisor. Nothing large is ever an
44
+ * RPC argument, so nothing has to be streamed or replayed.
45
+ * requests — workerd refuses to transfer an object owned by a
46
+ * dynamically-loaded worker across a sibling-DO hop, so a request
47
+ * travels to the peer as PARTS and the response comes back as
48
+ * parts, both with plain `ReadableStream` bodies that RPC carries
49
+ * with flow control. A live SSE body still streams; nothing is
50
+ * buffered.
51
+ * liveness — the host leg is held open for the process's whole life, which
52
+ * is also what keeps the hosting DO resident. A peer therefore
53
+ * never outlives its coordinator: the coordinator dying cancels
54
+ * the inbound call and the facet dies with it. Nothing in this
55
+ * fabric arms an alarm, on either substrate.
56
+ *
57
+ * WebSockets are the one thing on that list the RPC path cannot carry at all.
58
+ * A 101 Response owns a live socket, and RPC's Request/Response transport
59
+ * reconstructs a value rather than handing the socket over, so an upgrade
60
+ * stays on FETCH semantics for every hop: a facet is fetched directly, and a
61
+ * peer is fetched as a service binding which then fetches its hosted facet.
62
+ * Two headers carry what the RPC arguments would have — see
63
+ * {@link HOSTED_WEBSOCKET_KEY_HEADER} — and a per-process capability makes
64
+ * that pair unforgeable by anything that did not open the process.
65
+ */
66
+ import { type ProcessHost, type ResidentDiskReader } from './process-fabric.js';
67
+ /** The substrates this deployment can be configured for. */
68
+ export type ProcessHostMode = 'facet' | 'peer';
69
+ /**
70
+ * The substrate for this deployment, resolved once. The mode arrives already
71
+ * decided — the embedder owns the config var that picks it, and refuses an
72
+ * unrecognized value there rather than defaulting, because a typo that
73
+ * silently kept the old substrate would make an operator's comparison a lie.
74
+ * `disk` is the coordinator's own filesystem reader; the peer host does not
75
+ * take it, because a peer reads the same disk through the supervisor instead.
76
+ */
77
+ export declare function createProcessHost(mode: ProcessHostMode, ctx: DurableObjectState, env: unknown, disk: () => ResidentDiskReader): ProcessHost;
78
+ export declare function isolateToken(): string;
79
+ /** Options the coordinator hands a hosting peer. */
80
+ export interface HostProcessOpts {
81
+ coordinatorDoId: string;
82
+ pid: number;
83
+ writerId: string;
84
+ workerKey: string;
85
+ /** Unforgeable capability for the fetch-semantic WebSocket hop. */
86
+ webSocketCapability: string;
87
+ startArgs?: unknown;
88
+ }
89
+ /**
90
+ * Inbound HTTP for a peer-hosted process travels as PARTS, not as a
91
+ * Request/Response pair: workerd refuses to transfer an object owned by a
92
+ * dynamically-loaded worker across a sibling-DO hop. Bodies are plain
93
+ * ReadableStreams, which RPC carries with flow control, so nothing is buffered
94
+ * and a live SSE body still streams.
95
+ */
96
+ export interface HostedHttpRequest {
97
+ method: string;
98
+ url: string;
99
+ headers: [string, string][];
100
+ body: ReadableStream | null;
101
+ }
102
+ export interface HostedHttpResponse {
103
+ status: number;
104
+ statusText: string;
105
+ headers: [string, string][];
106
+ body: ReadableStream | null;
107
+ }
108
+ /**
109
+ * Which hosted process a fetched upgrade is for. An upgrade cannot travel as
110
+ * RPC arguments, so the two values `_rpcRouteHostedHttp` would have taken ride
111
+ * as headers on the peer fetch instead.
112
+ *
113
+ * The key alone is guessable from a pid, so it is not enough on its own; the
114
+ * capability is minted per `open()` and known only to the coordinator that
115
+ * opened the process and the peer that hosts it. The receiving session strips
116
+ * both before the request reaches the process.
117
+ */
118
+ export declare const HOSTED_WEBSOCKET_KEY_HEADER = "x-nimbus-hosted-websocket";
119
+ export declare const HOSTED_WEBSOCKET_CAPABILITY_HEADER = "x-nimbus-hosted-websocket-capability";
120
+ /**
121
+ * Headers as pairs, with every `Set-Cookie` kept separate.
122
+ *
123
+ * Iterating a `Headers` combines same-named fields into one comma-joined
124
+ * value, and for `Set-Cookie` that is not reversible — `append` cannot split
125
+ * `a=1; Path=/, b=2; Path=/` back into two cookies, and a browser reading the
126
+ * merged form sets one malformed cookie instead of two. Every other field
127
+ * combines by comma legally, so only this one needs the separate accessor.
128
+ * A user's server setting two cookies must not depend on which substrate its
129
+ * process happened to run on.
130
+ */
131
+ export declare function headerPairs(headers: Headers): [string, string][];
132
+ //# sourceMappingURL=process-host.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"process-host.d.ts","sourceRoot":"","sources":["../src/process-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAKH,OAAO,EAGL,KAAK,WAAW,EAIhB,KAAK,kBAAkB,EAExB,MAAM,qBAAqB,CAAC;AAU7B,4DAA4D;AAC5D,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,CAAC;AAE/C;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,eAAe,EACrB,GAAG,EAAE,kBAAkB,EACvB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,kBAAkB,GAC7B,WAAW,CAIb;AAgFD,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,oDAAoD;AACpD,MAAM,WAAW,eAAe;IAC9B,eAAe,EAAE,MAAM,CAAC;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;IAC5B,IAAI,EAAE,cAAc,GAAG,IAAI,CAAC;CAC7B;AA2BD;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,8BAA8B,CAAC;AACvE,eAAO,MAAM,kCAAkC,yCAAyC,CAAC;AA0RzF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAWhE"}