@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.
- package/README.md +169 -283
- package/dist/composition.d.ts +2 -86
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +2 -76
- package/dist/fenced-work.d.ts +33 -4
- package/dist/fenced-work.d.ts.map +1 -1
- package/dist/fenced-work.js +81 -28
- package/dist/process-fabric.d.ts +31 -1
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +19 -0
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +11 -4
- package/dist/turn-budget.d.ts +5 -0
- package/dist/turn-budget.d.ts.map +1 -1
- package/dist/turn-budget.js +2 -1
- package/dist/workerd-facet-host.d.ts +44 -8
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +80 -10
- package/package.json +3 -3
- package/src/composition.ts +16 -127
- package/src/fenced-work.ts +81 -30
- package/src/process-fabric.ts +37 -1
- package/src/process-host.ts +13 -6
- package/src/turn-budget.ts +4 -3
- package/src/workerd-facet-host.ts +93 -16
package/src/process-fabric.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/process-host.ts
CHANGED
|
@@ -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 {
|
|
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 '${
|
|
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();
|
package/src/turn-budget.ts
CHANGED
|
@@ -123,9 +123,10 @@ export class TurnBudget {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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: () =>
|
|
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
|
|
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
|
-
* `
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
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'> & {
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|