@nimbus-sh/fabric 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +487 -0
  3. package/dist/alarms.d.ts +134 -0
  4. package/dist/alarms.d.ts.map +1 -0
  5. package/dist/alarms.js +214 -0
  6. package/dist/bindings.d.ts +316 -0
  7. package/dist/bindings.d.ts.map +1 -0
  8. package/dist/bindings.js +678 -0
  9. package/dist/ctx-exports.d.ts +47 -0
  10. package/dist/ctx-exports.d.ts.map +1 -0
  11. package/dist/ctx-exports.js +54 -0
  12. package/dist/facet-image-store.d.ts +112 -0
  13. package/dist/facet-image-store.d.ts.map +1 -0
  14. package/dist/facet-image-store.js +181 -0
  15. package/dist/fanout-pool.d.ts +223 -0
  16. package/dist/fanout-pool.d.ts.map +1 -0
  17. package/dist/fanout-pool.js +368 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +25 -0
  21. package/dist/inner-do-registry.d.ts +41 -0
  22. package/dist/inner-do-registry.d.ts.map +1 -0
  23. package/dist/inner-do-registry.js +51 -0
  24. package/dist/launch-journal.d.ts +170 -0
  25. package/dist/launch-journal.d.ts.map +1 -0
  26. package/dist/launch-journal.js +154 -0
  27. package/dist/launch-pacer.d.ts +173 -0
  28. package/dist/launch-pacer.d.ts.map +1 -0
  29. package/dist/launch-pacer.js +193 -0
  30. package/dist/loader-ledger.d.ts +57 -0
  31. package/dist/loader-ledger.d.ts.map +1 -0
  32. package/dist/loader-ledger.js +91 -0
  33. package/dist/loader-pool.d.ts +315 -0
  34. package/dist/loader-pool.d.ts.map +1 -0
  35. package/dist/loader-pool.js +666 -0
  36. package/dist/process-fabric.d.ts +524 -0
  37. package/dist/process-fabric.d.ts.map +1 -0
  38. package/dist/process-fabric.js +388 -0
  39. package/dist/process-host.d.ts +132 -0
  40. package/dist/process-host.d.ts.map +1 -0
  41. package/dist/process-host.js +444 -0
  42. package/dist/vendor/errors.d.ts +24 -0
  43. package/dist/vendor/errors.d.ts.map +1 -0
  44. package/dist/vendor/errors.js +46 -0
  45. package/dist/vendor/serialize.d.ts +3 -0
  46. package/dist/vendor/serialize.d.ts.map +1 -0
  47. package/dist/vendor/serialize.js +25 -0
  48. package/dist/vendor/types.d.ts +69 -0
  49. package/dist/vendor/types.d.ts.map +1 -0
  50. package/dist/vendor/types.js +4 -0
  51. package/dist/workerd-facet-host.d.ts +207 -0
  52. package/dist/workerd-facet-host.d.ts.map +1 -0
  53. package/dist/workerd-facet-host.js +508 -0
  54. package/dist/ws-hibernation-config.d.ts +73 -0
  55. package/dist/ws-hibernation-config.d.ts.map +1 -0
  56. package/dist/ws-hibernation-config.js +93 -0
  57. package/package.json +62 -0
  58. package/src/alarms.ts +275 -0
  59. package/src/bindings.ts +871 -0
  60. package/src/ctx-exports.ts +77 -0
  61. package/src/facet-image-store.ts +196 -0
  62. package/src/fanout-pool.ts +503 -0
  63. package/src/index.ts +26 -0
  64. package/src/inner-do-registry.ts +58 -0
  65. package/src/launch-journal.ts +229 -0
  66. package/src/launch-pacer.ts +231 -0
  67. package/src/loader-ledger.ts +112 -0
  68. package/src/loader-pool.ts +984 -0
  69. package/src/process-fabric.ts +729 -0
  70. package/src/process-host.ts +566 -0
  71. package/src/vendor/errors.ts +56 -0
  72. package/src/vendor/serialize.ts +37 -0
  73. package/src/vendor/types.ts +75 -0
  74. package/src/workerd-facet-host.ts +694 -0
  75. package/src/ws-hibernation-config.ts +123 -0
@@ -0,0 +1,871 @@
1
+ /**
2
+ * session/bindings.ts — Inner-Worker + assets binding shims (W10).
3
+ *
4
+ * `nimbus-wrangler dev` runs a USER worker as a child process. That
5
+ * child needs working `env` bindings (env.ASSETS, env.LOADER, env.MY_DO,
6
+ * etc.) but the DO's `env` belongs to the supervisor's contract — we
7
+ * can't pass it through directly. Workerd's enable_ctx_exports
8
+ * (compat date 2026-04-01+) auto-populates Service Bindings from
9
+ * top-level WorkerEntrypoint classes; these classes ARE those entry
10
+ * points. They forward each binding kind back to the supervisor DO via
11
+ * RPC stub (`env.NIMBUS_SESSION.idFromString(doId).get()...`).
12
+ *
13
+ * The shims have NO interaction with NimbusSession internals except
14
+ * through that RPC stub. Co-located here for grep-ability.
15
+ *
16
+ * NimbusAssetsRPC, NimbusLoaderRPC, NimbusLoadedWorker,
17
+ * NimbusLoadedEntrypoint, NimbusDurableObjectNamespace and NimbusDOStub are
18
+ * public API of the embedder's Worker: wrangler resolves them by class name
19
+ * and ctx.exports auto-populates them by export name, so the embedder's entry
20
+ * module re-exports them under exactly these names.
21
+ *
22
+ * Bundle-graph note: these classes must remain reachable from the embedder's
23
+ * entry module for Wrangler to bundle the WorkerEntrypoint exports.
24
+ */
25
+
26
+ import { WorkerEntrypoint } from 'cloudflare:workers';
27
+ import { z } from 'zod/v4';
28
+ import { disposeRpcResource, useRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
29
+ import { supervisorEntrypoint, supervisorEntrypointName } from './ctx-exports.js';
30
+ import { requireStagedBootAssembler } from './process-fabric.js';
31
+ import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
32
+ import type { EntrypointLoopbackFactory } from './ctx-exports.js';
33
+ import type { WorkerCode } from './vendor/types.js';
34
+
35
+ /**
36
+ * `ctx.exports` for the loopback hops: workerd mints one factory per top-level
37
+ * entrypoint export, and only the three the hops hand onward are named. An
38
+ * absent name is how a hop finds out the embedder's entry module does not
39
+ * re-export the class it needs.
40
+ */
41
+ interface ShimCtxExports {
42
+ NimbusLoadedWorker?: EntrypointLoopbackFactory;
43
+ NimbusLoadedEntrypoint?: EntrypointLoopbackFactory;
44
+ NimbusDOStub?: EntrypointLoopbackFactory;
45
+ }
46
+
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
+ /**
59
+ * A dynamic worker's entrypoint, as hop 3 relays to it. `fetch` is the
60
+ * entrypoint contract every loaded worker answers; `handleHttpRequest` is the
61
+ * fabric's own route target, which only a facet that serves ports exposes.
62
+ */
63
+ interface LoadedEntrypoint {
64
+ fetch(request: Request): Promise<Response>;
65
+ handleHttpRequest?(request: Request): Promise<Response>;
66
+ }
67
+
68
+ /** A stub for one dynamically-loaded worker, as the shims hop across it. */
69
+ interface LoadedWorker {
70
+ getEntrypoint(name?: string): LoadedEntrypoint;
71
+ getDurableObjectClass(name: string): DurableObjectClass;
72
+ }
73
+
74
+ /**
75
+ * The OUTER `env.LOADER` these shims forward to. `load` is the unkeyed arm the
76
+ * inner Worker asked for; `get` is the keyed arm every later hop re-enters in
77
+ * its own request context. `get`'s callback answers with whatever the caller
78
+ * assembled — a staged artifact's module map is the embedder's, so the fabric
79
+ * does not name it (see {@link ./process-fabric.js} StagedBootAssembler).
80
+ */
81
+ interface OuterWorkerLoader {
82
+ load(code: WorkerCode): LoadedWorker;
83
+ get(id: string, getCode: () => Promise<object>): LoadedWorker;
84
+ }
85
+
86
+ /**
87
+ * `env` for the three Worker-Loader hops. The depth var rides the env rather
88
+ * than props because it is set on the OUTERMOST session and every nested
89
+ * Nimbus inherits it.
90
+ */
91
+ interface NimbusLoaderShimEnv {
92
+ LOADER?: OuterWorkerLoader;
93
+ NIMBUS_INNER_LOADER_DEPTH?: string;
94
+ }
95
+
96
+ /**
97
+ * `ctx.exports` — workerd's loopback bag, which the installed
98
+ * @cloudflare/workers-types does not put on `ExecutionContext`. Probed rather
99
+ * than declared, so a runtime that predates it reads as absent, which is also
100
+ * what the module-level holder's fallback keys off.
101
+ */
102
+ function ctxExportsOf(ctx: unknown): unknown {
103
+ if (!ctx || typeof ctx !== 'object' || !('exports' in ctx)) return undefined;
104
+ return ctx.exports;
105
+ }
106
+
107
+ /**
108
+ * The loopback factories a hop may need. An absent bag reads as an empty set,
109
+ * which each hop reports as the specific class it could not find.
110
+ */
111
+ function shimCtxExports(ctx: unknown): ShimCtxExports {
112
+ const exports = ctxExportsOf(ctx);
113
+ if (!exports || typeof exports !== 'object') return {};
114
+ // The bag workerd populated. Which names are in it is the hop's question,
115
+ // and each hop answers it with its own error.
116
+ return exports as ShimCtxExports;
117
+ }
118
+
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
+ /** Props the assets shim is minted with. */
148
+ interface NimbusAssetsProps {
149
+ /** Project root in VFS (e.g. "home/user/myapp"). */
150
+ vfsRoot?: string;
151
+ /** Directory declared in wrangler.jsonc.assets.directory. */
152
+ assetsDir?: string;
153
+ /** Supervisor DO id whose VFS holds the assets. */
154
+ doId?: string;
155
+ }
156
+
157
+ /**
158
+ * Assets binding shim. The inner Worker calls `env.ASSETS.fetch(request)`
159
+ * and we serve the file from VFS under `<vfsRoot>/<assetsDir>/<pathname>`.
160
+ *
161
+ * Props (passed via ctx.props when this binding is constructed):
162
+ * vfsRoot — project root in VFS (e.g. "home/user/myapp")
163
+ * assetsDir — directory declared in wrangler.jsonc.assets.directory
164
+ * (e.g. "./public" → we trim the leading ./)
165
+ *
166
+ * The hostname on the incoming Request is irrelevant (Workers Assets
167
+ * convention); only pathname matters. Path traversal (`..`) is clamped.
168
+ * Directories resolve to their `index.html` child; missing files fall
169
+ * back to the assetsDir root `index.html` (SPA convention), then 404.
170
+ *
171
+ * The VFS is read from the supervisor DO via the class property
172
+ * `_nimbusVfsResolver` set by NimbusSession at construction. WorkerEntrypoint
173
+ * instances don't have direct access to the supervisor's SqliteVFS, so we
174
+ * reach it through the supervisor stub (env.NIMBUS_SESSION.idFromString).
175
+ * For Phase 1, we use a simpler approach: the props carry a supervisor
176
+ * DO id so we can round-trip through an RPC method that reads the file.
177
+ */
178
+ export class NimbusAssetsRPC extends WorkerEntrypoint<NimbusAssetsEnv, NimbusAssetsProps> {
179
+ /**
180
+ * Fetch a static asset. Called by the inner Worker as
181
+ * `env.ASSETS.fetch(request)`. The request URL's pathname is used to
182
+ * resolve a file under the configured assets directory.
183
+ */
184
+ async fetch(request: Request): Promise<Response> {
185
+ const url = new URL(request.url);
186
+ const props: NimbusAssetsProps = this.ctx.props || {};
187
+ const vfsRoot = String(props.vfsRoot || '');
188
+ const assetsDir = String(props.assetsDir || '').replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
189
+ const doId = String(props.doId || '');
190
+
191
+ // Normalize pathname: no leading /, drop .. segments entirely.
192
+ let clean = url.pathname.replace(/^\/+/, '');
193
+ const parts = clean.split('/').filter((p) => p && p !== '..' && p !== '.');
194
+ clean = parts.join('/');
195
+
196
+ // Resolve the supervisor DO stub so we can call its VFS read RPC.
197
+ const ns = this.env.NIMBUS_SESSION;
198
+ if (!ns || !doId) {
199
+ return new Response('ASSETS binding not wired: missing NIMBUS_SESSION or doId', { status: 500 });
200
+ }
201
+ const stub = ns.get(ns.idFromString(doId));
202
+
203
+ // Candidate VFS paths, tried in order. The assetsDir is relative to
204
+ // the project root in VFS. Trailing-slash and bare dir → index.html.
205
+ const base = (vfsRoot ? vfsRoot + '/' : '') + (assetsDir ? assetsDir + '/' : '');
206
+ const candidates: string[] = [];
207
+ if (clean) {
208
+ candidates.push(base + clean);
209
+ if (!clean.endsWith('.html') && !clean.includes('.')) {
210
+ candidates.push(base + clean.replace(/\/+$/, '') + '/index.html');
211
+ }
212
+ } else {
213
+ candidates.push(base + 'index.html');
214
+ }
215
+ // SPA fallback: any unmatched path serves the top-level index.html.
216
+ candidates.push(base + 'index.html');
217
+
218
+ try {
219
+ for (const candidate of candidates) {
220
+ try {
221
+ const response = await useRpcResource(
222
+ stub._rpcReadFileBytes(candidate),
223
+ (bytes: ArrayBuffer | Uint8Array | null) => {
224
+ if (!bytes || bytes.byteLength === undefined) return null;
225
+ return new Response(bytes, {
226
+ status: 200,
227
+ headers: {
228
+ 'Content-Type': mimeTypeForPath(candidate),
229
+ 'Cache-Control': 'no-store',
230
+ },
231
+ });
232
+ },
233
+ );
234
+ if (response) return response;
235
+ } catch { /* try next */ }
236
+ }
237
+ } finally {
238
+ disposeRpcResource(stub);
239
+ }
240
+
241
+ return new Response('Not found', { status: 404 });
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Pick a sensible content-type from a filename. Conservative list; the
247
+ * inner Worker can always override via the response it constructs
248
+ * (which Workers Assets won't touch for env.ASSETS.fetch results).
249
+ */
250
+ function mimeTypeForPath(path: string): string {
251
+ const i = path.lastIndexOf('.');
252
+ if (i < 0) return 'application/octet-stream';
253
+ const ext = path.slice(i + 1).toLowerCase();
254
+ switch (ext) {
255
+ case 'html': case 'htm': return 'text/html; charset=utf-8';
256
+ case 'css': return 'text/css; charset=utf-8';
257
+ case 'js': case 'mjs': return 'application/javascript; charset=utf-8';
258
+ case 'json': return 'application/json; charset=utf-8';
259
+ case 'svg': return 'image/svg+xml';
260
+ case 'png': return 'image/png';
261
+ case 'jpg': case 'jpeg': return 'image/jpeg';
262
+ case 'webp': return 'image/webp';
263
+ case 'gif': return 'image/gif';
264
+ case 'ico': return 'image/x-icon';
265
+ case 'woff': return 'font/woff';
266
+ case 'woff2': return 'font/woff2';
267
+ case 'txt': return 'text/plain; charset=utf-8';
268
+ case 'xml': return 'application/xml; charset=utf-8';
269
+ case 'wasm': return 'application/wasm';
270
+ case 'map': return 'application/json; charset=utf-8';
271
+ default: return 'application/octet-stream';
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Worker Loader binding shim.
277
+ *
278
+ * Option A — return the raw WorkerStub from RPC — was attempted first
279
+ * and failed at runtime with:
280
+ * "Could not serialize object of type \"WorkerStub\". This type does
281
+ * not support serialization."
282
+ *
283
+ * Option B — proxy the stub via chained WorkerEntrypoint classes — is
284
+ * implemented here. The three classes below mirror the three hops a
285
+ * caller makes:
286
+ *
287
+ * env.LOADER.load(code) → NimbusLoaderRPC.load (returns NimbusLoadedWorker)
288
+ * .getEntrypoint(name?) → NimbusLoadedWorker.getEntrypoint (returns NimbusLoadedEntrypoint)
289
+ * .fetch(request) → NimbusLoadedEntrypoint.fetch
290
+ *
291
+ * Each class is a WorkerEntrypoint, so Service Binding stubs for them
292
+ * pass across the isolate boundary cleanly. The outer WorkerStub lives
293
+ * at a module-level Map keyed by a random id that's carried in
294
+ * ctx.props so subsequent hops can look it up from the outer side.
295
+ *
296
+ * Depth cap (ctx.props.depth) prevents infinite nesting: Nimbus-in-
297
+ * Nimbus-in-Nimbus is fine; five levels deep is almost certainly a
298
+ * runaway and we throw a clear error. Default limit is 4; overridable
299
+ * via the NIMBUS_INNER_LOADER_DEPTH env var on the outermost session.
300
+ */
301
+
302
+ /**
303
+ * Module-level map of loaded worker CODE (not stubs), keyed by a random
304
+ * id. WorkerStubs are I/O objects tied to a request context, so they
305
+ * can't be stashed for later use ("Cannot perform I/O on behalf of a
306
+ * different request"). Storing the code instead lets each new outer
307
+ * request re-load the worker in its own context via env.LOADER.get(id)
308
+ * — workerd caches by id so repeated loads are essentially free.
309
+ *
310
+ * H7 (memory accounting cleanup). The pre-fix comment said "GC isn't
311
+ * needed" because "inner stubs that reference them die with the DO."
312
+ * That was true for STUBS but FALSE for these CODE entries: nothing
313
+ * deletes them. `wrangler dev`'s rebuild-on-save loop calls load()
314
+ * on every save, so the Map grows without bound until the supervisor
315
+ * isolate is evicted (or hits the 128 MiB hard cap and crashes).
316
+ *
317
+ * Fix: hard-cap LRU. The Map's iteration order is insertion order;
318
+ * we re-insert on every read AND eviction-on-overflow drops the
319
+ * oldest entry. _LOADED_CODES_MAX is a documented architectural cap
320
+ * (32 entries). Eviction count is observable via getLoadedCodesStats()
321
+ * which the diag endpoint surfaces.
322
+ *
323
+ * Why 32? wrangler dev's typical rebuild burst is < 5 entries before
324
+ * the user notices and stops typing. 32 covers a power user's
325
+ * iteration cycle and a reasonable amount of `LOADER.get(id, cb)`
326
+ * memoization without exposing more than a few MiB of code text in
327
+ * the worst case (typical user-worker bundle: 50-300 KiB; 32 × 300 KiB
328
+ * = ~10 MiB ceiling — well under the 64 MiB supervisor budget).
329
+ */
330
+ const _NIMBUS_LOADED_CODES: Map<string, WorkerCode> = new Map();
331
+ const _LOADED_CODES_MAX = 32;
332
+ let _loadedCodesEvictions = 0;
333
+
334
+ const NimbusLoadedEntrypointPropsSchema = z.object({
335
+ key: z.string().min(1),
336
+ name: z.string().nullable().optional(),
337
+ depth: z.number().int().nonnegative().optional(),
338
+ supervisor: z.object({
339
+ doId: z.string().min(1),
340
+ pid: z.number().int().nonnegative(),
341
+ writerId: z.string().uuid(),
342
+ }).optional(),
343
+ /**
344
+ * Staged-artifact spec, for a ONE-SHOT run. The module map — ~23 MB for
345
+ * Nimbus's largest stage — is assembled HERE, in this stateless
346
+ * entrypoint's isolate, on the Worker-Loader cache-miss path, so a
347
+ * one-shot run never materializes the artifact sources anywhere else.
348
+ * Validated by the registered assembler.
349
+ */
350
+ stage: z.unknown().optional(),
351
+ }).passthrough();
352
+
353
+ type NimbusLoadedEntrypointProps = z.infer<typeof NimbusLoadedEntrypointPropsSchema>;
354
+
355
+ async function materializeNestedRpcRequest(request: Request): Promise<Request> {
356
+ const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
357
+ const init: RequestInit & { duplex?: 'half' } = {
358
+ method: request.method,
359
+ headers: new Headers(request.headers),
360
+ body: hasBody ? await request.arrayBuffer() : undefined,
361
+ };
362
+ if (hasBody) init.duplex = 'half';
363
+ return new Request(request.url, init);
364
+ }
365
+
366
+ /**
367
+ * Insert OR refresh a key in the LRU. New keys may evict the oldest
368
+ * entry if at the cap; existing keys are re-inserted to update their
369
+ * recency.
370
+ */
371
+ function _loadedCodesPut(key: string, code: WorkerCode): void {
372
+ // If the key already exists, delete first so re-insertion lands at
373
+ // the MRU end of the iteration order (LRU-style refresh).
374
+ if (_NIMBUS_LOADED_CODES.has(key)) {
375
+ _NIMBUS_LOADED_CODES.delete(key);
376
+ } else if (_NIMBUS_LOADED_CODES.size >= _LOADED_CODES_MAX) {
377
+ // Evict the LRU entry — the first key in insertion order.
378
+ const oldest = _NIMBUS_LOADED_CODES.keys().next();
379
+ if (!oldest.done) {
380
+ _NIMBUS_LOADED_CODES.delete(oldest.value);
381
+ _loadedCodesEvictions++;
382
+ }
383
+ }
384
+ _NIMBUS_LOADED_CODES.set(key, code);
385
+ }
386
+
387
+ function _loadedCodesGet(key: string): WorkerCode | undefined {
388
+ const v = _NIMBUS_LOADED_CODES.get(key);
389
+ if (v === undefined) return undefined;
390
+ // LRU-refresh on read so memoization-style usage (LOADER.get(id, cb)
391
+ // re-hitting the same id repeatedly) keeps the entry warm.
392
+ _NIMBUS_LOADED_CODES.delete(key);
393
+ _NIMBUS_LOADED_CODES.set(key, v);
394
+ return v;
395
+ }
396
+
397
+ /**
398
+ * Diagnostic surface for /api/_diag/memory. Returns a snapshot of
399
+ * the Map state — entry count, configured cap, eviction counter
400
+ * since isolate boot. Pure read; no I/O.
401
+ */
402
+ export function getLoadedCodesStats(): { entries: number; maxEntries: number; evictions: number } {
403
+ return {
404
+ entries: _NIMBUS_LOADED_CODES.size,
405
+ maxEntries: _LOADED_CODES_MAX,
406
+ evictions: _loadedCodesEvictions,
407
+ };
408
+ }
409
+
410
+ function _genStubId(): string {
411
+ return 'ldr-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
412
+ }
413
+
414
+ /**
415
+ * Look up the stored code by key and create a fresh outer WorkerStub
416
+ * in the CURRENT request context. Uses LOADER.get(id, cb) so repeated
417
+ * calls reuse the same dynamic worker rather than spawning new ones.
418
+ */
419
+ function _resolveStubInCurrentContext(
420
+ outerLoader: OuterWorkerLoader,
421
+ key: string | undefined,
422
+ ): LoadedWorker | null {
423
+ if (key === undefined) return null;
424
+ const code = _loadedCodesGet(key);
425
+ if (!code) return null;
426
+ return outerLoader.get(key, async () => code);
427
+ }
428
+
429
+ /** Props every Worker-Loader hop carries: how deep this Nimbus already is. */
430
+ interface NimbusLoaderDepthProps {
431
+ depth?: number;
432
+ }
433
+
434
+ /** Hop 1: env.LOADER.{load,get} forwarded to the outer loader. */
435
+ export class NimbusLoaderRPC extends WorkerEntrypoint<NimbusLoaderShimEnv, NimbusLoaderDepthProps> {
436
+ private _currentDepth(): number {
437
+ const d = this.ctx.props?.depth;
438
+ return typeof d === 'number' && d >= 0 ? d : 0;
439
+ }
440
+
441
+ private _maxDepth(): number {
442
+ const raw = this.env?.NIMBUS_INNER_LOADER_DEPTH;
443
+ const parsed = raw ? parseInt(String(raw), 10) : NaN;
444
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
445
+ }
446
+
447
+ private _assertDepthOk(): void {
448
+ const depth = this._currentDepth();
449
+ const max = this._maxDepth();
450
+ if (depth >= max) {
451
+ throw new Error(
452
+ `Nimbus: refusing to spawn inner Worker Loader (depth=${depth + 1}, max=${max}). ` +
453
+ `Set NIMBUS_INNER_LOADER_DEPTH to raise the cap or break the recursion.`,
454
+ );
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Inner: env.LOADER.load(code). Stashes the CODE (not a stub — stubs
460
+ * are I/O-bound to the calling request context) and returns a
461
+ * NimbusLoadedWorker RPC stub. Each downstream call re-loads the
462
+ * worker in its own request context via LOADER.get(key, cb).
463
+ */
464
+ load(code: WorkerCode): unknown {
465
+ this._assertDepthOk();
466
+ const outerLoader = this.env?.LOADER;
467
+ if (!outerLoader) throw new Error('Nimbus: outer env.LOADER missing');
468
+ // Validate by loading once in THIS context (fails fast on bad code).
469
+ // The stub is discarded; downstream calls re-load fresh in their
470
+ // own context.
471
+ outerLoader.load(code);
472
+ const key = _genStubId();
473
+ _loadedCodesPut(key, code);
474
+ const ctxExports = shimCtxExports(this.ctx);
475
+ if (!ctxExports.NimbusLoadedWorker) {
476
+ throw new Error('Nimbus: ctx.exports.NimbusLoadedWorker unavailable');
477
+ }
478
+ return ctxExports.NimbusLoadedWorker({
479
+ props: { key, depth: this.ctx.props?.depth || 0 },
480
+ });
481
+ }
482
+
483
+ /**
484
+ * Inner: env.LOADER.get(id, callback). The inner's callback returns
485
+ * a code object; we treat `id` as the outer cache key (prefixed so
486
+ * it doesn't collide with load()-generated keys).
487
+ */
488
+ async get(id: string, callback: () => WorkerCode | Promise<WorkerCode>): Promise<unknown> {
489
+ this._assertDepthOk();
490
+ const outerLoader = this.env?.LOADER;
491
+ if (!outerLoader) throw new Error('Nimbus: outer env.LOADER missing');
492
+ const key = 'get:' + id;
493
+ if (_loadedCodesGet(key) === undefined) {
494
+ const code = await callback();
495
+ _loadedCodesPut(key, code);
496
+ }
497
+ const ctxExports = shimCtxExports(this.ctx);
498
+ if (!ctxExports.NimbusLoadedWorker) {
499
+ throw new Error('Nimbus: ctx.exports.NimbusLoadedWorker unavailable');
500
+ }
501
+ return ctxExports.NimbusLoadedWorker({
502
+ props: { key, depth: this.ctx.props?.depth || 0 },
503
+ });
504
+ }
505
+ }
506
+
507
+ /** Props hop 2 carries: the stashed code it re-loads, and its inherited depth. */
508
+ interface NimbusLoadedWorkerProps extends NimbusLoaderDepthProps {
509
+ key?: string;
510
+ }
511
+
512
+ /** Hop 2: the returned "worker" stub. Exposes .getEntrypoint(). */
513
+ export class NimbusLoadedWorker extends WorkerEntrypoint<NimbusLoaderShimEnv, NimbusLoadedWorkerProps> {
514
+ /**
515
+ * Returns a NimbusLoadedEntrypoint stub that carries the code key +
516
+ * entrypoint name forward. The actual outer-side load + fetch happens
517
+ * inside NimbusLoadedEntrypoint.fetch() so all outer hops run in a
518
+ * SINGLE outer request context (the cross-request-I/O limitation is
519
+ * real — stubs created in one outer request can't be used by another).
520
+ */
521
+ getEntrypoint(name?: string): unknown {
522
+ const props: NimbusLoadedWorkerProps = this.ctx.props || {};
523
+ const ctxExports = shimCtxExports(this.ctx);
524
+ if (!ctxExports.NimbusLoadedEntrypoint) {
525
+ throw new Error('Nimbus: ctx.exports.NimbusLoadedEntrypoint unavailable');
526
+ }
527
+ return ctxExports.NimbusLoadedEntrypoint({
528
+ props: { key: props.key, name: name || null, depth: props.depth },
529
+ });
530
+ }
531
+
532
+ /**
533
+ * Pass-through to outer worker.getDurableObjectClass(name). The
534
+ * returned stub is tied to THIS method's outer request context; if
535
+ * the caller (the inner worker) uses the class in a later request
536
+ * it will fail the cross-request-I/O check. For Phase 3 DO binding
537
+ * synthesis we resolve classes directly from nimbus-wrangler's own
538
+ * request context (which is the build-time context), not through
539
+ * this method.
540
+ */
541
+ getDurableObjectClass(name: string): DurableObjectClass {
542
+ const props: NimbusLoadedWorkerProps = this.ctx.props || {};
543
+ const outerLoader = this.env?.LOADER;
544
+ if (!outerLoader) throw new Error('Nimbus: outer env.LOADER missing');
545
+ const outer = _resolveStubInCurrentContext(outerLoader, props.key);
546
+ if (!outer) throw new Error('Nimbus: loaded worker code missing (key=' + props.key + ')');
547
+ return outer.getDurableObjectClass(name);
548
+ }
549
+ }
550
+
551
+ /** Hop 3: a named-or-default entrypoint. Exposes .fetch(). */
552
+ export class NimbusLoadedEntrypoint extends WorkerEntrypoint<NimbusLoaderShimEnv, NimbusLoadedEntrypointProps> {
553
+ _props(): NimbusLoadedEntrypointProps {
554
+ return NimbusLoadedEntrypointPropsSchema.parse(this.ctx.props || {});
555
+ }
556
+
557
+ async _supervisorBinding(props: NimbusLoadedEntrypointProps): Promise<unknown> {
558
+ if (!props.supervisor) return undefined;
559
+ const factory = supervisorEntrypoint(ctxExportsOf(this.ctx));
560
+ if (!factory) {
561
+ throw new Error(
562
+ `Nimbus: ctx.exports.${supervisorEntrypointName() ?? '<supervisor entrypoint>'} unavailable`,
563
+ );
564
+ }
565
+ return await factory({ props: props.supervisor });
566
+ }
567
+
568
+ async _resolveEntrypoint(): Promise<LoadedEntrypoint> {
569
+ const props = this._props();
570
+ const outerLoader = this.env?.LOADER;
571
+ if (!outerLoader) throw new Error('Nimbus: outer env.LOADER missing');
572
+ let outerStub: LoadedWorker;
573
+ if (props.stage !== undefined) {
574
+ // Staged artifact: assemble the full module map lazily, ONLY on a
575
+ // loader miss, in THIS stateless isolate. The facet's SUPERVISOR
576
+ // binding is created in this request context — the caller holds the
577
+ // one-shot fetch open for the whole run, which keeps that context
578
+ // alive.
579
+ const stage = props.stage;
580
+ outerStub = outerLoader.get(props.key, async () => {
581
+ const assembled = await requireStagedBootAssembler()(this.env, stage);
582
+ assertModuleMapWithinCodeLimit(
583
+ (assembled as { modules?: Record<string, unknown> }).modules ?? {},
584
+ );
585
+ const supervisorBinding = await this._supervisorBinding(props);
586
+ if (!supervisorBinding) return assembled;
587
+ return { ...assembled, env: { SUPERVISOR: supervisorBinding } };
588
+ });
589
+ } else {
590
+ // No spec in props: resolve the ALREADY-LOADED worker. First the inner
591
+ // Worker Loader shim's code map (nimbus-in-nimbus), else the outer
592
+ // loader's own cache. The cache-miss callback fails loud: a spec-free
593
+ // stub is a handle on a worker someone else loaded — re-loading it from
594
+ // code would boot an empty isolate, a silent wrong answer.
595
+ outerStub = _resolveStubInCurrentContext(outerLoader, props.key)
596
+ ?? outerLoader.get(props.key, async () => {
597
+ throw new Error(`Nimbus: dynamic worker '${props.key}' is no longer loaded (evicted?)`);
598
+ });
599
+ }
600
+ const outer = await outerStub;
601
+ if (!outer) throw new Error('Nimbus: loaded worker code missing');
602
+ return await (props.name ? outer.getEntrypoint(props.name) : outer.getEntrypoint());
603
+ }
604
+
605
+ /**
606
+ * Relay the inner entrypoint's Response to the caller with a LIVE body.
607
+ * The body streams through an identity pipe and the entrypoint stub is
608
+ * disposed only once the body finishes — materializing (arrayBuffer) here
609
+ * buffered every routed response to stream-end, which froze SSE/chunked
610
+ * bodies (an agent server's /event live-sync, `curl -N` loopback, external
611
+ * preview) until the facet closed the stream.
612
+ */
613
+ private _relayNestedRpcResponse(ep: unknown, response: unknown): Response {
614
+ if (!(response instanceof Response)) {
615
+ disposeRpcResource(response);
616
+ disposeRpcResource(ep);
617
+ return new Response('Nimbus: loaded worker entrypoint returned a non-Response value', { status: 502 });
618
+ }
619
+ const init = {
620
+ status: response.status,
621
+ statusText: response.statusText,
622
+ headers: new Headers(response.headers),
623
+ };
624
+ if (!response.body) {
625
+ disposeRpcResource(ep);
626
+ return new Response(null, init);
627
+ }
628
+ const { readable, writable } = new IdentityTransformStream();
629
+ this.ctx.waitUntil(
630
+ response.body
631
+ .pipeTo(writable)
632
+ .catch(() => {})
633
+ .finally(() => disposeRpcResource(ep)),
634
+ );
635
+ return new Response(readable, init);
636
+ }
637
+
638
+ /**
639
+ * Invoke the facet's HTTP handler.
640
+ *
641
+ * The call must be written as `ep.method(request)`. An RPC stub's method is a
642
+ * JsRpcProperty, whose every property access is a WILDCARD that extends a
643
+ * pipelined path (`JSG_WILDCARD_PROPERTY`, workerd api/worker-rpc.h) — so
644
+ * `method.call(ep, request)` does NOT reach Function.prototype.call. It builds
645
+ * the path `handleHttpRequest.call` and invokes it remotely with `ep` as its
646
+ * first ARGUMENT. Serializing `ep` — an entrypoint to a dynamically-loaded
647
+ * worker — is what workerd refuses:
648
+ *
649
+ * DataCloneError: Entrypoints to dynamically-loaded workers cannot be
650
+ * transferred to other Workers
651
+ *
652
+ * (server.c++ `requireAllowsTransfer` → `throwDynamicEntrypointTransferError`).
653
+ * The facet is never entered, because the failure is in serializing the
654
+ * arguments, before the call is delivered.
655
+ */
656
+ private _callHttpHandler(ep: LoadedEntrypoint, request: Request): Promise<Response> {
657
+ return typeof ep.handleHttpRequest === 'function'
658
+ ? ep.handleHttpRequest(request)
659
+ : ep.fetch(request);
660
+ }
661
+
662
+ async handleHttpRequest(request: Request): Promise<Response> {
663
+ const ep = await this._resolveEntrypoint();
664
+ try {
665
+ if (typeof ep.handleHttpRequest !== 'function' && typeof ep.fetch !== 'function') {
666
+ disposeRpcResource(ep);
667
+ return new Response('Nimbus: loaded worker entrypoint has no HTTP request handler', { status: 502 });
668
+ }
669
+ const response = await this._callHttpHandler(ep, await materializeNestedRpcRequest(request));
670
+ return this._relayNestedRpcResponse(ep, response);
671
+ } catch (e) {
672
+ disposeRpcResource(ep);
673
+ throw e;
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Forward fetch() to the outer worker's entrypoint. All three outer
679
+ * hops (load → getEntrypoint → fetch) run in the same outer request
680
+ * context (this method's invocation), which sidesteps the
681
+ * cross-request-I/O limitation.
682
+ */
683
+ async fetch(request: Request): Promise<Response> {
684
+ const ep = await this._resolveEntrypoint();
685
+ try {
686
+ const response = await ep.fetch(await materializeNestedRpcRequest(request));
687
+ return this._relayNestedRpcResponse(ep, response);
688
+ } catch (e) {
689
+ disposeRpcResource(ep);
690
+ throw e;
691
+ }
692
+ }
693
+ }
694
+
695
+ // ── Durable Object binding synthesis ────────────────────────────────────
696
+ //
697
+ // The inner-DO class registry was extracted to ./inner-do-registry.ts in
698
+ // Arc A Phase 3 to break the import cycle:
699
+ // index.ts -> nimbus-session.ts -> nimbus-wrangler.ts -> nimbus-session.ts
700
+ // nimbus-wrangler.ts now consumes registerInnerDoClass/clearInnerDoClasses
701
+ // directly from the leaf, and this file consumes getInnerDoClass via the
702
+ // imports at the top. The Map identity is preserved across the isolate
703
+ // (still process-scoped module-level state).
704
+ //
705
+ // Inner Worker code:
706
+ // const stub = env.MY_DO.get(env.MY_DO.idFromName('x'));
707
+ // await stub.fetch(req);
708
+ // We synthesize env.MY_DO as a NimbusDurableObjectNamespace
709
+ // WorkerEntrypoint stub. Its .get() returns a NimbusDOStub that — on
710
+ // fetch() — resolves the class from the registry and invokes
711
+ // ctx.facets.get(facetName, {class, id}).fetch(req) in the same outer
712
+ // request context.
713
+
714
+ /** Props the synthesized namespace carries: which binding, on which supervisor. */
715
+ interface NimbusDoNamespaceProps {
716
+ bindingName?: string;
717
+ supervisorDoId?: string;
718
+ }
719
+
720
+ /**
721
+ * `env.MY_DO` shim — a DurableObjectNamespace-like WorkerEntrypoint.
722
+ *
723
+ * Usage from inner Worker:
724
+ * const id = await env.MY_DO.idFromName('x'); // AWAIT required
725
+ * const stub = env.MY_DO.get(id);
726
+ * await stub.fetch(request);
727
+ *
728
+ * IMPORTANT: unlike the real DurableObjectNamespace, idFromName /
729
+ * newUniqueId / idFromString here return **Promises**, because they're
730
+ * RPC-backed WorkerEntrypoint methods. The inner caller MUST `await`
731
+ * them before passing the result to `.get()`. Workers RPC pipelining
732
+ * does not currently allow passing an RpcPromise as a method argument
733
+ * — the no-await form fails with:
734
+ * "Could not serialize object of type \"RpcPromise\"."
735
+ *
736
+ * Typical real-Worker code written for Cloudflare's synchronous
737
+ * DurableObjectNamespace needs a one-word change (add `await`).
738
+ *
739
+ * idFromName produces prefix `name:` (deterministic FNV-style hash);
740
+ * newUniqueId uses `uniq:` (random). The prefixes keep the two id
741
+ * spaces distinct so a name-derived id can't collide with a random
742
+ * one.
743
+ */
744
+ export class NimbusDurableObjectNamespace extends WorkerEntrypoint<unknown, NimbusDoNamespaceProps> {
745
+ /** Stable string id derived from a name. Hash is deterministic. */
746
+ idFromName(name: string): string {
747
+ // Simple 64-bit-ish FNV-style hash → hex. Stable across runs;
748
+ // distinct names → distinct strings; same name → same string.
749
+ let h1 = 0xdeadbeef ^ name.length;
750
+ let h2 = 0x41c6ce57 ^ name.length;
751
+ for (let i = 0; i < name.length; i++) {
752
+ const ch = name.charCodeAt(i);
753
+ h1 = Math.imul(h1 ^ ch, 2654435761);
754
+ h2 = Math.imul(h2 ^ ch, 1597334677);
755
+ }
756
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
757
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
758
+ const high = (h1 >>> 0).toString(16).padStart(8, '0');
759
+ const low = (h2 >>> 0).toString(16).padStart(8, '0');
760
+ return 'name:' + high + low;
761
+ }
762
+
763
+ /** Fresh random id (matches DurableObjectNamespace.newUniqueId()). */
764
+ newUniqueId(): string {
765
+ return 'uniq:' + Math.random().toString(36).slice(2) + Date.now().toString(36);
766
+ }
767
+
768
+ /** Accept-through for an already-formatted id. */
769
+ idFromString(s: string): string {
770
+ return s;
771
+ }
772
+
773
+ /** Return a stub bound to the given id. */
774
+ get(id: string): unknown {
775
+ const ctxExports = shimCtxExports(this.ctx);
776
+ if (!ctxExports.NimbusDOStub) throw new Error('Nimbus: ctx.exports.NimbusDOStub unavailable');
777
+ const props: NimbusDoNamespaceProps = this.ctx.props || {};
778
+ return ctxExports.NimbusDOStub({
779
+ props: {
780
+ bindingName: props.bindingName,
781
+ supervisorDoId: props.supervisorDoId,
782
+ id: String(id),
783
+ },
784
+ });
785
+ }
786
+ }
787
+
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
+ /** Props the DO stub carries: which binding, which supervisor, which id. */
814
+ interface NimbusDoStubProps extends NimbusDoNamespaceProps {
815
+ id?: string;
816
+ }
817
+
818
+ /**
819
+ * A Durable-Object-namespace-stub for a specific id. Exposes fetch()
820
+ * and will, if we later need it, forward RPC method calls through a
821
+ * dispatch helper. The important invariant: EVERY call resolves the
822
+ * inner DO class via getInnerDoClass() (./inner-do-registry.js) and
823
+ * spins up / attaches to a facet via the supervisor's ctx.facets in
824
+ * the SAME outer request context — never reusing stubs across requests.
825
+ */
826
+ export class NimbusDOStub extends WorkerEntrypoint<NimbusInnerDoEnv, NimbusDoStubProps> {
827
+ /**
828
+ * Resolve the supervisor DO from env.NIMBUS_SESSION and route through
829
+ * its _rpcInnerDoFetch RPC method, which runs ctx.facets.get(...) in
830
+ * its own context and forwards the request.
831
+ */
832
+ async fetch(request: Request): Promise<Response> {
833
+ 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
+ const supervisorDoId = String(props.supervisorDoId || '');
837
+ if (!supervisorDoId) return new Response('Nimbus: supervisorDoId missing', { status: 500 });
838
+ const bindingName = String(props.bindingName || '');
839
+ const id = String(props.id || '');
840
+ const stub = ns.get(ns.idFromString(supervisorDoId));
841
+ // Forward the full request (method, body, headers preserved) by
842
+ // serializing what's needed and reconstructing on the other side.
843
+ // The supervisor reconstitutes the Request from these fields and
844
+ // invokes the facet.
845
+ const body = request.method !== 'GET' && request.method !== 'HEAD'
846
+ ? await request.arrayBuffer()
847
+ : null;
848
+ const headerList: [string, string][] = [];
849
+ request.headers.forEach((v, k) => { headerList.push([k, v]); });
850
+ try {
851
+ return await useRpcResource(
852
+ stub._rpcInnerDoFetch({
853
+ bindingName,
854
+ id,
855
+ method: request.method,
856
+ url: request.url,
857
+ headers: headerList,
858
+ body,
859
+ }),
860
+ (res) =>
861
+ new Response(res.body, {
862
+ status: res.status,
863
+ statusText: res.statusText,
864
+ headers: res.headers,
865
+ }),
866
+ );
867
+ } finally {
868
+ disposeRpcResource(stub);
869
+ }
870
+ }
871
+ }