@nimbus-sh/fabric 0.5.0 → 0.6.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/dist/bindings.d.ts +5 -49
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +59 -27
- package/dist/fanout.d.ts +4 -11
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +38 -42
- package/dist/host-dispatch.d.ts +6 -0
- package/dist/host-dispatch.d.ts.map +1 -0
- package/dist/host-dispatch.js +27 -0
- package/dist/isolate-pool.d.ts +21 -0
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +53 -9
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +60 -33
- package/package.json +3 -3
- package/src/bindings.ts +57 -92
- package/src/fanout.ts +48 -67
- package/src/host-dispatch.ts +34 -0
- package/src/isolate-pool.ts +106 -14
- package/src/process-host.ts +64 -44
package/dist/isolate-pool.js
CHANGED
|
@@ -85,7 +85,10 @@ export function assembleLoaderWorkerModuleSource(options) {
|
|
|
85
85
|
const callExpr = options.hasBindings
|
|
86
86
|
? '__fn__(...args, this.env)'
|
|
87
87
|
: '__fn__(...args)';
|
|
88
|
-
|
|
88
|
+
const requestCallExpr = options.hasBindings
|
|
89
|
+
? '__fn__(request, this.env)'
|
|
90
|
+
: '__fn__(request)';
|
|
91
|
+
lines.push('export default class extends WorkerEntrypoint {', ' execute(...args) {', ` const result = ${callExpr};`, ' if (result instanceof Promise) return result;', ' return result;', ' }', '', ' // Request/Response transport: the ONE dispatch path whose abort is', ' // real. workerd cancels the inner execution context when the request', ' // signal aborts while the fn is suspended on I/O; RPC execute() above', ' // has no equivalent (measured: Symbol.dispose on a pending RPC does', ' // not cancel). The fn is request-shaped — it encodes and decodes its', ' // own payload; nothing generic is serialized here.', ' async fetch(request) {', ' try {', ` const result = await ${requestCallExpr};`, ' if (result instanceof Response) return result;', ' return Response.json(result ?? null);', ' } catch (err) {', ' // Fail loud inside the response so an abort of the transport is', ' // not conflated with a guest exception: the caller sees 500 only', ' // for a real fn failure, and aborts arrive as fetch rejections.', ' const message = err instanceof Error && err.message ? err.message : String(err);', ' return Response.json({ __nimbusFacetError: message }, { status: 500 });', ' }', ' }', '}');
|
|
89
92
|
return lines.join('\n');
|
|
90
93
|
}
|
|
91
94
|
/**
|
|
@@ -159,6 +162,8 @@ export class IsolatePool {
|
|
|
159
162
|
* the old key shape and its warm slots stay shared.
|
|
160
163
|
*/
|
|
161
164
|
supervisorKey;
|
|
165
|
+
/** Extra loader-id segment from options.scope — see IsolatePoolOptions. */
|
|
166
|
+
scope;
|
|
162
167
|
constructor(env, ctx, opts) {
|
|
163
168
|
// A host hands its whole env over; the binding is claimed here and the
|
|
164
169
|
// claim is checked on the next line.
|
|
@@ -180,6 +185,7 @@ export class IsolatePool {
|
|
|
180
185
|
this.doIdShort = opts?.cacheScope === 'global'
|
|
181
186
|
? 'global'
|
|
182
187
|
: ctx.id.toString().slice(0, 12);
|
|
188
|
+
this.scope = opts?.scope ?? '';
|
|
183
189
|
// Materialise the wasm-modules table. Sanitise each name into a
|
|
184
190
|
// valid JS identifier for the static import binding; key collisions
|
|
185
191
|
// (e.g. 'esbuild.wasm' and 'esbuild_wasm' both sanitise to
|
|
@@ -430,8 +436,14 @@ export class IsolatePool {
|
|
|
430
436
|
* warm isolate services the call; callers round-robin slots themselves.
|
|
431
437
|
* Serialized per slot: this call waits for the slot's previous
|
|
432
438
|
* execution to settle before touching the warm isolate.
|
|
439
|
+
*
|
|
440
|
+
* `invoke` is the single-attempt call against the slot's entrypoint
|
|
441
|
+
* stub. `execute` dispatches call `entrypoint.execute(...args)`;
|
|
442
|
+
* `fetch` dispatches call `entrypoint.fetch(<per-attempt Request>)` —
|
|
443
|
+
* the attempt index lets a fetch invoke mint a fresh clone for retries,
|
|
444
|
+
* since a Request's body is consumed once.
|
|
433
445
|
*/
|
|
434
|
-
async #dispatchSlot(fnSource, fnHash, slotIndex,
|
|
446
|
+
async #dispatchSlot(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, wasAborted) {
|
|
435
447
|
// A warm slot executes one dispatch at a time: queue behind the
|
|
436
448
|
// previous owner, then record this dispatch as the new tail. The
|
|
437
449
|
// tail outlives the caller's outcome: a timeout rejects the
|
|
@@ -448,7 +460,7 @@ export class IsolatePool {
|
|
|
448
460
|
}
|
|
449
461
|
const inFlight = [];
|
|
450
462
|
try {
|
|
451
|
-
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex,
|
|
463
|
+
return await this.#dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted);
|
|
452
464
|
}
|
|
453
465
|
finally {
|
|
454
466
|
// Do not delay the caller's own outcome — the tail releases when
|
|
@@ -456,7 +468,7 @@ export class IsolatePool {
|
|
|
456
468
|
void Promise.allSettled(inFlight).then(() => release());
|
|
457
469
|
}
|
|
458
470
|
}
|
|
459
|
-
async #dispatchSlotOwned(fnSource, fnHash, slotIndex,
|
|
471
|
+
async #dispatchSlotOwned(fnSource, fnHash, slotIndex, invoke, resilience, perCallWasm, inFlight, wasAborted) {
|
|
460
472
|
// Per-call wasm fingerprint. Mixed into the cache key so two calls
|
|
461
473
|
// with different bytes hit different slots (no cache poisoning).
|
|
462
474
|
// For the common case (no per-call wasm) the fingerprint is '0',
|
|
@@ -468,7 +480,7 @@ export class IsolatePool {
|
|
|
468
480
|
// identity so a wake of that same session can never reuse a warm
|
|
469
481
|
// worker whose SUPERVISOR binding still names the dead generation's
|
|
470
482
|
// pid. See the supervisorKey field comment for the failure mode.
|
|
471
|
-
const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}`;
|
|
483
|
+
const buildId = (generation) => `nfp:${this.tag}:${this.doIdShort}:${fnHash}:${this.preambleHash}:${this.wasmHash}:${perCallWasmHash}:${this.supervisorKey}:slot-${slotIndex}:g${generation}${this.scope ? `:${this.scope}` : ''}`;
|
|
472
484
|
let id = buildId(this.slotGenerations.get(slotIndex) ?? 0);
|
|
473
485
|
const code = this.#buildCode(fnSource, perCallWasmEntries);
|
|
474
486
|
// W5 Lever 5: record the dispatch so /api/_diag/memory shows the
|
|
@@ -510,8 +522,7 @@ export class IsolatePool {
|
|
|
510
522
|
// wrapped. See beginLoaderFetch for the measured DO-poisoning hazard.
|
|
511
523
|
const endFetch = beginLoaderFetch(this.ctx);
|
|
512
524
|
try {
|
|
513
|
-
|
|
514
|
-
return out;
|
|
525
|
+
return await invoke(entrypoint, attempt);
|
|
515
526
|
}
|
|
516
527
|
catch (err) {
|
|
517
528
|
if (err instanceof Error) {
|
|
@@ -585,6 +596,18 @@ export class IsolatePool {
|
|
|
585
596
|
});
|
|
586
597
|
}
|
|
587
598
|
catch { /* fail-soft */ }
|
|
599
|
+
if (wasAborted?.()) {
|
|
600
|
+
// The caller aborted this dispatch's Request. workerd cancelled
|
|
601
|
+
// the isolate's execution context wherever it was suspended —
|
|
602
|
+
// mid-syscall, mid-stream — so the interpreter's heap may hold
|
|
603
|
+
// half-applied state. Bump the slot generation: the next
|
|
604
|
+
// dispatch lands on a FRESH worker under a new id rather than
|
|
605
|
+
// reusing the abandoned one. Retrying is wrong — the user
|
|
606
|
+
// interrupted; surface the abort.
|
|
607
|
+
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
608
|
+
this.slotGenerations.set(slotIndex, generation);
|
|
609
|
+
throw lastError;
|
|
610
|
+
}
|
|
588
611
|
if (cause === 'clone_refused' && !retriedCloneRefusal) {
|
|
589
612
|
retriedCloneRefusal = true;
|
|
590
613
|
const generation = (this.slotGenerations.get(slotIndex) ?? 0) + 1;
|
|
@@ -628,7 +651,28 @@ export class IsolatePool {
|
|
|
628
651
|
async submit(fn, arg, opts) {
|
|
629
652
|
const { fnSource, fnHash } = this.#prepare(fn);
|
|
630
653
|
const resilience = this.#resolve(opts);
|
|
631
|
-
return (await this.#dispatchSlot(fnSource, fnHash, 0,
|
|
654
|
+
return (await this.#dispatchSlot(fnSource, fnHash, 0, (entrypoint) => entrypoint.execute(arg), resilience, opts?.wasmModules));
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Dispatch `fn` through the fetch transport — the pool's only
|
|
658
|
+
* cancellable path: aborting `request.signal` cancels the inner
|
|
659
|
+
* execution context at its next I/O suspension (a synchronous CPU
|
|
660
|
+
* section cannot be preempted — the platform's honest limit), the
|
|
661
|
+
* call rejects, and the slot generation bumps so the next dispatch
|
|
662
|
+
* gets a fresh interpreter. `fn` is request-shaped: it encodes and
|
|
663
|
+
* decodes its own payload.
|
|
664
|
+
*/
|
|
665
|
+
async submitRequest(fn, request, opts) {
|
|
666
|
+
if (request.bodyUsed) {
|
|
667
|
+
throw new BindingError('IsolatePool.submitRequest: request body is already consumed — ' +
|
|
668
|
+
'pass an unconsumed Request so retries can re-issue it.');
|
|
669
|
+
}
|
|
670
|
+
const { fnSource, fnHash } = this.#prepare(fn);
|
|
671
|
+
const resilience = this.#resolve(opts);
|
|
672
|
+
// Every attempt fetches a clone: a Request's body is consumed once,
|
|
673
|
+
// so the caller's original stays unspent and retries get a fresh
|
|
674
|
+
// body that follows the same signal.
|
|
675
|
+
return this.#dispatchSlot(fnSource, fnHash, 0, (entrypoint) => entrypoint.fetch(request.clone()), resilience, opts?.wasmModules, () => request.signal.aborted);
|
|
632
676
|
}
|
|
633
677
|
/**
|
|
634
678
|
* Run `fn` on every item in `items`, at most `concurrency` at a time,
|
|
@@ -677,7 +721,7 @@ export class IsolatePool {
|
|
|
677
721
|
if (idx >= items.length)
|
|
678
722
|
return;
|
|
679
723
|
try {
|
|
680
|
-
const value = (await this.#dispatchSlot(fnSource, fnHash, slotIndex,
|
|
724
|
+
const value = (await this.#dispatchSlot(fnSource, fnHash, slotIndex, (entrypoint) => entrypoint.execute(items[idx]), resilience, opts?.wasmModules));
|
|
681
725
|
settled[idx] = { ok: true, value };
|
|
682
726
|
}
|
|
683
727
|
catch (err) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"process-host.d.ts","sourceRoot":"","sources":["../src/process-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;
|
|
1
|
+
{"version":3,"file":"process-host.d.ts","sourceRoot":"","sources":["../src/process-host.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgEG;AAOH,OAAO,EAGL,KAAK,WAAW,EAIhB,KAAK,kBAAkB,EAExB,MAAM,qBAAqB,CAAC;AAQ7B,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;AA8ED,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;AAaD;;;;;;;;;GASG;AACH,eAAO,MAAM,2BAA2B,8BAA8B,CAAC;AACvE,eAAO,MAAM,kCAAkC,yCAAyC,CAAC;AAgUzF;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAWhE"}
|
package/dist/process-host.js
CHANGED
|
@@ -66,6 +66,8 @@
|
|
|
66
66
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
67
67
|
import { isTransientDoReset } from '@nimbus-sh/platform/oom-classify.js';
|
|
68
68
|
import { PEER_RETRY_BACKOFF_MS, PEER_TRANSIENT_RESET_RETRIES } from './fanout.js';
|
|
69
|
+
import { hostNamespaceBinding, hostOpDispatch } from './host-dispatch.js';
|
|
70
|
+
import { z } from 'zod/v4';
|
|
69
71
|
import { DYNAMIC_WORKER_CODE_LIMIT_BYTES } from './budgets.js';
|
|
70
72
|
import { BindingError } from './vendor/errors.js';
|
|
71
73
|
import { processes, } from './workerd-facet-host.js';
|
|
@@ -149,19 +151,6 @@ export function isolateToken() {
|
|
|
149
151
|
_isolateToken = crypto.randomUUID();
|
|
150
152
|
return _isolateToken;
|
|
151
153
|
}
|
|
152
|
-
/**
|
|
153
|
-
* Every method a hosting sibling must answer. Checked as a set at first
|
|
154
|
-
* contact: a stub that has the probe but not the router is a deployment skew,
|
|
155
|
-
* and finding that out mid-process is finding it out too late.
|
|
156
|
-
*/
|
|
157
|
-
const PROCESS_PEER_METHODS = [
|
|
158
|
-
'_rpcProcessHostProbe',
|
|
159
|
-
'_rpcHostProcess',
|
|
160
|
-
'_rpcAwaitHostedOpen',
|
|
161
|
-
'_rpcAwaitHostedBoot',
|
|
162
|
-
'_rpcRouteHostedHttp',
|
|
163
|
-
'_rpcCancelHostProcess',
|
|
164
|
-
];
|
|
165
154
|
/**
|
|
166
155
|
* Which hosted process a fetched upgrade is for. An upgrade cannot travel as
|
|
167
156
|
* RPC arguments, so the two values `_rpcRouteHostedHttp` would have taken ride
|
|
@@ -174,31 +163,66 @@ const PROCESS_PEER_METHODS = [
|
|
|
174
163
|
*/
|
|
175
164
|
export const HOSTED_WEBSOCKET_KEY_HEADER = 'x-nimbus-hosted-websocket';
|
|
176
165
|
export const HOSTED_WEBSOCKET_CAPABILITY_HEADER = 'x-nimbus-hosted-websocket-capability';
|
|
166
|
+
/**
|
|
167
|
+
* Peer stubs forward one supervisorOp entrypoint: every method the host-
|
|
168
|
+
* process surface needs is an envelope op, not a private _rpc* method.
|
|
169
|
+
*/
|
|
177
170
|
function peerNamespace(env) {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
171
|
+
try {
|
|
172
|
+
return hostNamespaceBinding(env, 'ProcessFabric');
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (e instanceof BindingError) {
|
|
176
|
+
throw new BindingError(e.message
|
|
177
|
+
+ " NIMBUS_PROCESS_HOST='peer' hosts every resident process on a sibling "
|
|
178
|
+
+ "Durable Object; name the host's own binding with composeFabric({ hostNamespace }).");
|
|
179
|
+
}
|
|
180
|
+
throw e;
|
|
187
181
|
}
|
|
188
|
-
return ns;
|
|
189
182
|
}
|
|
183
|
+
const PeerProbeResult = z.object({ isolateToken: z.string() });
|
|
184
|
+
const PeerOpenResult = z.object({ ok: z.boolean() });
|
|
185
|
+
const PeerBootResult = z.object({ payload: z.unknown() });
|
|
186
|
+
const PeerCancelResult = z.object({ cancelled: z.boolean() });
|
|
187
|
+
const PeerHttpResult = z.object({
|
|
188
|
+
status: z.number().int(),
|
|
189
|
+
statusText: z.string(),
|
|
190
|
+
headers: z.array(z.tuple([z.string(), z.string()])),
|
|
191
|
+
body: z.instanceof(ReadableStream).nullable(),
|
|
192
|
+
});
|
|
190
193
|
function processPeerStub(value, peerName) {
|
|
191
|
-
|
|
192
|
-
|
|
194
|
+
const dispatch = hostOpDispatch(value, `ProcessFabric peer '${peerName}'`);
|
|
195
|
+
const fetchFn = (typeof value === 'object' || typeof value === 'function') && value !== null
|
|
196
|
+
? Reflect.get(value, 'fetch')
|
|
197
|
+
: undefined;
|
|
198
|
+
if (typeof fetchFn !== 'function') {
|
|
199
|
+
throw new BindingError(`ProcessFabric: peer '${peerName}' exposes no fetch() — the hosted-websocket `
|
|
200
|
+
+ 'upgrade leg still travels as a service-binding fetch.');
|
|
193
201
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
const adapter = {
|
|
203
|
+
_rpcProcessHostProbe: async () => PeerProbeResult.parse(await dispatch({ op: 'processHostProbe', args: [] })),
|
|
204
|
+
_rpcHostProcess: async (boot, opts) => PeerOpenResult.parse(await dispatch({ op: 'hostProcess', args: [boot, opts] })),
|
|
205
|
+
_rpcAwaitHostedOpen: async (key) => PeerOpenResult.parse(await dispatch({ op: 'awaitHostedOpen', args: [key] })),
|
|
206
|
+
_rpcAwaitHostedBoot: async (key) => {
|
|
207
|
+
const result = PeerBootResult.parse(await dispatch({ op: 'awaitHostedBoot', args: [key] }));
|
|
208
|
+
return { payload: result.payload };
|
|
209
|
+
},
|
|
210
|
+
_rpcRouteHostedHttp: async (key, request) => PeerHttpResult.parse(await dispatch({ op: 'routeHostedHttp', args: [key, request] })),
|
|
211
|
+
_rpcCancelHostProcess: async (key) => PeerCancelResult.parse(await dispatch({ op: 'cancelHostProcess', args: [key] })),
|
|
212
|
+
fetch: async (request) => {
|
|
213
|
+
const response = await Reflect.apply(fetchFn, value, [request]);
|
|
214
|
+
if (!(response instanceof Response))
|
|
215
|
+
throw new BindingError(`ProcessFabric: peer '${peerName}' returned no Response`);
|
|
216
|
+
return response;
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
// The adapter wraps the raw stub; releasing the adapter must release it —
|
|
220
|
+
// a held RPC stub pins the peer DO for the session's life.
|
|
221
|
+
const disposerKey = Reflect.get(Symbol, 'dispose');
|
|
222
|
+
if (typeof disposerKey === 'symbol') {
|
|
223
|
+
Object.defineProperty(adapter, disposerKey, { value: () => disposeRpcResource(value) });
|
|
200
224
|
}
|
|
201
|
-
return
|
|
225
|
+
return adapter;
|
|
202
226
|
}
|
|
203
227
|
class PeerProcessHost {
|
|
204
228
|
ctx;
|
|
@@ -219,6 +243,9 @@ class PeerProcessHost {
|
|
|
219
243
|
tokensInUse = new Map();
|
|
220
244
|
constructor(ctx, env) {
|
|
221
245
|
this.ctx = ctx;
|
|
246
|
+
if (env === null || (typeof env !== 'object' && typeof env !== 'function')) {
|
|
247
|
+
throw new BindingError('ProcessFabric: a peer host requires environment bindings');
|
|
248
|
+
}
|
|
222
249
|
this.ns = peerNamespace(env);
|
|
223
250
|
this.env = (env ?? {});
|
|
224
251
|
this.coordDoId = ctx.id.toString();
|
|
@@ -339,7 +366,7 @@ class PeerProcessHost {
|
|
|
339
366
|
/** One sibling name, resolved and probed. Leaks nothing on failure. */
|
|
340
367
|
async _probePlacement(pid, attempt) {
|
|
341
368
|
const peerName = `${this.coordDoId}:proc:${pid}:${attempt}`;
|
|
342
|
-
let resource;
|
|
369
|
+
let resource = null;
|
|
343
370
|
try {
|
|
344
371
|
resource = this.ns.get(this.ns.idFromName(peerName));
|
|
345
372
|
const stub = processPeerStub(resource, peerName);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nimbus-sh/fabric",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
],
|
|
47
47
|
"scripts": {
|
|
48
48
|
"build": "tsc -p tsconfig.json --noCheck --noEmit false --declaration true --declarationMap true --outDir dist --rootDir src",
|
|
49
|
-
"prepack": "
|
|
49
|
+
"prepack": "bun run build",
|
|
50
50
|
"typecheck": "tsc --noEmit"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@nimbus-sh/core": "^0.
|
|
53
|
+
"@nimbus-sh/core": "^0.10.0",
|
|
54
54
|
"@nimbus-sh/platform": "^0.4.0",
|
|
55
55
|
"zod": "^4.4.3"
|
|
56
56
|
},
|
package/src/bindings.ts
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
import { WorkerEntrypoint } from 'cloudflare:workers';
|
|
27
27
|
import { z } from 'zod/v4';
|
|
28
28
|
import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
29
|
-
import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
|
|
29
|
+
import { hostNamespace, supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
|
|
30
30
|
import { stagedBootAssembler } from './composition.js';
|
|
31
|
+
import { hostNamespaceBinding, hostOpDispatch, type HostOpDispatch } from './host-dispatch.js';
|
|
31
32
|
import { assertModuleMapWithinCodeLimit } from './budgets.js';
|
|
32
33
|
import type { EntrypointLoopbackFactory } from './composition.js';
|
|
33
34
|
import type { WorkerCode } from './vendor/types.js';
|
|
@@ -44,17 +45,6 @@ interface ShimCtxExports {
|
|
|
44
45
|
NimbusDOStub?: EntrypointLoopbackFactory;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
/**
|
|
48
|
-
* The supervisor DO namespace a shim resolves ONE stub from, by the id its
|
|
49
|
-
* props carry. `Stub` is that DO's RPC surface as the calling shim uses it —
|
|
50
|
-
* the supervisor class belongs to the embedder, so each shim names the methods
|
|
51
|
-
* it calls rather than the class.
|
|
52
|
-
*/
|
|
53
|
-
interface SupervisorNamespace<Stub> {
|
|
54
|
-
idFromString(id: string): DurableObjectId;
|
|
55
|
-
get(id: DurableObjectId): Stub;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
48
|
/**
|
|
59
49
|
* A dynamic worker's entrypoint, as hop 3 relays to it. `fetch` is the
|
|
60
50
|
* entrypoint contract every loaded worker answers; `handleHttpRequest` is the
|
|
@@ -116,34 +106,6 @@ function shimCtxExports(ctx: unknown): ShimCtxExports {
|
|
|
116
106
|
return exports as ShimCtxExports;
|
|
117
107
|
}
|
|
118
108
|
|
|
119
|
-
// ── Inner-Worker loopback bindings ────────────────────────────────────
|
|
120
|
-
//
|
|
121
|
-
// These WorkerEntrypoint classes are top-level exports so that ctx.exports
|
|
122
|
-
// auto-populates Service Bindings for them (enable_ctx_exports compat
|
|
123
|
-
// flag is already enabled via default compatibility_date 2026-04-01).
|
|
124
|
-
//
|
|
125
|
-
// They are re-exported from src/index.ts so wrangler detects them as
|
|
126
|
-
// reachable from the entry file and bundles their classes.
|
|
127
|
-
//
|
|
128
|
-
// Usage pattern (in nimbus-wrangler.ts):
|
|
129
|
-
// ctx.exports.NimbusAssetsRPC({ props: { vfsRoot, assetsDir } })
|
|
130
|
-
// produces a Service Binding stub that can be placed in the inner
|
|
131
|
-
// Worker's `env` under whatever binding name the user declared in
|
|
132
|
-
// wrangler.jsonc's `assets.binding` (typically "ASSETS").
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* What the assets shim reads off the supervisor DO: the VFS bytes of one path,
|
|
136
|
-
* or null when it holds no such file.
|
|
137
|
-
*/
|
|
138
|
-
interface AssetsSupervisorStub {
|
|
139
|
-
_rpcReadFileBytes(path: string): Promise<ArrayBuffer | Uint8Array | null>;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** `env` for the assets shim: the supervisor its VFS reads round-trip through. */
|
|
143
|
-
interface NimbusAssetsEnv {
|
|
144
|
-
NIMBUS_SESSION?: SupervisorNamespace<AssetsSupervisorStub>;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
109
|
/** Props the assets shim is minted with. */
|
|
148
110
|
interface NimbusAssetsProps {
|
|
149
111
|
/** Project root in VFS (e.g. "home/user/myapp"). */
|
|
@@ -175,7 +137,7 @@ interface NimbusAssetsProps {
|
|
|
175
137
|
* For Phase 1, we use a simpler approach: the props carry a supervisor
|
|
176
138
|
* DO id so we can round-trip through an RPC method that reads the file.
|
|
177
139
|
*/
|
|
178
|
-
export class NimbusAssetsRPC extends WorkerEntrypoint<
|
|
140
|
+
export class NimbusAssetsRPC extends WorkerEntrypoint<object, NimbusAssetsProps> {
|
|
179
141
|
/**
|
|
180
142
|
* Fetch a static asset. Called by the inner Worker as
|
|
181
143
|
* `env.ASSETS.fetch(request)`. The request URL's pathname is used to
|
|
@@ -193,12 +155,22 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
|
|
|
193
155
|
const parts = clean.split('/').filter((p) => p && p !== '..' && p !== '.');
|
|
194
156
|
clean = parts.join('/');
|
|
195
157
|
|
|
196
|
-
// Resolve the supervisor DO
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
158
|
+
// Resolve the supervisor DO through the composed host namespace and
|
|
159
|
+
// dispatch readFileBytes through its supervisorOp entrypoint — a host
|
|
160
|
+
// forwards envelopes, not private _rpc* methods.
|
|
161
|
+
if (!doId) {
|
|
162
|
+
return new Response('ASSETS binding not wired: missing doId', { status: 500 });
|
|
163
|
+
}
|
|
164
|
+
let stub: DurableObjectStub | null = null;
|
|
165
|
+
let dispatch: HostOpDispatch;
|
|
166
|
+
try {
|
|
167
|
+
const ns = hostNamespaceBinding(this.env, 'NimbusAssetsRPC');
|
|
168
|
+
stub = ns.get(ns.idFromString(doId));
|
|
169
|
+
dispatch = hostOpDispatch(stub, 'NimbusAssetsRPC');
|
|
170
|
+
} catch (e) {
|
|
171
|
+
disposeRpcResource(stub);
|
|
172
|
+
return new Response(`ASSETS binding not wired: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
200
173
|
}
|
|
201
|
-
const stub = ns.get(ns.idFromString(doId));
|
|
202
174
|
|
|
203
175
|
// Candidate VFS paths, tried in order. The assetsDir is relative to
|
|
204
176
|
// the project root in VFS. Trailing-slash and bare dir → index.html.
|
|
@@ -219,10 +191,14 @@ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAss
|
|
|
219
191
|
for (const candidate of candidates) {
|
|
220
192
|
try {
|
|
221
193
|
const response = await useRpcResource(
|
|
222
|
-
|
|
223
|
-
(bytes
|
|
224
|
-
if (
|
|
225
|
-
|
|
194
|
+
dispatch({ op: 'readFileBytes', args: [candidate] }),
|
|
195
|
+
(bytes) => {
|
|
196
|
+
if (bytes === null) return null;
|
|
197
|
+
const body = bytes instanceof ArrayBuffer ? bytes
|
|
198
|
+
: bytes instanceof Uint8Array && bytes.buffer instanceof ArrayBuffer
|
|
199
|
+
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : null;
|
|
200
|
+
if (!body) return new Response('Nimbus: readFileBytes returned invalid bytes', { status: 502 });
|
|
201
|
+
return new Response(body, {
|
|
226
202
|
status: 200,
|
|
227
203
|
headers: {
|
|
228
204
|
'Content-Type': mimeTypeForPath(candidate),
|
|
@@ -785,31 +761,6 @@ export class NimbusDurableObjectNamespace extends WorkerEntrypoint<unknown, Nimb
|
|
|
785
761
|
}
|
|
786
762
|
}
|
|
787
763
|
|
|
788
|
-
/**
|
|
789
|
-
* What the DO shim reads off the supervisor: one inner-DO request, answered
|
|
790
|
-
* from the facet the supervisor resolves in its own request context.
|
|
791
|
-
*/
|
|
792
|
-
interface InnerDoSupervisorStub {
|
|
793
|
-
_rpcInnerDoFetch(request: {
|
|
794
|
-
bindingName: string;
|
|
795
|
-
id: string;
|
|
796
|
-
method: string;
|
|
797
|
-
url: string;
|
|
798
|
-
headers: [string, string][];
|
|
799
|
-
body: ArrayBuffer | null;
|
|
800
|
-
}): Promise<{
|
|
801
|
-
body: ArrayBuffer;
|
|
802
|
-
status: number;
|
|
803
|
-
statusText: string;
|
|
804
|
-
headers: [string, string][];
|
|
805
|
-
}>;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
/** `env` for the DO shim: the supervisor that owns the facet. */
|
|
809
|
-
interface NimbusInnerDoEnv {
|
|
810
|
-
NIMBUS_SESSION?: SupervisorNamespace<InnerDoSupervisorStub>;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
764
|
/** Props the DO stub carries: which binding, which supervisor, which id. */
|
|
814
765
|
interface NimbusDoStubProps extends NimbusDoNamespaceProps {
|
|
815
766
|
id?: string;
|
|
@@ -823,21 +774,28 @@ interface NimbusDoStubProps extends NimbusDoNamespaceProps {
|
|
|
823
774
|
* spins up / attaches to a facet via the supervisor's ctx.facets in
|
|
824
775
|
* the SAME outer request context — never reusing stubs across requests.
|
|
825
776
|
*/
|
|
826
|
-
export class NimbusDOStub extends WorkerEntrypoint<
|
|
777
|
+
export class NimbusDOStub extends WorkerEntrypoint<object, NimbusDoStubProps> {
|
|
827
778
|
/**
|
|
828
|
-
* Resolve the supervisor DO
|
|
829
|
-
*
|
|
830
|
-
*
|
|
779
|
+
* Resolve the supervisor DO through the composed host namespace and
|
|
780
|
+
* dispatch the innerDoFetch op through its one supervisorOp entrypoint —
|
|
781
|
+
* a host forwards envelopes, not private _rpc* methods.
|
|
831
782
|
*/
|
|
832
783
|
async fetch(request: Request): Promise<Response> {
|
|
833
784
|
const props: NimbusDoStubProps = this.ctx.props || {};
|
|
834
|
-
const ns = this.env?.NIMBUS_SESSION;
|
|
835
|
-
if (!ns) return new Response('Nimbus: env.NIMBUS_SESSION unavailable', { status: 500 });
|
|
836
785
|
const supervisorDoId = String(props.supervisorDoId || '');
|
|
837
786
|
if (!supervisorDoId) return new Response('Nimbus: supervisorDoId missing', { status: 500 });
|
|
838
787
|
const bindingName = String(props.bindingName || '');
|
|
839
788
|
const id = String(props.id || '');
|
|
840
|
-
|
|
789
|
+
let stub: DurableObjectStub | null = null;
|
|
790
|
+
let dispatch: HostOpDispatch;
|
|
791
|
+
try {
|
|
792
|
+
const ns = hostNamespaceBinding(this.env ?? {}, 'NimbusDOStub');
|
|
793
|
+
stub = ns.get(ns.idFromString(supervisorDoId));
|
|
794
|
+
dispatch = hostOpDispatch(stub, 'NimbusDOStub');
|
|
795
|
+
} catch (e) {
|
|
796
|
+
disposeRpcResource(stub);
|
|
797
|
+
return new Response(`Nimbus: ${e instanceof Error ? e.message : String(e)}`, { status: 500 });
|
|
798
|
+
}
|
|
841
799
|
// Forward the full request (method, body, headers preserved) by
|
|
842
800
|
// serializing what's needed and reconstructing on the other side.
|
|
843
801
|
// The supervisor reconstitutes the Request from these fields and
|
|
@@ -849,20 +807,27 @@ export class NimbusDOStub extends WorkerEntrypoint<NimbusInnerDoEnv, NimbusDoStu
|
|
|
849
807
|
request.headers.forEach((v, k) => { headerList.push([k, v]); });
|
|
850
808
|
try {
|
|
851
809
|
return await useRpcResource(
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
810
|
+
dispatch({
|
|
811
|
+
op: 'innerDoFetch',
|
|
812
|
+
args: [{
|
|
813
|
+
bindingName,
|
|
814
|
+
id,
|
|
815
|
+
method: request.method,
|
|
816
|
+
url: request.url,
|
|
817
|
+
headers: headerList,
|
|
818
|
+
body,
|
|
819
|
+
}],
|
|
859
820
|
}),
|
|
860
|
-
(res) =>
|
|
861
|
-
|
|
821
|
+
(res) => {
|
|
822
|
+
if (!(res instanceof Response)) {
|
|
823
|
+
return new Response('Nimbus: innerDoFetch returned an invalid result', { status: 502 });
|
|
824
|
+
}
|
|
825
|
+
return new Response(res.body, {
|
|
862
826
|
status: res.status,
|
|
863
827
|
statusText: res.statusText,
|
|
864
828
|
headers: res.headers,
|
|
865
|
-
})
|
|
829
|
+
});
|
|
830
|
+
},
|
|
866
831
|
);
|
|
867
832
|
} finally {
|
|
868
833
|
disposeRpcResource(stub);
|