@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.
- package/LICENSE +21 -0
- package/README.md +487 -0
- package/dist/alarms.d.ts +134 -0
- package/dist/alarms.d.ts.map +1 -0
- package/dist/alarms.js +214 -0
- package/dist/bindings.d.ts +316 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +678 -0
- package/dist/ctx-exports.d.ts +47 -0
- package/dist/ctx-exports.d.ts.map +1 -0
- package/dist/ctx-exports.js +54 -0
- package/dist/facet-image-store.d.ts +112 -0
- package/dist/facet-image-store.d.ts.map +1 -0
- package/dist/facet-image-store.js +181 -0
- package/dist/fanout-pool.d.ts +223 -0
- package/dist/fanout-pool.d.ts.map +1 -0
- package/dist/fanout-pool.js +368 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/inner-do-registry.d.ts +41 -0
- package/dist/inner-do-registry.d.ts.map +1 -0
- package/dist/inner-do-registry.js +51 -0
- package/dist/launch-journal.d.ts +170 -0
- package/dist/launch-journal.d.ts.map +1 -0
- package/dist/launch-journal.js +154 -0
- package/dist/launch-pacer.d.ts +173 -0
- package/dist/launch-pacer.d.ts.map +1 -0
- package/dist/launch-pacer.js +193 -0
- package/dist/loader-ledger.d.ts +57 -0
- package/dist/loader-ledger.d.ts.map +1 -0
- package/dist/loader-ledger.js +91 -0
- package/dist/loader-pool.d.ts +315 -0
- package/dist/loader-pool.d.ts.map +1 -0
- package/dist/loader-pool.js +666 -0
- package/dist/process-fabric.d.ts +524 -0
- package/dist/process-fabric.d.ts.map +1 -0
- package/dist/process-fabric.js +388 -0
- package/dist/process-host.d.ts +132 -0
- package/dist/process-host.d.ts.map +1 -0
- package/dist/process-host.js +444 -0
- package/dist/vendor/errors.d.ts +24 -0
- package/dist/vendor/errors.d.ts.map +1 -0
- package/dist/vendor/errors.js +46 -0
- package/dist/vendor/serialize.d.ts +3 -0
- package/dist/vendor/serialize.d.ts.map +1 -0
- package/dist/vendor/serialize.js +25 -0
- package/dist/vendor/types.d.ts +69 -0
- package/dist/vendor/types.d.ts.map +1 -0
- package/dist/vendor/types.js +4 -0
- package/dist/workerd-facet-host.d.ts +207 -0
- package/dist/workerd-facet-host.d.ts.map +1 -0
- package/dist/workerd-facet-host.js +508 -0
- package/dist/ws-hibernation-config.d.ts +73 -0
- package/dist/ws-hibernation-config.d.ts.map +1 -0
- package/dist/ws-hibernation-config.js +93 -0
- package/package.json +62 -0
- package/src/alarms.ts +275 -0
- package/src/bindings.ts +871 -0
- package/src/ctx-exports.ts +77 -0
- package/src/facet-image-store.ts +196 -0
- package/src/fanout-pool.ts +503 -0
- package/src/index.ts +26 -0
- package/src/inner-do-registry.ts +58 -0
- package/src/launch-journal.ts +229 -0
- package/src/launch-pacer.ts +231 -0
- package/src/loader-ledger.ts +112 -0
- package/src/loader-pool.ts +984 -0
- package/src/process-fabric.ts +729 -0
- package/src/process-host.ts +566 -0
- package/src/vendor/errors.ts +56 -0
- package/src/vendor/serialize.ts +37 -0
- package/src/vendor/types.ts +75 -0
- package/src/workerd-facet-host.ts +694 -0
- package/src/ws-hibernation-config.ts +123 -0
package/src/bindings.ts
ADDED
|
@@ -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
|
+
}
|