@nimbus-sh/fabric 0.2.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.
@@ -76,6 +76,7 @@
76
76
 
77
77
  import { z } from 'zod/v4';
78
78
  import type { RouteableFacetTarget } from '@nimbus-sh/core/runtime/os-contracts.js';
79
+ import type { ServiceStub } from './vendor/types.js';
79
80
 
80
81
  /**
81
82
  * The class every generated resident runner exports. One name for every
@@ -131,6 +132,19 @@ export const ResidentCodeSpecSchema = z.object({
131
132
  * image store below and the spec names it.
132
133
  */
133
134
  vfsTextModules: z.record(z.string(), z.string()).optional(),
135
+ /**
136
+ * The isolate's exact `env`: one entry per binding the embedder minted,
137
+ * carried by reference so loopback stubs survive untouched. Defined —
138
+ * even as `{}` — means the embedder takes the whole env and no
139
+ * SUPERVISOR binding is injected; absent keeps the default (a SUPERVISOR
140
+ * minted from the composed supervisor entrypoint).
141
+ */
142
+ env: z.record(z.string(), z.unknown()).optional(),
143
+ /** Absent inherits outbound, null denies it, a binding mediates it by reference. */
144
+ globalOutbound: z.custom<ServiceStub>((value) =>
145
+ value !== null && (typeof value === 'object' || typeof value === 'function')
146
+ && 'fetch' in value && typeof value.fetch === 'function',
147
+ ).nullable().optional(),
134
148
  });
135
149
 
136
150
  export type ResidentCodeSpec = z.infer<typeof ResidentCodeSpecSchema>;
@@ -238,6 +252,11 @@ export interface ResidentDiskReader {
238
252
  * path, verifying each generated image against the digest its own path claims.
239
253
  * Runs inside the loader's cache-miss callback, so the bytes exist only for
240
254
  * the duration of the load.
255
+ *
256
+ * The spec's isolation posture rides along verbatim: an explicit `env` is
257
+ * the isolate's whole env (loopback stubs by reference, never cloned or
258
+ * re-minted). An absent env stays absent
259
+ * so the worker config can tell "embedder takes the env" from the default.
241
260
  */
242
261
  export async function residentLoaderConfig(
243
262
  spec: ResidentCodeSpec,
@@ -258,6 +277,8 @@ export async function residentLoaderConfig(
258
277
  compatibilityFlags: spec.compatibilityFlags,
259
278
  mainModule: spec.mainModule,
260
279
  modules: { ...spec.modules, ...resolved },
280
+ ...(spec.env !== undefined ? { env: spec.env } : {}),
281
+ ...(spec.globalOutbound !== undefined ? { globalOutbound: spec.globalOutbound } : {}),
261
282
  };
262
283
  }
263
284
 
@@ -309,6 +330,14 @@ export interface ProcessHostParams {
309
330
  writerId: string;
310
331
  /** Forwarded verbatim to the runner's startProcess. */
311
332
  startArgs: unknown;
333
+ /**
334
+ * Set only by the coordinator's durable-application path: an explicit facet
335
+ * name (`app-slot-<n>`) allocated from DO storage, plus the release split
336
+ * that keeps its SQLite across aborts. Absent, the host allocates an
337
+ * ephemeral `proc-slot-<n>` name from its in-memory free list and deletes
338
+ * the store on release.
339
+ */
340
+ facet?: { name: string; durable: boolean };
312
341
  }
313
342
 
314
343
  /**
@@ -611,10 +640,16 @@ export interface ResidentProcessSpawn {
611
640
  pid: number;
612
641
  /** Keyed dynamic-worker identity (`nimbus-process:${doId}:${pid}`). */
613
642
  workerKey: string;
614
- /** What the facet boots from. */
643
+ /** What the process boots from. */
615
644
  boot: ResidentBootSpec;
616
645
  /** Forwarded verbatim to the runner's startProcess. */
617
646
  startArgs?: unknown;
647
+ /**
648
+ * A durable application's explicit facet name (`app-slot-<n>`) and the
649
+ * release split that keeps its SQLite. Coordinator-allocated; absent for an
650
+ * ephemeral process, which takes a `proc-slot-<n>` name from the book.
651
+ */
652
+ facet?: { name: string; durable: boolean };
618
653
  /**
619
654
  * Called before any concrete host capability can expose this writer.
620
655
  * A spawn must not proceed unless the supervisor accepts the authority.
@@ -658,6 +693,7 @@ export class ProcessFabric {
658
693
  boot: spawn.boot,
659
694
  writerId,
660
695
  startArgs: spawn.startArgs,
696
+ ...(spawn.facet !== undefined ? { facet: spawn.facet } : {}),
661
697
  });
662
698
  } catch (error) {
663
699
  spawn.onWriterRetired(writerId);
@@ -78,12 +78,11 @@ import {
78
78
  type ResidentSupervisorProps,
79
79
  } from './process-fabric.js';
80
80
  import { DYNAMIC_WORKER_CODE_LIMIT_BYTES } from './budgets.js';
81
+ import { BindingError } from './vendor/errors.js';
81
82
  import {
82
83
  processes,
83
- residentFacetName,
84
84
  type ResidentFacetEnv,
85
85
  } from './workerd-facet-host.js';
86
- import { BindingError } from './vendor/errors.js';
87
86
 
88
87
  /** The substrates this deployment can be configured for. */
89
88
  export type ProcessHostMode = 'facet' | 'peer';
@@ -148,11 +147,11 @@ class FacetProcessHost implements ProcessHost {
148
147
  pid: params.pid,
149
148
  writerId: params.writerId,
150
149
  };
151
- const { slot, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
150
+ const { name, ...facet } = processes(this.ctx, this.env).spawn(this.disk, supervisor, params);
152
151
  return {
153
152
  ...facet,
154
153
  describe: () =>
155
- `facet '${residentFacetName(slot)}' (pid ${params.pid})`
154
+ `facet '${name}' (pid ${params.pid})`
156
155
  + ` of session ${this.coordDoId.slice(-12)}`
157
156
  + `; ${describeImageDelivery(this.imageDelivery)}`,
158
157
  };
@@ -340,10 +339,18 @@ class PeerProcessHost implements ProcessHost {
340
339
  consume,
341
340
  );
342
341
  }
343
-
344
342
  async open(params: ProcessHostParams): Promise<HostedProcess> {
343
+ if (params.facet) {
344
+ // A durable application's facet must be a child of the COORDINATOR's
345
+ // Durable Object — its `app-slot-<n>` row and retained SQLite live in
346
+ // that DO's storage. A sibling host would own storage the coordinator's
347
+ // durable-slot book and removeDurableApp cannot reach.
348
+ throw new Error(
349
+ 'Nimbus: a durable spawn must be facet-hosted on its own coordinator; '
350
+ + 'the peer substrate cannot serve one',
351
+ );
352
+ }
345
353
  const placement = await this._place(params.pid);
346
- this.tokensInUse.set(params.pid, placement.isolateToken);
347
354
  // Minted per open, held only by this coordinator and the peer that hosts
348
355
  // the process. The workerKey is derivable from a pid; this is not.
349
356
  const webSocketCapability = crypto.randomUUID();
@@ -123,9 +123,10 @@ export class TurnBudget {
123
123
  }
124
124
  }
125
125
 
126
- function withResolvers(): { promise: Promise<void>; resolve: () => void } {
127
- let resolve!: () => void;
128
- const promise = new Promise<void>((r) => { resolve = r; });
126
+ /** `Promise.withResolvers` for the runtime the project targets. */
127
+ export function withResolvers<T = void>(): { promise: Promise<T>; resolve: (value: T | PromiseLike<T>) => void } {
128
+ let resolve!: (value: T | PromiseLike<T>) => void;
129
+ const promise = new Promise<T>((r) => { resolve = r; });
129
130
  return { promise, resolve };
130
131
  }
131
132
 
@@ -136,9 +136,14 @@ interface LoadedWorkerStub {
136
136
  * Durable Object class, so a resident process can be re-entered; `load` is
137
137
  * unkeyed and yields a stateless entrypoint, which is all a program that ends
138
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.
139
144
  */
140
145
  interface WorkerLoaderBinding {
141
- get(id: string, code: () => Promise<unknown>): { getDurableObjectClass(name: string): unknown };
146
+ get(id: string | null, code: () => unknown): { getDurableObjectClass(name: string): unknown };
142
147
  load(code: unknown): LoadedWorkerStub;
143
148
  }
144
149
 
@@ -223,7 +228,7 @@ export async function cloneStorage(
223
228
  }
224
229
 
225
230
  /**
226
- * 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.
227
232
  *
228
233
  * A Durable Object admits 65,536 facets over its LIFETIME: the IDs are
229
234
  * append-only and are never reclaimed, so the bound is on facets ever CREATED,
@@ -235,11 +240,20 @@ export async function cloneStorage(
235
240
  * Reusing a NAME costs no new ID. So the name comes from a free list and the
236
241
  * pid stays what it always was: the process identity in the ProcessTable. The
237
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.
238
249
  */
239
250
  export function residentFacetName(slot: number): string {
240
251
  return `proc-slot-${slot}`;
241
252
  }
242
253
 
254
+ /** The prefix every durable application's facet name carries. */
255
+ export const DURABLE_FACET_NAME_PREFIX = 'app-slot-';
256
+
243
257
  /** One hosting actor's slot book. */
244
258
  interface SlotBook {
245
259
  /** Returned slots, lowest reused first so the high-water mark stays low. */
@@ -264,6 +278,11 @@ interface SlotBook {
264
278
  * VFS epoch, in which case `invalidatedSince` can only answer poison and the
265
279
  * whole store is dropped. A process therefore cannot boot onto a previous
266
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.
267
286
  */
268
287
  const slotBooks = new WeakMap<DurableObjectState, SlotBook>();
269
288
 
@@ -300,16 +319,29 @@ function releaseSlot(ctx: DurableObjectState, pid: number): void {
300
319
  book.free.sort((a, b) => a - b);
301
320
  }
302
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
+
303
334
 
304
335
  /**
305
336
  * What `processes(ctx, env).spawn` hands back: a running process, minus its placement.
306
337
  *
307
- * `slot` rides along because the caller's `describe` needs the facet's real
308
- * name and the slot is not derivable from the pid — that indirection is the
309
- * whole point of the free list. Reading it back out of the book later would
310
- * 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.
311
343
  */
312
- export type ResidentFacet = Omit<HostedProcess, 'describe'> & { slot: number };
344
+ export type ResidentFacet = Omit<HostedProcess, 'describe'> & { name: string; slot?: number };
313
345
 
314
346
  /**
315
347
  * The process surface of one hosting actor: how a resident process comes
@@ -371,8 +403,21 @@ function spawnResident(
371
403
  params: ProcessHostParams,
372
404
  ): ResidentFacet {
373
405
  const facets = facetContainer(ctx);
374
- const slot = acquireSlot(ctx, params.pid);
375
- 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!);
376
421
  // The start callback is the ONLY way this facet is ever created, and it
377
422
  // fires AT MOST ONCE. Every later use goes through the stub below, so the
378
423
  // callback running a second time means the facet was released or died —
@@ -398,7 +443,7 @@ function spawnResident(
398
443
  try {
399
444
  facet = facets.get(name, start);
400
445
  } catch (error) {
401
- releaseSlot(ctx, params.pid);
446
+ if (slot !== undefined) releaseSlot(ctx, params.pid);
402
447
  throw withFacetBudgetNamed(facetNameCount(ctx), error);
403
448
  }
404
449
 
@@ -408,10 +453,17 @@ function spawnResident(
408
453
  disposed = true;
409
454
  released = true;
410
455
  try { facets.abort(name, new Error('Nimbus: resident process released')); } catch { /* already gone */ }
411
- 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
+ }
412
464
  // Only after the facet is gone. A slot handed out while its previous
413
465
  // tenant were still being torn down would have two processes on one name.
414
- releaseSlot(ctx, params.pid);
466
+ if (slot !== undefined) releaseSlot(ctx, params.pid);
415
467
  };
416
468
 
417
469
  let started: Promise<unknown>;
@@ -439,6 +491,7 @@ function spawnResident(
439
491
  handleHttpRequest: (request: Request) => facet.handleHttpRequest(request),
440
492
  handleWebSocketRequest: (request: Request) => facet.fetch(request),
441
493
  release,
494
+ name,
442
495
  slot,
443
496
  };
444
497
  }
@@ -546,18 +599,33 @@ async function runOneShot<T>(
546
599
  }
547
600
  }
548
601
 
549
- 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(
550
615
  env: ResidentFacetEnv,
551
616
  disk: () => ResidentDiskReader,
552
617
  supervisor: ResidentSupervisorProps,
553
618
  boot: ResidentBootSpec,
554
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
+ }
555
625
  const config = boot.kind === 'staged'
556
626
  ? await stagedBootAssembler()(env, boot.stage)
557
627
  : await residentLoaderConfig(boot.code, disk());
558
- assertModuleMapWithinCodeLimit(
559
- (config as { modules?: Record<string, unknown> }).modules ?? {},
560
- );
628
+ assertModuleMapWithinCodeLimit(configModules(config));
561
629
  const supervisorRpc = supervisorEntrypoint();
562
630
  if (!supervisorRpc) {
563
631
  throw new Error(
@@ -566,3 +634,12 @@ async function residentWorkerConfig(
566
634
  }
567
635
  return { ...config, env: { SUPERVISOR: supervisorRpc({ props: supervisor }) } };
568
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
+ }