@nimbus-sh/fabric 0.1.0 → 0.3.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 (104) hide show
  1. package/README.md +208 -293
  2. package/dist/bindings.js +5 -5
  3. package/dist/budgets.d.ts +132 -0
  4. package/dist/budgets.d.ts.map +1 -0
  5. package/dist/budgets.js +248 -0
  6. package/dist/composition.d.ts +3 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +2 -0
  9. package/dist/connections.d.ts +81 -0
  10. package/dist/connections.d.ts.map +1 -0
  11. package/dist/connections.js +114 -0
  12. package/dist/derived.d.ts +65 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +95 -0
  15. package/dist/do-calls.d.ts +94 -0
  16. package/dist/do-calls.d.ts.map +1 -0
  17. package/dist/do-calls.js +111 -0
  18. package/dist/facet-pool.d.ts +90 -0
  19. package/dist/facet-pool.d.ts.map +1 -0
  20. package/dist/facet-pool.js +113 -0
  21. package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
  22. package/dist/fanout.d.ts.map +1 -0
  23. package/dist/{fanout-pool.js → fanout.js} +20 -20
  24. package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/fenced-work.js +241 -0
  27. package/dist/generation.d.ts +69 -0
  28. package/dist/generation.d.ts.map +1 -0
  29. package/dist/generation.js +118 -0
  30. package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
  31. package/dist/image-store.d.ts.map +1 -0
  32. package/dist/{facet-image-store.js → image-store.js} +4 -4
  33. package/dist/index.d.ts +16 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -8
  36. package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
  37. package/dist/isolate-pool.d.ts.map +1 -0
  38. package/dist/{loader-pool.js → isolate-pool.js} +20 -20
  39. package/dist/journal.d.ts +111 -0
  40. package/dist/journal.d.ts.map +1 -0
  41. package/dist/journal.js +177 -0
  42. package/dist/outbox.d.ts +249 -0
  43. package/dist/outbox.d.ts.map +1 -0
  44. package/dist/outbox.js +355 -0
  45. package/dist/process-fabric.d.ts +33 -15
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +25 -15
  48. package/dist/process-host.d.ts +1 -1
  49. package/dist/process-host.d.ts.map +1 -1
  50. package/dist/process-host.js +19 -11
  51. package/dist/sealed.d.ts +78 -0
  52. package/dist/sealed.d.ts.map +1 -0
  53. package/dist/sealed.js +145 -0
  54. package/dist/timers.d.ts +138 -0
  55. package/dist/timers.d.ts.map +1 -0
  56. package/dist/timers.js +231 -0
  57. package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +24 -12
  60. package/dist/workerd-facet-host.d.ts +67 -70
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +129 -181
  63. package/examples/agent-core-adapter.ts +191 -0
  64. package/package.json +4 -2
  65. package/src/bindings.ts +6 -6
  66. package/src/budgets.ts +308 -0
  67. package/src/composition.ts +16 -0
  68. package/src/connections.ts +140 -0
  69. package/src/derived.ts +135 -0
  70. package/src/do-calls.ts +156 -0
  71. package/src/facet-pool.ts +157 -0
  72. package/src/{fanout-pool.ts → fanout.ts} +35 -35
  73. package/src/{launch-journal.ts → fenced-work.ts} +129 -42
  74. package/src/generation.ts +144 -0
  75. package/src/{facet-image-store.ts → image-store.ts} +9 -9
  76. package/src/index.ts +16 -8
  77. package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
  78. package/src/journal.ts +242 -0
  79. package/src/node-async-hooks.d.ts +14 -0
  80. package/src/outbox.ts +520 -0
  81. package/src/process-fabric.ts +43 -34
  82. package/src/process-host.ts +22 -20
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
  86. package/src/workerd-facet-host.ts +159 -208
  87. package/dist/alarms.d.ts +0 -134
  88. package/dist/alarms.d.ts.map +0 -1
  89. package/dist/alarms.js +0 -214
  90. package/dist/ctx-exports.d.ts +0 -47
  91. package/dist/ctx-exports.d.ts.map +0 -1
  92. package/dist/ctx-exports.js +0 -54
  93. package/dist/facet-image-store.d.ts.map +0 -1
  94. package/dist/fanout-pool.d.ts.map +0 -1
  95. package/dist/launch-journal.d.ts.map +0 -1
  96. package/dist/launch-journal.js +0 -154
  97. package/dist/launch-pacer.d.ts.map +0 -1
  98. package/dist/loader-ledger.d.ts +0 -57
  99. package/dist/loader-ledger.d.ts.map +0 -1
  100. package/dist/loader-ledger.js +0 -91
  101. package/dist/loader-pool.d.ts.map +0 -1
  102. package/src/alarms.ts +0 -275
  103. package/src/ctx-exports.ts +0 -77
  104. package/src/loader-ledger.ts +0 -112
@@ -13,20 +13,25 @@
13
13
  * `HostedProcess` and never imports this file.
14
14
  */
15
15
 
16
- import { disposeRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
16
+ import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
17
17
  import {
18
18
  getCtxExports,
19
+ stagedBootAssembler,
19
20
  supervisorEntrypoint,
20
21
  supervisorEntrypointName,
21
- } from './ctx-exports.js';
22
+ } from './composition.js';
22
23
  import {
24
+ assertModuleMapWithinCodeLimit,
23
25
  beginLoaderFetch,
26
+ facetNameCount,
27
+ facetNameCountDurable,
28
+ recordFacetNameMinted,
24
29
  recordLoaderId,
25
30
  withDynamicWorkerCapNamed,
26
- } from './loader-ledger.js';
31
+ withFacetBudgetNamed,
32
+ } from './budgets.js';
27
33
  import {
28
34
  RESIDENT_PROCESS_CLASS,
29
- requireStagedBootAssembler,
30
35
  residentLoaderConfig,
31
36
  type HostedProcess,
32
37
  type OneShotCodeSpec,
@@ -91,71 +96,6 @@ export async function createLoadedWorkerEntrypoint(
91
96
  });
92
97
  }
93
98
 
94
- /**
95
- * Total bytes a dynamic Worker's module map may carry, across every member of
96
- * it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
97
- * refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
98
- * size of 67108864 bytes", confirmed at five sizes with two trials each. The
99
- * budget is shared, so a ruby process is already 34.3 MiB down before its disk
100
- * is counted.
101
- */
102
- export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
103
-
104
- /**
105
- * Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
106
- * the largest members. The platform's own refusal reports one number for a
107
- * budget shared across every member of the map, which tells the operator
108
- * nothing about WHAT to shrink — so every fabric seam that assembles a map
109
- * runs this before the loader sees it.
110
- *
111
- * Costed to its two paths. Under the ceiling: one length read per member —
112
- * UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
113
- * text the generators emit and undercount otherwise; the platform's own
114
- * refusal still backstops the exotic case, because this check exists to name
115
- * members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
116
- * then, sorted so the biggest lever is first.
117
- */
118
- export function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>): void {
119
- let estimate = 0;
120
- for (const content of Object.values(modules)) {
121
- estimate += memberBytes(content, null);
122
- }
123
- if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES) return;
124
-
125
- const encoder = new TextEncoder();
126
- const sized = Object.entries(modules)
127
- .map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
128
- .sort((a, b) => b.bytes - a.bytes);
129
- const total = sized.reduce((sum, member) => sum + member.bytes, 0);
130
- const top = sized.slice(0, 5)
131
- .map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
132
- .join(', ');
133
- throw new Error(
134
- `Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
135
- + `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
136
- + `every member. Largest members: ${top}`,
137
- );
138
- }
139
-
140
- /**
141
- * Bytes one module-map member carries, across the loader's content kinds
142
- * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
143
- * encoder, text is measured exactly; without one, by code-unit length.
144
- */
145
- function memberBytes(content: unknown, encoder: TextEncoder | null): number {
146
- const textBytes = (text: string): number =>
147
- encoder ? encoder.encode(text).byteLength : text.length;
148
- if (typeof content === 'string') return textBytes(content);
149
- if (content !== null && typeof content === 'object') {
150
- for (const value of Object.values(content)) {
151
- if (typeof value === 'string') return textBytes(value);
152
- if (value instanceof ArrayBuffer) return value.byteLength;
153
- if (ArrayBuffer.isView(value)) return value.byteLength;
154
- }
155
- }
156
- return 0;
157
- }
158
-
159
99
  // ── Facet plumbing ──────────────────────────────────────────────────────────
160
100
 
161
101
  /** The subset of a facet stub a resident process exposes to whoever opened it. */
@@ -178,7 +118,7 @@ interface FacetContainer {
178
118
  /**
179
119
  * Present on deployed Cloudflare workerd, absent from the pinned
180
120
  * `@cloudflare/workers-types` and from local workerd ≤ 1.20260603.1 — see
181
- * {@link cloneFacetStorage}, the one way the fabric calls it.
121
+ * {@link cloneStorage}, the one way the fabric calls it.
182
122
  */
183
123
  clone?(src: string, dst: string): void;
184
124
  }
@@ -196,14 +136,19 @@ interface LoadedWorkerStub {
196
136
  * Durable Object class, so a resident process can be re-entered; `load` is
197
137
  * unkeyed and yields a stateless entrypoint, which is all a program that ends
198
138
  * with its call can ever need.
139
+ *
140
+ * `get` stays wide on purpose: the platform passes a null id for the unkeyed
141
+ * call and answers the callback with the code object or a promise of it, so a
142
+ * narrower declaration would refuse the real binding. The fabric itself always
143
+ * passes a string id and a promise callback.
199
144
  */
200
145
  interface WorkerLoaderBinding {
201
- get(id: string, code: () => Promise<unknown>): { getDurableObjectClass(name: string): unknown };
146
+ get(id: string | null, code: () => unknown): { getDurableObjectClass(name: string): unknown };
202
147
  load(code: unknown): LoadedWorkerStub;
203
148
  }
204
149
 
205
150
  /**
206
- * The bindings `openResidentFacet` needs off whichever DO is hosting. A
151
+ * The bindings `processes` needs off whichever DO is hosting. A
207
152
  * staged boot's assembler may read more off the same env (Nimbus's reads
208
153
  * ASSETS); the env travels to it whole, so nothing further is named here.
209
154
  */
@@ -250,7 +195,7 @@ function facetContainer(ctx: DurableObjectState): FacetContainer {
250
195
  * storage budget grants no copy-on-write credit — crossing it resets the
251
196
  * object rather than raising an error.
252
197
  */
253
- export async function cloneFacetStorage(
198
+ export async function cloneStorage(
254
199
  ctx: DurableObjectState,
255
200
  clone: {
256
201
  src: string;
@@ -283,7 +228,7 @@ export async function cloneFacetStorage(
283
228
  }
284
229
 
285
230
  /**
286
- * The facet name for a slot. Reused, and that is the entire point.
231
+ * The facet name for an ephemeral slot. Reused, and that is the entire point.
287
232
  *
288
233
  * A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
289
234
  * append-only and are never reclaimed, so the bound is on facets ever CREATED,
@@ -295,21 +240,19 @@ export async function cloneFacetStorage(
295
240
  * Reusing a NAME costs no new ID. So the name comes from a free list and the
296
241
  * pid stays what it always was: the process identity in the ProcessTable. The
297
242
  * two were only ever conflated because one of them happened to be handy.
243
+ *
244
+ * The book shares the facet-ID space with one other namespace: durable
245
+ * applications, which mint `app-slot-<n>` names of their own (one ID per app,
246
+ * ever). The prefixes are disjoint BY CONSTRUCTION, and that disjointness is
247
+ * load-bearing — a proc-slot name reissued onto a durable app's retained
248
+ * storage would boot the wrong process into someone else's disk.
298
249
  */
299
250
  export function residentFacetName(slot: number): string {
300
251
  return `proc-slot-${slot}`;
301
252
  }
302
253
 
303
- /**
304
- * Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
305
- * never reclaimed, so crossing it is unrecoverable for the object — which is
306
- * why the ledger below counts consumption durably instead of leaving the
307
- * bound as prose the slot book merely respects.
308
- */
309
- export const FACET_ID_LIFETIME_BUDGET = 65_536;
310
-
311
- /** Where the ledger persists the count of facet names ever minted. */
312
- export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
254
+ /** The prefix every durable application's facet name carries. */
255
+ export const DURABLE_FACET_NAME_PREFIX = 'app-slot-';
313
256
 
314
257
  /** One hosting actor's slot book. */
315
258
  interface SlotBook {
@@ -319,16 +262,6 @@ interface SlotBook {
319
262
  next: number;
320
263
  /** Slot held by each live pid, so release can find it. */
321
264
  held: Map<number, number>;
322
- /**
323
- * The durable high-water of names ever minted, as an adopt-then-advance
324
- * chain. It starts as the read of {@link FACET_NAME_HIGH_WATER_KEY} and
325
- * every later link writes only a LARGER count — a fresh incarnation restarts
326
- * `next` at zero, and a write that had not adopted first would clobber the
327
- * lifetime count down to this incarnation's. The chain never rejects.
328
- */
329
- ledger: Promise<number>;
330
- /** The largest count the chain has adopted or written, for sync reads. */
331
- ledgerKnown: number;
332
265
  }
333
266
 
334
267
  /**
@@ -345,52 +278,23 @@ interface SlotBook {
345
278
  * VFS epoch, in which case `invalidatedSince` can only answer poison and the
346
279
  * whole store is dropped. A process therefore cannot boot onto a previous
347
280
  * tenant's filesystem even when release never ran.
281
+ *
282
+ * The book names only the `proc-slot-` space. Durable `app-slot-` names are
283
+ * allocated against DO storage instead (their owner survives a reset), so a
284
+ * fresh incarnation's `next` starting at 0 can never collide with them even
285
+ * before the durable ledger is adopted.
348
286
  */
349
287
  const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
350
288
 
351
289
  function slotBook(ctx: DurableObjectState): SlotBook {
352
290
  let book = slotBooks.get(ctx);
353
291
  if (!book) {
354
- const created: SlotBook = {
355
- free: [],
356
- next: 0,
357
- held: new Map(),
358
- ledger: Promise.resolve(0),
359
- ledgerKnown: 0,
360
- };
361
- created.ledger = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
362
- .then((value) => (typeof value === 'number' ? value : 0))
363
- .catch(() => 0)
364
- .then((adopted) => {
365
- created.ledgerKnown = Math.max(created.ledgerKnown, adopted);
366
- return adopted;
367
- });
368
- book = created;
292
+ book = { free: [], next: 0, held: new Map() };
369
293
  slotBooks.set(ctx, book);
370
294
  }
371
295
  return book;
372
296
  }
373
297
 
374
- /**
375
- * The lifetime facet-ID ledger: how many facet names this fabric has ever
376
- * minted on the Durable Object, against the 65,536 the platform will ever
377
- * grant it. `consumed` only ever counts FIRST uses — a reused name, in this
378
- * incarnation or any earlier one, cost no new ID, which is the slot book's
379
- * whole reason to exist. Surfaced so an operator can see proximity to a wall
380
- * whose crossing is unrecoverable, instead of discovering it from the
381
- * platform's opaque failure.
382
- */
383
- export async function facetIdBudget(
384
- ctx: DurableObjectState,
385
- ): Promise<{ consumed: number; budget: number }> {
386
- const book = slotBook(ctx);
387
- const durable = await book.ledger;
388
- return {
389
- consumed: Math.max(durable, book.next),
390
- budget: FACET_ID_LIFETIME_BUDGET,
391
- };
392
- }
393
-
394
298
  /** Take a slot for `pid`, reusing a returned one before minting a new name. */
395
299
  function acquireSlot(ctx: DurableObjectState, pid: number): number {
396
300
  const book = slotBook(ctx);
@@ -399,49 +303,12 @@ function acquireSlot(ctx: DurableObjectState, pid: number): number {
399
303
  const reused = book.free.length > 0;
400
304
  const slot = reused ? book.free.shift()! : book.next++;
401
305
  book.held.set(pid, slot);
402
- if (!reused) recordNameMinted(ctx, book);
306
+ // A fresh name is a permanently consumed facet ID; the durable count lives
307
+ // in the budgets ledger (see budgets.ts).
308
+ if (!reused) recordFacetNameMinted(ctx, book.next);
403
309
  return slot;
404
310
  }
405
311
 
406
- /**
407
- * Advance the durable ledger to this incarnation's name count, if it is a new
408
- * lifetime high. Chained behind adoption so the comparison is always against
409
- * the real persisted value; a failed write leaves the old link's count and the
410
- * next mint tries again — the ledger may transiently undercount, never over.
411
- */
412
- function recordNameMinted(ctx: DurableObjectState, book: SlotBook): void {
413
- const count = book.next;
414
- book.ledger = book.ledger.then(async (durable) => {
415
- if (count <= durable) return durable;
416
- try {
417
- await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
418
- } catch {
419
- return durable;
420
- }
421
- book.ledgerKnown = Math.max(book.ledgerKnown, count);
422
- return count;
423
- });
424
- }
425
-
426
- /**
427
- * Name the facet-ID budget on a creation failure at the wall; below it, hand
428
- * the error back untouched. Exhaustion is the one failure here the platform
429
- * reports opaquely AND that no teardown, retry or reset can undo, so the
430
- * ledger — the only witness to the real cause — does the naming. Not a
431
- * threshold: the comparison is against the budget itself.
432
- */
433
- function withFacetBudgetNamed(consumed: number, error: unknown): unknown {
434
- if (consumed < FACET_ID_LIFETIME_BUDGET) return error;
435
- const platform = error instanceof Error ? error.message : String(error);
436
- return new Error(
437
- `Nimbus: facet creation failed with this Durable Object's `
438
- + `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
439
- + `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
440
- + `so this failure is permanent for the object: ${platform}`,
441
- { cause: error },
442
- );
443
- }
444
-
445
312
  /** Return `pid`'s slot to the free list. */
446
313
  function releaseSlot(ctx: DurableObjectState, pid: number): void {
447
314
  const book = slotBook(ctx);
@@ -452,29 +319,83 @@ function releaseSlot(ctx: DurableObjectState, pid: number): void {
452
319
  book.free.sort((a, b) => a - b);
453
320
  }
454
321
 
322
+ /**
323
+ * Drop one facet's SQLite by name — the ONLY call site that may delete facet
324
+ * storage. `spawnResident` releases ephemeral processes with abort+delete
325
+ * (storage is slot-reuse hygiene) and durable ones with abort alone (the
326
+ * storage IS the durable application's state); explicit removal arrives here
327
+ * through the coordinator's durable-slot book, owner-checked.
328
+ */
329
+ export function deleteFacetStorage(ctx: DurableObjectState, name: string): void {
330
+ facetContainer(ctx).delete(name);
331
+ }
332
+
333
+
455
334
 
456
335
  /**
457
- * What `openResidentFacet` hands back: a running process, minus its placement.
336
+ * What `processes(ctx, env).spawn` hands back: a running process, minus its placement.
458
337
  *
459
- * `slot` rides along because the caller's `describe` needs the facet's real
460
- * name and the slot is not derivable from the pid — that indirection is the
461
- * whole point of the free list. Reading it back out of the book later would
462
- * also race the release that empties it.
338
+ * `name` is the facet's real name and `slot` its ephemeral book entry (absent
339
+ * for a durable spawn, whose name its coordinator allocated out of storage).
340
+ * Both ride along because the caller's `describe` needs them and neither is
341
+ * derivable from the pid — reading a slot back out of the book would race the
342
+ * release that empties it.
463
343
  */
464
- export type ResidentFacet = Omit<HostedProcess, 'describe'> & { slot: number };
344
+ export type ResidentFacet = Omit<HostedProcess, 'describe'> & { name: string; slot?: number };
465
345
 
466
346
  /**
467
- * Open a resident process as a facet of the actor whose `ctx` and `env` are
468
- * given, and start its runner.
347
+ * The process surface of one hosting actor: how a resident process comes
348
+ * into existence on workerd, and how a one-shot program runs to completion.
469
349
  *
470
- * This is the ONE way a resident process comes into existence, and every
350
+ * `spawn` is the ONE way a resident process comes into existence, and every
471
351
  * substrate goes through it: the facet host calls it with the coordinator's
472
352
  * own `ctx`, the peer host calls it — over one RPC — with a sibling session
473
353
  * DO's. Everything a substrate could plausibly want to special-case is a
474
354
  * PARAMETER here rather than a branch: which actor hosts the child, and how
475
355
  * the boot spec's by-path members are read.
476
356
  */
477
- export function openResidentFacet(
357
+ export function processes(ctx: DurableObjectState, env: ResidentFacetEnv): Processes {
358
+ return new Processes(ctx, env);
359
+ }
360
+
361
+ export class Processes {
362
+ constructor(
363
+ private readonly ctx: DurableObjectState,
364
+ private readonly env: ResidentFacetEnv,
365
+ ) {}
366
+
367
+ /** Open a resident process as a facet of this actor, and start its runner. */
368
+ spawn(
369
+ disk: () => ResidentDiskReader,
370
+ supervisor: ResidentSupervisorProps,
371
+ params: ProcessHostParams,
372
+ ): ResidentFacet {
373
+ return spawnResident(this.ctx, this.env, disk, supervisor, params);
374
+ }
375
+
376
+ /**
377
+ * Run one program to completion as an UNKEYED dynamic worker.
378
+ *
379
+ * Unkeyed is the whole difference from `spawn`: nothing can re-resolve this
380
+ * worker into a later request's context, so it can never be a routeable
381
+ * target and never has to be released by name. It exists for the duration
382
+ * of one call and its stubs are dropped as that call unwinds.
383
+ *
384
+ * Shared by both substrates on purpose. `peer` places processes that have a
385
+ * residency to place; a one-shot has none, and shipping its fully-inline
386
+ * map across a sibling hop would meet the 32 MiB RPC ceiling that by-path
387
+ * boot specs exist to avoid — for a run that gains nothing by moving.
388
+ */
389
+ run<T>(
390
+ supervisor: ResidentSupervisorProps,
391
+ params: OneShotParams,
392
+ consume: (response: Response) => Promise<T>,
393
+ ): Promise<T> {
394
+ return runOneShot(this.ctx, this.env, supervisor, params, consume);
395
+ }
396
+ }
397
+
398
+ function spawnResident(
478
399
  ctx: DurableObjectState,
479
400
  env: ResidentFacetEnv,
480
401
  disk: () => ResidentDiskReader,
@@ -482,9 +403,21 @@ export function openResidentFacet(
482
403
  params: ProcessHostParams,
483
404
  ): ResidentFacet {
484
405
  const facets = facetContainer(ctx);
485
- const book = slotBook(ctx);
486
- const slot = acquireSlot(ctx, params.pid);
487
- const name = residentFacetName(slot);
406
+ // An explicit name is the durable path: the caller allocated an
407
+ // `app-slot-<n>` identity out of DO storage and this facet keeps its SQLite
408
+ // across aborts. Anything else takes the in-memory book — and that book's
409
+ // `proc-slot-` names must never be minted for it, or an ephemeral release's
410
+ // delete would wipe the app's storage and a reused slot would land a new
411
+ // process on someone else's disk.
412
+ const explicit = params.facet;
413
+ if (explicit && !explicit.name.startsWith(DURABLE_FACET_NAME_PREFIX)) {
414
+ throw new Error(
415
+ `Nimbus: an explicit facet name must carry the '${DURABLE_FACET_NAME_PREFIX}' `
416
+ + `prefix, got '${explicit.name}'`,
417
+ );
418
+ }
419
+ const slot = explicit ? undefined : acquireSlot(ctx, params.pid);
420
+ const name = explicit ? explicit.name : residentFacetName(slot!);
488
421
  // The start callback is the ONLY way this facet is ever created, and it
489
422
  // fires AT MOST ONCE. Every later use goes through the stub below, so the
490
423
  // callback running a second time means the facet was released or died —
@@ -510,8 +443,8 @@ export function openResidentFacet(
510
443
  try {
511
444
  facet = facets.get(name, start);
512
445
  } catch (error) {
513
- releaseSlot(ctx, params.pid);
514
- throw withFacetBudgetNamed(Math.max(book.ledgerKnown, book.next), error);
446
+ if (slot !== undefined) releaseSlot(ctx, params.pid);
447
+ throw withFacetBudgetNamed(facetNameCount(ctx), error);
515
448
  }
516
449
 
517
450
  let disposed = false;
@@ -520,10 +453,17 @@ export function openResidentFacet(
520
453
  disposed = true;
521
454
  released = true;
522
455
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
523
- try { facets.delete(name); } catch { /* already gone */ }
456
+ // The two release classes: an ephemeral facet's SQLite is slot-reuse
457
+ // hygiene — the name is handed out again, so the store must not be — and
458
+ // a durable one's is the application itself: abort ends the process, the
459
+ // data stays for the next boot, and only removeDurableApp's explicit
460
+ // deleteFacetStorage call ever drops it.
461
+ if (!explicit?.durable) {
462
+ try { facets.delete(name); } catch { /* already gone */ }
463
+ }
524
464
  // Only after the facet is gone. A slot handed out while its previous
525
465
  // tenant were still being torn down would have two processes on one name.
526
- releaseSlot(ctx, params.pid);
466
+ if (slot !== undefined) releaseSlot(ctx, params.pid);
527
467
  };
528
468
 
529
469
  let started: Promise<unknown>;
@@ -531,15 +471,14 @@ export function openResidentFacet(
531
471
  started = facet.startProcess(params.startArgs);
532
472
  } catch (error) {
533
473
  void release();
534
- throw withFacetBudgetNamed(Math.max(book.ledgerKnown, book.next), error);
474
+ throw withFacetBudgetNamed(facetNameCount(ctx), error);
535
475
  }
536
476
  // The rejection that carries the platform's failure at ID exhaustion is
537
477
  // this one, and it is annotated AFTER awaiting the ledger — the first
538
478
  // failure of a fresh incarnation must compare against the persisted count,
539
479
  // not the zero its adoption read has not yet replaced.
540
480
  started = started.catch(async (error) => {
541
- const durable = await book.ledger;
542
- throw withFacetBudgetNamed(Math.max(durable, book.next), error);
481
+ throw withFacetBudgetNamed(await facetNameCountDurable(ctx), error);
543
482
  });
544
483
  // A caller reads whichever of `started` and the lifecycle it needs, so keep
545
484
  // the runtime from reporting the other as an unhandled rejection.
@@ -552,6 +491,7 @@ export function openResidentFacet(
552
491
  handleHttpRequest: (request: Request) => facet.handleHttpRequest(request),
553
492
  handleWebSocketRequest: (request: Request) => facet.fetch(request),
554
493
  release,
494
+ name,
555
495
  slot,
556
496
  };
557
497
  }
@@ -587,20 +527,7 @@ function residentProcessClass(
587
527
  }
588
528
  }
589
529
 
590
- /**
591
- * Run one program to completion as an UNKEYED dynamic worker.
592
- *
593
- * Unkeyed is the whole difference from `openResidentFacet`: nothing can
594
- * re-resolve this worker into a later request's context, so it can never be a
595
- * routeable target and never has to be released by name. It exists for the
596
- * duration of one call and its stubs are dropped as that call unwinds.
597
- *
598
- * Shared by both substrates on purpose. `peer` places processes that have a
599
- * residency to place; a one-shot has none, and shipping its fully-inline map
600
- * across a sibling hop would meet the 32 MiB RPC ceiling that by-path boot
601
- * specs exist to avoid — for a run that gains nothing by moving.
602
- */
603
- export async function runOneShotWorker<T>(
530
+ async function runOneShot<T>(
604
531
  ctx: DurableObjectState,
605
532
  env: ResidentFacetEnv,
606
533
  supervisor: ResidentSupervisorProps,
@@ -672,18 +599,33 @@ export async function runOneShotWorker<T>(
672
599
  }
673
600
  }
674
601
 
675
- async function residentWorkerConfig(
602
+ /**
603
+ * The WorkerCode the loader callback returns for one resident boot: the
604
+ * module map from {@link residentLoaderConfig} (or the staged assembler),
605
+ * plus the isolate's env and network posture.
606
+ *
607
+ * A `code` boot with an explicit `env` — defined, even as `{}` — is the
608
+ * embedder's whole statement about the isolate: the env rides through
609
+ * exactly as minted (loopback stubs by reference), and the composed supervisor
610
+ * entrypoint is not consulted at all, so no SUPERVISOR binding appears.
611
+ * Without one, the default holds: inherited network plus a SUPERVISOR
612
+ * minted from the composed entrypoint for the coordinator's identity.
613
+ */
614
+ export async function residentWorkerConfig(
676
615
  env: ResidentFacetEnv,
677
616
  disk: () => ResidentDiskReader,
678
617
  supervisor: ResidentSupervisorProps,
679
618
  boot: ResidentBootSpec,
680
619
  ): Promise<Record<string, unknown>> {
620
+ if (boot.kind === 'code' && boot.code.env !== undefined) {
621
+ const isolated = await residentLoaderConfig(boot.code, disk());
622
+ assertModuleMapWithinCodeLimit(configModules(isolated));
623
+ return isolated;
624
+ }
681
625
  const config = boot.kind === 'staged'
682
- ? await requireStagedBootAssembler()(env, boot.stage)
626
+ ? await stagedBootAssembler()(env, boot.stage)
683
627
  : await residentLoaderConfig(boot.code, disk());
684
- assertModuleMapWithinCodeLimit(
685
- (config as { modules?: Record<string, unknown> }).modules ?? {},
686
- );
628
+ assertModuleMapWithinCodeLimit(configModules(config));
687
629
  const supervisorRpc = supervisorEntrypoint();
688
630
  if (!supervisorRpc) {
689
631
  throw new Error(
@@ -692,3 +634,12 @@ async function residentWorkerConfig(
692
634
  }
693
635
  return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
694
636
  }
637
+
638
+ /** The module map a loader config assembled, or empty when it named none. */
639
+ function configModules(config: object): Record<string, unknown> {
640
+ const modules: unknown = 'modules' in config ? config.modules : undefined;
641
+ if (typeof modules !== 'object' || modules === null) return {};
642
+ // Loader configs only ever carry a module map under this key.
643
+ const map: Record<string, unknown> = modules as Record<string, unknown>;
644
+ return map;
645
+ }