@nimbus-sh/fabric 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -11
- package/dist/bindings.d.ts +31 -33
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +108 -97
- package/dist/budgets.d.ts +102 -29
- package/dist/budgets.d.ts.map +1 -1
- package/dist/budgets.js +266 -44
- package/dist/do-calls.d.ts +20 -0
- package/dist/do-calls.d.ts.map +1 -1
- package/dist/do-calls.js +24 -11
- package/dist/fanout.d.ts +40 -32
- package/dist/fanout.d.ts.map +1 -1
- package/dist/fanout.js +48 -51
- package/dist/fenced-work.d.ts +3 -3
- package/dist/fenced-work.js +3 -3
- package/dist/host-wasm.d.ts +29 -0
- package/dist/host-wasm.d.ts.map +1 -0
- package/dist/host-wasm.js +31 -0
- package/dist/image-store.d.ts +1 -1
- package/dist/image-store.d.ts.map +1 -1
- package/dist/image-store.js +33 -1
- package/dist/inner-do-env.d.ts +83 -0
- package/dist/inner-do-env.d.ts.map +1 -0
- package/dist/inner-do-env.js +181 -0
- package/dist/isolate-pool.d.ts +40 -23
- package/dist/isolate-pool.d.ts.map +1 -1
- package/dist/isolate-pool.js +105 -55
- package/dist/process-fabric.d.ts +26 -11
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +44 -0
- package/dist/timers.d.ts +12 -0
- package/dist/timers.d.ts.map +1 -1
- package/dist/timers.js +44 -9
- package/dist/vendor/types.d.ts +11 -5
- package/dist/vendor/types.d.ts.map +1 -1
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +35 -30
- package/package.json +4 -4
- package/src/bindings.ts +121 -98
- package/src/budgets.ts +311 -53
- package/src/do-calls.ts +45 -11
- package/src/fanout.ts +62 -53
- package/src/fenced-work.ts +3 -3
- package/src/host-wasm.ts +41 -0
- package/src/image-store.ts +29 -2
- package/src/inner-do-env.ts +213 -0
- package/src/isolate-pool.ts +145 -75
- package/src/process-fabric.ts +55 -13
- package/src/timers.ts +45 -9
- package/src/vendor/types.ts +11 -5
- package/src/workerd-facet-host.ts +36 -31
package/src/fanout.ts
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Two-tier fan-out primitive for work that must execute in Worker Loader
|
|
3
|
-
*
|
|
3
|
+
* isolates within the Durable Object's Dynamic Worker budget.
|
|
4
4
|
*
|
|
5
|
-
* A
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* A Durable Object may have `DO_DYNAMIC_WORKER_LIMIT` distinct Dynamic
|
|
6
|
+
* Workers with in-flight requests, shared across every concurrent request to
|
|
7
|
+
* it (budgets.ts). A batch the coordinator's remaining headroom can hold —
|
|
8
|
+
* the limit less the workers it already has in flight (resident processes,
|
|
9
|
+
* the esbuild facet, a git network op) and other fan-outs' claims — runs in
|
|
10
|
+
* the coordinator through IsolatePool, one Dynamic Worker per task. Only a
|
|
11
|
+
* batch wider than that headroom is sharded across sibling NimbusSession
|
|
12
|
+
* DOs, each of which spends its own budget.
|
|
10
13
|
*
|
|
11
|
-
* Routing is deterministic: each task has a stable key,
|
|
12
|
-
* to a sibling DO shard. There is no silent fallback to
|
|
13
|
-
* missing LOADER or NIMBUS_SESSION bindings fail loudly so
|
|
14
|
-
* runtime operations do not appear successful after partial
|
|
14
|
+
* Routing is deterministic for a given headroom: each task has a stable key,
|
|
15
|
+
* and the key maps to a sibling DO shard. There is no silent fallback to
|
|
16
|
+
* width-1 execution; missing LOADER or NIMBUS_SESSION bindings fail loudly so
|
|
17
|
+
* install and runtime operations do not appear successful after partial
|
|
18
|
+
* dispatch.
|
|
15
19
|
*/
|
|
16
20
|
|
|
17
21
|
import { serializeFunction } from './vendor/serialize.js';
|
|
18
22
|
import { BindingError } from './vendor/errors.js';
|
|
19
23
|
import { IsolatePool, type FacetTaskFn } from './isolate-pool.js';
|
|
24
|
+
import { claimDynamicWorkers, dynamicWorkerHeadroom, type DynamicWorkerClaim } from './budgets.js';
|
|
20
25
|
import { hostRoute } from './composition.js';
|
|
21
26
|
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
22
27
|
import { classifyDoCall, describeError, isRetryableDoCall } from '@nimbus-sh/platform/oom-classify.js';
|
|
@@ -35,14 +40,17 @@ export interface FanoutEnv {
|
|
|
35
40
|
NIMBUS_SESSION?: unknown;
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
*/
|
|
45
|
-
|
|
43
|
+
/** Where one `submitMany` ran. */
|
|
44
|
+
export type FanoutTopology = 'in-do' | 'peer-do';
|
|
45
|
+
|
|
46
|
+
/** The routing decision for one `submitMany`, as its caller may log it. */
|
|
47
|
+
export interface FanoutRoute {
|
|
48
|
+
topology: FanoutTopology;
|
|
49
|
+
/** Tasks in the batch — the Dynamic Workers an in-DO run spends. */
|
|
50
|
+
tasks: number;
|
|
51
|
+
/** The coordinator's Dynamic Worker headroom when the batch was routed. */
|
|
52
|
+
headroom: number;
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
/**
|
|
48
56
|
* Hard cap on concurrent peer DOs per single submitMany call. Throughput stays
|
|
@@ -153,12 +161,14 @@ export interface FanoutOptions {
|
|
|
153
161
|
* Not called on the in-DO path, which has no phases.
|
|
154
162
|
*/
|
|
155
163
|
onDispatchPhase?: (width: number, elapsedMs: number) => void;
|
|
164
|
+
/** Called once per non-empty submitMany with the route it took. */
|
|
165
|
+
onRoute?: (route: FanoutRoute) => void;
|
|
156
166
|
/**
|
|
157
167
|
* Cap on peer DOs this pool will spread one submitMany across. Defaults to
|
|
158
168
|
* MAX_PEER_FANOUT. Tasks beyond the cap bucket into the peers that exist and
|
|
159
169
|
* run through their in-peer pool, so lowering it trades peers for barriers
|
|
160
|
-
* without lowering total concurrency: each peer runs its bucket
|
|
161
|
-
*
|
|
170
|
+
* without lowering total concurrency: each peer runs its bucket as wide as
|
|
171
|
+
* its own Dynamic Worker headroom allows.
|
|
162
172
|
*
|
|
163
173
|
* A caller sets this when its per-task work is small enough that a peer per
|
|
164
174
|
* task buys nothing but round-trips — one task per peer costs ⌈tasks/
|
|
@@ -178,7 +188,7 @@ function isPeerResult<R>(value: Awaited<ReturnType<HostOpDispatch>>): value is F
|
|
|
178
188
|
|
|
179
189
|
/**
|
|
180
190
|
* Two-tier fan-out pool. Constructed by the supervisor DO; routes
|
|
181
|
-
* each `submitMany` call
|
|
191
|
+
* each `submitMany` call on the coordinator's live Dynamic Worker headroom.
|
|
182
192
|
*
|
|
183
193
|
* Lifetime: cheap to construct (no async init). Multiple submitMany
|
|
184
194
|
* calls share NO state — each is dispatched fresh. The class
|
|
@@ -216,16 +226,16 @@ export class Fanout {
|
|
|
216
226
|
* Dispatch `tasks` across the appropriate topology and return
|
|
217
227
|
* results in input order.
|
|
218
228
|
*
|
|
219
|
-
* Routing:
|
|
220
|
-
* tasks.length
|
|
221
|
-
*
|
|
229
|
+
* Routing, against the coordinator's Dynamic Worker headroom at call time:
|
|
230
|
+
* tasks.length <= headroom -> coordinator-local IsolatePool, one Dynamic
|
|
231
|
+
* Worker per task, the width claimed on the
|
|
232
|
+
* ledger until the batch settles
|
|
233
|
+
* tasks.length > headroom -> sibling NimbusSession DOs
|
|
222
234
|
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
* single submitMany call returns when ALL tasks complete (or any
|
|
228
|
-
* throws).
|
|
235
|
+
* Peer shards: tasks hash onto min(tasks, maxPeers ?? MAX_PEER_FANOUT)
|
|
236
|
+
* peers and each peer runs its bucket through its own IsolatePool, as
|
|
237
|
+
* wide as that peer's headroom allows. A single submitMany call returns
|
|
238
|
+
* when ALL tasks complete (or any throws).
|
|
229
239
|
*
|
|
230
240
|
* `fn` is the user function executed per task. It runs INSIDE a
|
|
231
241
|
* Worker Loader isolate (in the in-DO path) or inside a peer DO's
|
|
@@ -239,16 +249,21 @@ export class Fanout {
|
|
|
239
249
|
): Promise<R[]> {
|
|
240
250
|
if (tasks.length === 0) return [];
|
|
241
251
|
|
|
242
|
-
|
|
243
|
-
|
|
252
|
+
const headroom = dynamicWorkerHeadroom(this.ctx);
|
|
253
|
+
const claim = claimDynamicWorkers(this.ctx, tasks.length);
|
|
254
|
+
this.opts.onRoute?.({ topology: claim ? 'in-do' : 'peer-do', tasks: tasks.length, headroom });
|
|
255
|
+
if (!claim) return this._dispatchPeerDo<A, R>(tasks, fn);
|
|
256
|
+
try {
|
|
257
|
+
return await this._dispatchInDo<A, R>(tasks, fn, claim);
|
|
258
|
+
} finally {
|
|
259
|
+
claim.release();
|
|
244
260
|
}
|
|
245
|
-
return this._dispatchPeerDo<A, R>(tasks, fn);
|
|
246
261
|
}
|
|
247
262
|
|
|
248
|
-
/**
|
|
249
|
-
topologyFor(taskCount: number):
|
|
263
|
+
/** The topology a task count would take against the headroom right now. */
|
|
264
|
+
topologyFor(taskCount: number): FanoutTopology | 'empty' {
|
|
250
265
|
if (taskCount === 0) return 'empty';
|
|
251
|
-
return taskCount
|
|
266
|
+
return taskCount <= dynamicWorkerHeadroom(this.ctx) ? 'in-do' : 'peer-do';
|
|
252
267
|
}
|
|
253
268
|
|
|
254
269
|
/**
|
|
@@ -268,14 +283,13 @@ export class Fanout {
|
|
|
268
283
|
private async _dispatchInDo<A, R>(
|
|
269
284
|
tasks: FanoutTask<A>[],
|
|
270
285
|
fn: FacetTaskFn<A, R>,
|
|
286
|
+
claim: DynamicWorkerClaim,
|
|
271
287
|
): Promise<R[]> {
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
// here, so the cap won't bite). Each task = one pool.submit;
|
|
275
|
-
// pool.map runs them with stable-slot reuse.
|
|
276
|
-
const concurrency = Math.min(tasks.length, IN_DO_THRESHOLD - 1);
|
|
288
|
+
// One slot — one Dynamic Worker — per task; submitMany has claimed
|
|
289
|
+
// that width on the ledger, and the pool's dispatches are held inside it.
|
|
277
290
|
const pool = new IsolatePool(this.env, this.ctx, {
|
|
278
|
-
concurrency,
|
|
291
|
+
concurrency: tasks.length,
|
|
292
|
+
claim,
|
|
279
293
|
timeoutMs: this.opts.timeoutMs,
|
|
280
294
|
tag: this.opts.tag,
|
|
281
295
|
preamble: this.opts.preamble,
|
|
@@ -285,9 +299,6 @@ export class Fanout {
|
|
|
285
299
|
supervisorPid: this.opts.supervisorPid,
|
|
286
300
|
});
|
|
287
301
|
try {
|
|
288
|
-
// pool.map runs the function over `items` with concurrency-bounded
|
|
289
|
-
// slot reuse. Each slot is one warm loader isolate; we get exactly
|
|
290
|
-
// `concurrency` loader isolates total — well under the 4-cap.
|
|
291
302
|
const items = tasks.map((t) => t.args);
|
|
292
303
|
const results = await pool.map<A, R>(fn, items);
|
|
293
304
|
// pool.map returns Array<R | null> (null on per-item failure with
|
|
@@ -316,10 +327,9 @@ export class Fanout {
|
|
|
316
327
|
// identical fns.
|
|
317
328
|
const fnSource = serializeFunction(fn);
|
|
318
329
|
|
|
319
|
-
// Cap peer count at MAX_PEER_FANOUT. Tasks beyond
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
// (concurrency capped at 4 there).
|
|
330
|
+
// Cap peer count at maxPeers (default MAX_PEER_FANOUT). Tasks beyond
|
|
331
|
+
// it bucket into existing shards; each peer runs its bucket through
|
|
332
|
+
// its own IsolatePool, as wide as its own headroom allows.
|
|
323
333
|
const peerCount = Math.min(tasks.length, this.opts.maxPeers ?? MAX_PEER_FANOUT);
|
|
324
334
|
// Group tasks by deterministic shard. Same key → same shard, so
|
|
325
335
|
// tests can predict which peer handles which task.
|
|
@@ -360,10 +370,9 @@ export class Fanout {
|
|
|
360
370
|
const peerStub = ns.get(id);
|
|
361
371
|
try {
|
|
362
372
|
const dispatch = hostOpDispatch(peerStub, `Fanout peer ${siblingName}`);
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// makes peer-DO fanout work.
|
|
373
|
+
// A peer DO call is a Durable Object RPC, not a Dynamic Worker:
|
|
374
|
+
// it spends none of this coordinator's budget, and the peer
|
|
375
|
+
// spends its own.
|
|
367
376
|
const rpcResp = await dispatch({
|
|
368
377
|
op: 'fanoutExecute',
|
|
369
378
|
args: [
|
package/src/fenced-work.ts
CHANGED
|
@@ -262,9 +262,9 @@ export class FencedWork<R extends FencedWorkRecord> {
|
|
|
262
262
|
* Runs once per instance — re-calls in the same instance are no-ops — and
|
|
263
263
|
* re-drives every row whose pid is `> 0` and at or below `generationBase()`
|
|
264
264
|
* with `attempt < FENCED_WORK_MAX_ATTEMPT`; the rest are abandoned. What the
|
|
265
|
-
* re-drive resolver receives is the journalled
|
|
266
|
-
* env and
|
|
267
|
-
*
|
|
265
|
+
* re-drive resolver receives is the journalled record and nothing else: a
|
|
266
|
+
* host keeps env and secrets out of it, so the resolver's embedder
|
|
267
|
+
* re-resolves them rather than reading them back.
|
|
268
268
|
*/
|
|
269
269
|
async recoverInterrupted(): Promise<void> {
|
|
270
270
|
if (this.recovered) return;
|
package/src/host-wasm.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* host-wasm.ts — what the fabric knows about a WebAssembly.Module a host
|
|
3
|
+
* hands a dynamic worker already compiled.
|
|
4
|
+
*
|
|
5
|
+
* workerd accepts a compiled module in a Worker Loader module map and lets
|
|
6
|
+
* the dynamic worker share its compiled code (src/workerd/api/
|
|
7
|
+
* worker-loader.c++, extractWasmModuleContent), so a host that bundles a
|
|
8
|
+
* fixed wasm (esbuild's, say) hands that module over instead of fetching a
|
|
9
|
+
* second copy of the bytes and having the guest compile them again. Two
|
|
10
|
+
* things the fabric needs about a member are unreadable from JS for a
|
|
11
|
+
* Module:
|
|
12
|
+
* - its size: the module's wire bytes still count toward the 64 MiB
|
|
13
|
+
* dynamic-worker code limit (worker-loader.c++ sums every member), and
|
|
14
|
+
* assertModuleMapWithinCodeLimit must count them;
|
|
15
|
+
* - its identity: a pool folds its wasm into the loader cache key, and a
|
|
16
|
+
* Module has no bytes to fingerprint.
|
|
17
|
+
* The host that owns the module states both once, here.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface HostWasmIdentity {
|
|
21
|
+
/** A stable name for the module's content, e.g. `esbuild@0.24.2`. */
|
|
22
|
+
readonly id: string;
|
|
23
|
+
/** The module's wire bytes: what it costs the dynamic-worker code budget. */
|
|
24
|
+
readonly bytes: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const described = new WeakMap<WebAssembly.Module, HostWasmIdentity>();
|
|
28
|
+
|
|
29
|
+
/** Record what `module` is, and return it. */
|
|
30
|
+
export function describeHostWasm(module: WebAssembly.Module, identity: HostWasmIdentity): WebAssembly.Module {
|
|
31
|
+
if (!Number.isSafeInteger(identity.bytes) || identity.bytes <= 0) {
|
|
32
|
+
throw new RangeError(`describeHostWasm: '${identity.id}' needs its wire size in bytes, got ${identity.bytes}`);
|
|
33
|
+
}
|
|
34
|
+
described.set(module, identity);
|
|
35
|
+
return module;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What a host said `module` is, or undefined for a module nobody described. */
|
|
39
|
+
export function hostWasmIdentity(module: WebAssembly.Module): HostWasmIdentity | undefined {
|
|
40
|
+
return described.get(module);
|
|
41
|
+
}
|
package/src/image-store.ts
CHANGED
|
@@ -122,7 +122,7 @@ export class ImageStore {
|
|
|
122
122
|
*/
|
|
123
123
|
async materialize(
|
|
124
124
|
pid: number,
|
|
125
|
-
images: AsyncIterable<readonly [string, string]> | Iterable<readonly [string, string]>,
|
|
125
|
+
images: AsyncIterable<readonly [string, string | readonly string[]]> | Iterable<readonly [string, string | readonly string[]]>,
|
|
126
126
|
pacer: TurnBudget,
|
|
127
127
|
): Promise<Record<string, string>> {
|
|
128
128
|
const fs = this.blobs();
|
|
@@ -138,7 +138,7 @@ export class ImageStore {
|
|
|
138
138
|
fs.mkdirp(FACET_IMAGE_DIR);
|
|
139
139
|
let count = 0;
|
|
140
140
|
for await (const [moduleName, source] of images) {
|
|
141
|
-
const bytes = new TextEncoder().encode(source);
|
|
141
|
+
const bytes = typeof source === 'string' ? new TextEncoder().encode(source) : encodeParts(source);
|
|
142
142
|
const path = facetImagePath(await facetImageDigest(bytes));
|
|
143
143
|
paths[moduleName] = path;
|
|
144
144
|
rooted.push(path);
|
|
@@ -207,3 +207,30 @@ export class ImageStore {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* UTF-8 bytes of an image given as ordered parts, encoded into one buffer a
|
|
213
|
+
* part at a time: joining the parts first would hold the whole image twice as
|
|
214
|
+
* text.
|
|
215
|
+
*/
|
|
216
|
+
function encodeParts(parts: readonly string[]): Uint8Array {
|
|
217
|
+
let length = 0;
|
|
218
|
+
for (const part of parts) {
|
|
219
|
+
for (let i = 0; i < part.length; i++) {
|
|
220
|
+
const code = part.charCodeAt(i);
|
|
221
|
+
if (code < 0x80) length += 1;
|
|
222
|
+
else if (code < 0x800) length += 2;
|
|
223
|
+
else if (code >= 0xd800 && code <= 0xdbff && (part.charCodeAt(i + 1) & 0xfc00) === 0xdc00) { length += 4; i++; }
|
|
224
|
+
else length += 3;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const bytes = new Uint8Array(length);
|
|
228
|
+
const encoder = new TextEncoder();
|
|
229
|
+
let offset = 0;
|
|
230
|
+
for (const part of parts) {
|
|
231
|
+
const encoded = encoder.encode(part);
|
|
232
|
+
bytes.set(encoded, offset);
|
|
233
|
+
offset += encoded.byteLength;
|
|
234
|
+
}
|
|
235
|
+
return bytes;
|
|
236
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inner-do-env.ts — a classic Durable Object binding inside an inner Worker.
|
|
3
|
+
*
|
|
4
|
+
* `nimbus wrangler dev` loads the user's Worker as a dynamic worker, and its
|
|
5
|
+
* Durable Object bindings run as facets of the session Durable Object. The
|
|
6
|
+
* binding the loader passes (`NimbusDurableObjectNamespace`, an entrypoint of
|
|
7
|
+
* the session's isolate) answers RPC, so it cannot be the inner Worker's
|
|
8
|
+
* namespace: a DurableObjectNamespace's API is synchronous (`env.P.get(
|
|
9
|
+
* env.P.idFromName('x'))` takes no await), and an RpcPromise cannot travel as
|
|
10
|
+
* an argument ("Could not serialize object of type RpcPromise").
|
|
11
|
+
*
|
|
12
|
+
* So a module the inner Worker runs before any of its own code (its main
|
|
13
|
+
* module's first import, `innerWorkerModules`) replaces each such binding in
|
|
14
|
+
* the isolate's env, which every handler, entrypoint and Durable Object of the
|
|
15
|
+
* isolate sees, with a local namespace. Ids are made locally, and `get`
|
|
16
|
+
* answers at once an RPC stub (`new RpcStub(target)`) of a local target that
|
|
17
|
+
* relays each member the stub's caller reaches (a call, a read, or a path
|
|
18
|
+
* through members, fetch included) to the binding's `callOn` or `getOn`,
|
|
19
|
+
* which the session runs on the object's facet. An RPC stub is what a
|
|
20
|
+
* Durable Object stub is to the runtime: callable by any method name, read by
|
|
21
|
+
* any property name, pipelined, bound to its request, and transferable, as an
|
|
22
|
+
* argument or an answer, where an entrypoint of a dynamically-loaded Worker
|
|
23
|
+
* is not. Arguments and answers cross natively, stubs, functions and streams
|
|
24
|
+
* included.
|
|
25
|
+
*
|
|
26
|
+
* Its prototype is not RpcStub's but one shaped as a Durable Object stub's:
|
|
27
|
+
* its constructor is a class `DurableObject` that cannot be constructed, its
|
|
28
|
+
* tag is 'DurableObject', and it has no `dup` or Symbol.dispose of its own,
|
|
29
|
+
* so `dup` is a member, which the runtime refuses as on Cloudflare. It
|
|
30
|
+
* differs from a Durable Object stub in two ways the runtime fixes. `typeof`
|
|
31
|
+
* is 'function'. And it is not persistent, so a Worker Loader env cannot carry
|
|
32
|
+
* it ("RpcStub cannot be serialized in this context because it is not a
|
|
33
|
+
* persistent stub"): the loader shim (NimbusLoaderRPC) keeps a child's code
|
|
34
|
+
* and loads it again in each later request, so the child's env can carry
|
|
35
|
+
* nothing made in one request, the session's own stubs included.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { RpcStub, WorkerEntrypoint } from 'cloudflare:workers';
|
|
39
|
+
import { ESBUILD_NAME_MODULE_SHIM } from '@nimbus-sh/core/_shared/esbuild-facet-shim.js';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The id string a name gives: deterministic (FNV-style, 64-bit hex), with the
|
|
43
|
+
* prefix `name:` so it never collides with a `uniq:` id. Self-contained: its
|
|
44
|
+
* source also runs in the inner isolate (innerDoAdapter).
|
|
45
|
+
*/
|
|
46
|
+
export function innerDoIdFromName(name: string): string {
|
|
47
|
+
let h1 = 0xdeadbeef ^ name.length;
|
|
48
|
+
let h2 = 0x41c6ce57 ^ name.length;
|
|
49
|
+
for (let i = 0; i < name.length; i++) {
|
|
50
|
+
const ch = name.charCodeAt(i);
|
|
51
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
52
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
53
|
+
}
|
|
54
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
55
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
56
|
+
return 'name:' + (h1 >>> 0).toString(16).padStart(8, '0') + (h2 >>> 0).toString(16).padStart(8, '0');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What the binding the loader passes answers: one access to one object. */
|
|
60
|
+
export interface InnerDoRemote {
|
|
61
|
+
/** The member of object `id` at `path` (names from the object down), called with `args`. */
|
|
62
|
+
callOn(id: string, path: string[], args: unknown[]): Promise<unknown>;
|
|
63
|
+
/** The member of object `id` at `path`, read. */
|
|
64
|
+
getOn(id: string, path: string[]): Promise<unknown>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What the adapter runs over: the inner isolate's `cloudflare:workers`. */
|
|
68
|
+
export interface InnerDoRuntime {
|
|
69
|
+
env: object;
|
|
70
|
+
WorkerEntrypoint: typeof WorkerEntrypoint;
|
|
71
|
+
RpcStub: typeof RpcStub;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The entrypoint a build asks which Durable Object classes are missing, unless the bundle spells it. */
|
|
75
|
+
const CLASSES_ENTRYPOINT = 'NimbusDurableObjectClasses';
|
|
76
|
+
/**
|
|
77
|
+
* The adapter, as it runs in the inner isolate: it replaces each of `names`
|
|
78
|
+
* in `runtime.env` that holds the binding with a local DurableObjectNamespace,
|
|
79
|
+
* and answers the class check the main module exports. `main` is the main
|
|
80
|
+
* module's namespace. Self-contained (serialized with toString): it reaches
|
|
81
|
+
* nothing outside itself but its arguments.
|
|
82
|
+
*/
|
|
83
|
+
export function innerDoAdapter(
|
|
84
|
+
idFromName: (name: string) => string,
|
|
85
|
+
names: readonly string[],
|
|
86
|
+
main: object,
|
|
87
|
+
runtime: InnerDoRuntime,
|
|
88
|
+
): { NimbusDurableObjectClasses: unknown } {
|
|
89
|
+
/** A Durable Object id: its string, and the name it was made from. */
|
|
90
|
+
class DurableObjectId {
|
|
91
|
+
readonly name?: string;
|
|
92
|
+
readonly #id: string;
|
|
93
|
+
constructor(id: string, name?: string) {
|
|
94
|
+
this.#id = id;
|
|
95
|
+
if (name !== undefined) this.name = name;
|
|
96
|
+
}
|
|
97
|
+
toString(): string { return this.#id; }
|
|
98
|
+
equals(other: unknown): boolean { return other instanceof DurableObjectId && String(other) === this.#id; }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Whether `value` is the binding the loader passes (an RPC stub's methods are its properties). */
|
|
102
|
+
function isRemote(value: unknown): value is InnerDoRemote {
|
|
103
|
+
return value !== null && (typeof value === 'object' || typeof value === 'function')
|
|
104
|
+
&& typeof Reflect.get(value, 'callOn') === 'function' && typeof Reflect.get(value, 'getOn') === 'function';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The member of object `id` at `path`, as the runtime reaches it in an RPC
|
|
109
|
+
* to the stub: called, read (it is thenable, and the runtime awaits what a
|
|
110
|
+
* read answers), or walked through to one of its own members (the runtime
|
|
111
|
+
* walks a path through own properties only, so every name is reported as
|
|
112
|
+
* one). At the empty path it is the stub's target.
|
|
113
|
+
*/
|
|
114
|
+
function member(remote: InnerDoRemote, id: string, path: readonly string[]): (...args: unknown[]) => unknown {
|
|
115
|
+
const next = (name: string) => member(remote, id, [...path, name]);
|
|
116
|
+
return new Proxy((..._args: unknown[]): unknown => undefined, {
|
|
117
|
+
apply: (_target, _self, args: unknown[]) => remote.callOn(id, [...path], args),
|
|
118
|
+
get: (target, name) => {
|
|
119
|
+
if (name === 'then') {
|
|
120
|
+
return (resolve: (value: unknown) => unknown, reject: (reason: unknown) => unknown) => remote.getOn(id, [...path]).then(resolve, reject);
|
|
121
|
+
}
|
|
122
|
+
return typeof name === 'string' ? next(name) : Reflect.get(target, name);
|
|
123
|
+
},
|
|
124
|
+
getOwnPropertyDescriptor: (target, name) => (typeof name === 'string' && name !== 'then'
|
|
125
|
+
? { value: next(name), writable: true, enumerable: true, configurable: true }
|
|
126
|
+
: Reflect.getOwnPropertyDescriptor(target, name)),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A stub's prototype, as a Durable Object stub's shows: its constructor
|
|
132
|
+
* cannot be called or constructed, and it is tagged 'DurableObject'.
|
|
133
|
+
*/
|
|
134
|
+
const stubPrototype: object = Object.create(Object.prototype, {
|
|
135
|
+
constructor: {
|
|
136
|
+
value: function DurableObject(): never { throw new TypeError('Illegal constructor'); },
|
|
137
|
+
writable: true,
|
|
138
|
+
configurable: true,
|
|
139
|
+
},
|
|
140
|
+
[Symbol.toStringTag]: { value: 'DurableObject', configurable: true },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Asked once by a build: which classes the main module does not export. */
|
|
144
|
+
class NimbusDurableObjectClasses extends runtime.WorkerEntrypoint {
|
|
145
|
+
missing(classNames: string[]): string[] {
|
|
146
|
+
return classNames.filter((name) => typeof Reflect.get(main, name) !== 'function');
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** env.MY_DO: the namespace, made locally; its stubs relay to `remote`. */
|
|
151
|
+
class DurableObjectNamespace {
|
|
152
|
+
readonly #remote: InnerDoRemote;
|
|
153
|
+
constructor(remote: InnerDoRemote) { this.#remote = remote; }
|
|
154
|
+
idFromName(name: string): DurableObjectId { return new DurableObjectId(idFromName(String(name)), String(name)); }
|
|
155
|
+
newUniqueId(): DurableObjectId { return new DurableObjectId('uniq:' + crypto.randomUUID().replaceAll('-', '')); }
|
|
156
|
+
idFromString(id: string): DurableObjectId { return new DurableObjectId(String(id)); }
|
|
157
|
+
get(id: DurableObjectId | string): object {
|
|
158
|
+
const at = id instanceof DurableObjectId ? id : new DurableObjectId(String(id));
|
|
159
|
+
const stub = Object.setPrototypeOf(new runtime.RpcStub(member(this.#remote, String(at), [])), stubPrototype);
|
|
160
|
+
// As a Durable Object stub has them: its own, enumerable, in this order.
|
|
161
|
+
return Object.defineProperties(stub, {
|
|
162
|
+
name: { value: at.name, enumerable: true },
|
|
163
|
+
id: { value: at, enumerable: true },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
getByName(name: string): object { return this.get(this.idFromName(name)); }
|
|
167
|
+
jurisdiction(): DurableObjectNamespace { return this; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const name of names) {
|
|
171
|
+
const remote: unknown = Reflect.get(runtime.env, name);
|
|
172
|
+
if (isRemote(remote)) Reflect.set(runtime.env, name, new DurableObjectNamespace(remote));
|
|
173
|
+
}
|
|
174
|
+
return { NimbusDurableObjectClasses };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The inner Worker's own module, as bundled, and the adapter's. */
|
|
178
|
+
const MAIN_MODULE = 'worker.js';
|
|
179
|
+
const ADAPTER_MODULE = 'nimbus-do-env.js';
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The modules an inner Worker runs with Durable Object bindings `names`: its
|
|
183
|
+
* bundle as the main module, whose first import is the adapter (so the
|
|
184
|
+
* adapter has run before any of the Worker's code) and which exports the
|
|
185
|
+
* class check as `classesEntrypoint`, and the adapter. The import shares the
|
|
186
|
+
* bundle's first line, so line numbers stay the bundle's. A Worker with no
|
|
187
|
+
* such binding runs its bundle as it is, and has no class check (null).
|
|
188
|
+
*
|
|
189
|
+
* `classesEntrypoint` is a name the bundle never spells, so it exports no
|
|
190
|
+
* such name itself (a bundler prints an ASCII name as it is).
|
|
191
|
+
*/
|
|
192
|
+
export function innerWorkerModules(bundle: string, names: readonly string[]): {
|
|
193
|
+
mainModule: string;
|
|
194
|
+
modules: Record<string, string>;
|
|
195
|
+
classesEntrypoint: string | null;
|
|
196
|
+
} {
|
|
197
|
+
if (names.length === 0) return { mainModule: MAIN_MODULE, modules: { [MAIN_MODULE]: bundle }, classesEntrypoint: null };
|
|
198
|
+
let classesEntrypoint = CLASSES_ENTRYPOINT;
|
|
199
|
+
for (let n = 2; bundle.includes(classesEntrypoint); n++) classesEntrypoint = `${CLASSES_ENTRYPOINT}_${n}`;
|
|
200
|
+
const head = `export { ${CLASSES_ENTRYPOINT} as ${classesEntrypoint} } from './${ADAPTER_MODULE}';`;
|
|
201
|
+
// A hashbang must stay first.
|
|
202
|
+
const at = bundle.startsWith('#!') ? bundle.indexOf('\n') + 1 : 0;
|
|
203
|
+
const main = bundle.slice(0, at) + head + bundle.slice(at);
|
|
204
|
+
const adapter = [
|
|
205
|
+
"import { env, RpcStub, WorkerEntrypoint } from 'cloudflare:workers';",
|
|
206
|
+
`import * as main from './${MAIN_MODULE}';`,
|
|
207
|
+
// The functions below are serialized from the bundled worker, which wraps them in __name.
|
|
208
|
+
ESBUILD_NAME_MODULE_SHIM,
|
|
209
|
+
`const { ${CLASSES_ENTRYPOINT} } = (${innerDoAdapter.toString()})(${innerDoIdFromName.toString()}, ${JSON.stringify(names)}, main, { env, RpcStub, WorkerEntrypoint });`,
|
|
210
|
+
`export { ${CLASSES_ENTRYPOINT} };`,
|
|
211
|
+
].join('\n');
|
|
212
|
+
return { mainModule: MAIN_MODULE, modules: { [MAIN_MODULE]: main, [ADAPTER_MODULE]: adapter }, classesEntrypoint };
|
|
213
|
+
}
|