@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,193 @@
1
+ /**
2
+ * launch-pacer.ts — spreading a resident launch across Durable Object turns.
3
+ *
4
+ * Building a resident process is the largest single span of computation this
5
+ * session performs: for pi it walks a 17 MB source tree through eight
6
+ * enrichment passes, serializes a 22.9 MB module map, and writes that map into
7
+ * the image store. Done in one turn it occupied the session DO's only thread
8
+ * for 15-35 s, and a session that cannot reach its thread cannot service the
9
+ * terminal WebSocket — the launch turn finished `outcome=ok` and the terminal
10
+ * died anyway, painting "[process terminal closed]" over a process that was
11
+ * still running.
12
+ *
13
+ * A faster launch does not fix that. A launch half the length still blocks the
14
+ * thread for as long as it runs, and the socket is dropped inside that window
15
+ * whether or not the work succeeds. What fixes it is never holding the thread
16
+ * for long in the first place, which means suspending the launch at bounded
17
+ * intervals and resuming it on a fresh turn. Responsiveness stops depending on
18
+ * how long the total work takes.
19
+ *
20
+ * A fresh turn is also a fresh CPU budget. The same launches that dropped the
21
+ * socket were also being killed with `exceededCpu` at 31.8 s and 32.5 s
22
+ * against a 30 s ceiling, and no amount of yielding *within* one invocation
23
+ * moves that: CPU accrues to the invocation, not to the pause. Only genuinely
24
+ * re-entering the object resets it.
25
+ *
26
+ * Progress is measured in bytes rather than milliseconds because workerd's
27
+ * clock does not advance without I/O — a wall-clock guard inside a span of
28
+ * pure computation reads zero however many seconds it burns, which is why the
29
+ * phase costs behind this module had to be recovered from per-turn `cpuTime`
30
+ * rather than measured in place. Bytes are what the work is actually
31
+ * proportional to, and they are exact. The same reasoning is why
32
+ * `git/network-facet.ts` bounds its checkout chunks by entries and decoded
33
+ * bytes and treats its wall guard as coarse.
34
+ */
35
+ /**
36
+ * Bytes of launch work one turn may perform before it must yield.
37
+ *
38
+ * Sized so a chunk stays far below both the CPU ceiling and the span in which
39
+ * a terminal socket is at risk, while keeping the number of turn handoffs —
40
+ * each an alarm round trip — small enough not to dominate a launch. pi's
41
+ * 22.9 MB map crosses this about a dozen times per phase that handles it.
42
+ */
43
+ export const LAUNCH_CHUNK_MAX_BYTES = 2_000_000;
44
+ /**
45
+ * Accounts launch progress and ends the turn when a chunk's worth has been
46
+ * spent.
47
+ *
48
+ * Callers report the work they are about to do or have just done and await
49
+ * the result; a pacer that is not yielding returns without suspending, so the
50
+ * one-shot exec path — which passes no pacer at all — keeps its exact
51
+ * behaviour and cost. Nothing here decides WHAT the launch does, only where it
52
+ * is allowed to stop.
53
+ */
54
+ export class LaunchPacer {
55
+ scheduler;
56
+ maxChunkBytes;
57
+ stillWanted;
58
+ /** Turn handoffs this launch has taken. Reported with the launch. */
59
+ chunks = 0;
60
+ /** Total work accounted, for the same report. */
61
+ bytes = 0;
62
+ spent = 0;
63
+ chunkEnded;
64
+ /**
65
+ * @param stillWanted Checked every time the launch resumes. A launch spans
66
+ * many turns, so anything may have happened to what it is building for
67
+ * while it was suspended; throwing from here is how a launch stops instead
68
+ * of spending turn after turn on work nothing will use. Checked at the one
69
+ * place a launch can be interrupted, rather than at whichever phases
70
+ * remembered to ask.
71
+ */
72
+ constructor(scheduler, maxChunkBytes = LAUNCH_CHUNK_MAX_BYTES, stillWanted) {
73
+ this.scheduler = scheduler;
74
+ this.maxChunkBytes = maxChunkBytes;
75
+ this.stillWanted = stillWanted;
76
+ }
77
+ /**
78
+ * Account `bytes` of completed work, ending the turn if a chunk is full.
79
+ *
80
+ * Safe to call anywhere the launch holds no state that a concurrent turn
81
+ * could invalidate — which is why the image store registers its whole root
82
+ * set before the first call rather than one entry at a time.
83
+ */
84
+ async spend(bytes) {
85
+ this.bytes += bytes;
86
+ this.spent += bytes;
87
+ if (this.spent < this.maxChunkBytes)
88
+ return;
89
+ this.spent = 0;
90
+ this.chunks++;
91
+ // Release the turn that resumed us before asking for the next one.
92
+ this.chunkEnded?.resolve();
93
+ const ended = withResolvers();
94
+ this.chunkEnded = ended;
95
+ await this.scheduler.nextTurn(ended.promise);
96
+ this.stillWanted?.();
97
+ }
98
+ /**
99
+ * The launch has finished (or failed). Releases the turn still waiting on
100
+ * the chunk it resumed, so a launch that ends mid-chunk does not strand the
101
+ * handler that granted it.
102
+ */
103
+ settle() {
104
+ this.chunkEnded?.resolve();
105
+ this.chunkEnded = undefined;
106
+ }
107
+ }
108
+ function withResolvers() {
109
+ let resolve;
110
+ const promise = new Promise((r) => { resolve = r; });
111
+ return { promise, resolve };
112
+ }
113
+ /**
114
+ * The granting side of {@link LaunchTurnScheduler}: parks suspended launches
115
+ * and resumes every one of them when the host grants a fresh turn.
116
+ */
117
+ export class LaunchTurnPump {
118
+ host;
119
+ /**
120
+ * Launches suspended between chunks, waiting for a turn of their own.
121
+ *
122
+ * In-memory on purpose: a launch is only meaningful while the process table
123
+ * entry it is building for exists, and both are lost together if the isolate
124
+ * resets. What survives a reset is the journal, which names the launch's
125
+ * INPUTS rather than its position — a resumed queue would be resurrecting
126
+ * half-built work for pids that no longer exist, where re-driving a launch
127
+ * from its inputs is the same idempotent work again.
128
+ */
129
+ waiters = [];
130
+ constructor(host) {
131
+ this.host = host;
132
+ }
133
+ /**
134
+ * How a paced launch asks for a fresh turn.
135
+ *
136
+ * The host grants one by calling {@link pump} from a context that is
137
+ * genuinely a new invocation — the session's alarm. Without such a host
138
+ * there is no fresh turn to be had, and the launch continues on this one
139
+ * rather than hanging: that is exactly the single-turn launch this path has
140
+ * always performed, so a harness or a runtime without alarms loses the
141
+ * responsiveness but keeps the behaviour.
142
+ */
143
+ nextTurn(chunkEnded) {
144
+ return new Promise((resume) => {
145
+ this.waiters.push({ resume, chunkEnded });
146
+ if (this.host.requestTurn) {
147
+ this.host.requestTurn();
148
+ return;
149
+ }
150
+ setTimeout(() => { void this.pump(); }, 0);
151
+ });
152
+ }
153
+ /**
154
+ * Run one chunk of every launch waiting for a turn.
155
+ *
156
+ * Awaits the chunk each resumed launch then performs, so the invocation that
157
+ * granted the turn is the invocation that pays for the work — rather than
158
+ * releasing it into a handler's microtask drain, where nothing owns it and
159
+ * the runtime may tear the context down mid-chunk.
160
+ */
161
+ async pump() {
162
+ await this.host.recover?.();
163
+ const waiting = this.waiters;
164
+ if (waiting.length === 0)
165
+ return;
166
+ this.waiters = [];
167
+ for (const waiter of waiting)
168
+ waiter.resume();
169
+ await Promise.all(waiting.map((waiter) => waiter.chunkEnded));
170
+ }
171
+ /** Whether any launch is suspended waiting for a turn. */
172
+ get hasPending() {
173
+ return this.waiters.length > 0;
174
+ }
175
+ }
176
+ /**
177
+ * Chunk bound for this session, honouring the verification knob.
178
+ *
179
+ * `NIMBUS_LAUNCH_CHUNK_BYTES` forces a small bound so an ordinary launch —
180
+ * not just a pathological one — crosses several turns and exercises every
181
+ * suspension point. Without it the multi-turn path would only ever be
182
+ * reached by the largest programs, which is the same reason
183
+ * `git/commands.ts` carries `NIMBUS_GIT_CHECKOUT_CHUNK_ENTRIES`. Unset in
184
+ * production, where the default applies.
185
+ */
186
+ export function launchChunkMaxBytes(env) {
187
+ const raw = env
188
+ ?.NIMBUS_LAUNCH_CHUNK_BYTES;
189
+ if (!raw)
190
+ return LAUNCH_CHUNK_MAX_BYTES;
191
+ const parsed = Number(raw);
192
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : LAUNCH_CHUNK_MAX_BYTES;
193
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * loader-ledger.ts — per-DO accounting for the Worker Loader's two caps.
3
+ *
4
+ * Measured on production workerd: a Durable Object admits ~5–6 concurrent
5
+ * dynamic workers before the platform refuses with "Too many concurrent
6
+ * dynamic workers", one DO method can drive at most 4 concurrent Loader
7
+ * fetches, and loader-cache entries are never released — every DISTINCT
8
+ * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
9
+ * the object's lifetime. Nimbus stays under the caps by construction
10
+ * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
11
+ * were counted in prose. This ledger counts them at the fabric's loader call
12
+ * sites instead — the loader pool's slots, a resident process's keyed worker,
13
+ * a one-shot's load — so proximity is measurable and a cap failure can name
14
+ * the ids actually holding slots.
15
+ *
16
+ * Measurement only: no admission control. The caps are the platform's, they
17
+ * are approximate ("~5–6"), and a gate on an approximate number would refuse
18
+ * work the platform would have run.
19
+ *
20
+ * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
21
+ * caps are per Durable Object, and dynamic workers die with the isolate that
22
+ * loaded them, so a ledger that goes away with its host describes nothing
23
+ * that still exists.
24
+ */
25
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
26
+ export declare function recordLoaderId(ctx: object, id: string): void;
27
+ /**
28
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
29
+ * function ends it (idempotently), from the caller's own `finally`.
30
+ *
31
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
32
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
33
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
34
+ * Durable Object poisoned after every pooled dispatch — the next fabric
35
+ * activity hung the object or reset the instance outright (pid base jumped,
36
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
37
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
38
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
39
+ * workers: an RPC stub call must stay a direct property call awaited by the
40
+ * frame that made it, so the ledger only brackets it.
41
+ */
42
+ export declare function beginLoaderFetch(ctx: object): () => void;
43
+ /** Snapshot for the diag surface. Pure read; no I/O. */
44
+ export declare function loaderLedgerStats(ctx: object): {
45
+ idsEverGotten: string[];
46
+ liveFetches: number;
47
+ peakLiveFetches: number;
48
+ };
49
+ /**
50
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
51
+ * failure; hand every other error back untouched. The platform's message
52
+ * says only that the cap was hit — which ids hold the slots, and that a
53
+ * keyed id can never give one back, is what the operator needs to know to
54
+ * shrink anything.
55
+ */
56
+ export declare function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error;
57
+ //# sourceMappingURL=loader-ledger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader-ledger.d.ts","sourceRoot":"","sources":["../src/loader-ledger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAwBH,2EAA2E;AAC3E,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,IAAI,CAE5D;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,IAAI,CAUxD;AAED,wDAAwD;AACxD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC9C,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,MAAM,CAAC;CACzB,CAOA;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,CAW7E"}
@@ -0,0 +1,91 @@
1
+ /**
2
+ * loader-ledger.ts — per-DO accounting for the Worker Loader's two caps.
3
+ *
4
+ * Measured on production workerd: a Durable Object admits ~5–6 concurrent
5
+ * dynamic workers before the platform refuses with "Too many concurrent
6
+ * dynamic workers", one DO method can drive at most 4 concurrent Loader
7
+ * fetches, and loader-cache entries are never released — every DISTINCT
8
+ * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
9
+ * the object's lifetime. Nimbus stays under the caps by construction
10
+ * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
11
+ * were counted in prose. This ledger counts them at the fabric's loader call
12
+ * sites instead — the loader pool's slots, a resident process's keyed worker,
13
+ * a one-shot's load — so proximity is measurable and a cap failure can name
14
+ * the ids actually holding slots.
15
+ *
16
+ * Measurement only: no admission control. The caps are the platform's, they
17
+ * are approximate ("~5–6"), and a gate on an approximate number would refuse
18
+ * work the platform would have run.
19
+ *
20
+ * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
21
+ * caps are per Durable Object, and dynamic workers die with the isolate that
22
+ * loaded them, so a ledger that goes away with its host describes nothing
23
+ * that still exists.
24
+ */
25
+ import { classifyError } from '@nimbus-sh/core/observability/oom-classify.js';
26
+ const ledgers = new WeakMap();
27
+ function ledger(ctx) {
28
+ let entry = ledgers.get(ctx);
29
+ if (!entry) {
30
+ entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
31
+ ledgers.set(ctx, entry);
32
+ }
33
+ return entry;
34
+ }
35
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
36
+ export function recordLoaderId(ctx, id) {
37
+ ledger(ctx).ids.add(id);
38
+ }
39
+ /**
40
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
41
+ * function ends it (idempotently), from the caller's own `finally`.
42
+ *
43
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
44
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
45
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
46
+ * Durable Object poisoned after every pooled dispatch — the next fabric
47
+ * activity hung the object or reset the instance outright (pid base jumped,
48
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
49
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
50
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
51
+ * workers: an RPC stub call must stay a direct property call awaited by the
52
+ * frame that made it, so the ledger only brackets it.
53
+ */
54
+ export function beginLoaderFetch(ctx) {
55
+ const entry = ledger(ctx);
56
+ entry.liveFetches++;
57
+ entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
58
+ let ended = false;
59
+ return () => {
60
+ if (ended)
61
+ return;
62
+ ended = true;
63
+ entry.liveFetches--;
64
+ };
65
+ }
66
+ /** Snapshot for the diag surface. Pure read; no I/O. */
67
+ export function loaderLedgerStats(ctx) {
68
+ const entry = ledger(ctx);
69
+ return {
70
+ idsEverGotten: [...entry.ids],
71
+ liveFetches: entry.liveFetches,
72
+ peakLiveFetches: entry.peakLiveFetches,
73
+ };
74
+ }
75
+ /**
76
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
77
+ * failure; hand every other error back untouched. The platform's message
78
+ * says only that the cap was hit — which ids hold the slots, and that a
79
+ * keyed id can never give one back, is what the operator needs to know to
80
+ * shrink anything.
81
+ */
82
+ export function withDynamicWorkerCapNamed(ctx, error) {
83
+ if (classifyError(error) !== 'dynamic_worker_cap')
84
+ return error;
85
+ const entry = ledger(ctx);
86
+ const platform = error instanceof Error ? error.message : String(error);
87
+ return new Error(`${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
88
+ + `holding dynamic-worker slots (a loader.get id is never released): `
89
+ + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
90
+ + `peak ${entry.peakLiveFetches}`, { cause: error });
91
+ }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * loader-pool.ts — Nimbus loader-isolate pool based on cloudflare-parallel.
3
+ *
4
+ * Adds Nimbus-specific behavior to the upstream pool design:
5
+ * 1. **Stable-slot isolate reuse**. Upstream's #counter++ gives every
6
+ * dispatch a fresh isolate — fine for one-off AI calls, terrible for
7
+ * running 67 npm tarball extractions (cold-start dominates). We pin
8
+ * each job to `slot = cursor % concurrency` and use stable loader
9
+ * IDs `nfp:${fnHash}:slot-${i}:g${generation}`, so a pool of
10
+ * concurrency=4 keeps at most 4 warm isolates rather than N fresh ones.
11
+ * 2. **Nimbus defaults**: compatibilityDate = CF_COMPAT_DATE (matches
12
+ * the supervisor worker), compatibilityFlags = ['nodejs_compat'],
13
+ * globalOutbound = undefined (inherit parent network so the facet can
14
+ * reach https://registry.npmjs.org without a proxy binding).
15
+ * 3. **Supervisor autoinjection**. The pool grabs the embedder's
16
+ * registered supervisor entrypoint stub (see `supervisorEntrypoint` in
17
+ * ctx-exports.ts) and forwards it as `env.SUPERVISOR` to every facet,
18
+ * same pattern as git-network-facet.ts. Callers can add more bindings
19
+ * via `extraBindings`.
20
+ * 4. **Fail-loud defaults**: timeout 60s, retries 0, onError 'throw'.
21
+ * Caller opts in to leniency.
22
+ *
23
+ * The vendored directory contains only the upstream serialization, error,
24
+ * and binding types used by this implementation.
25
+ */
26
+ import type { FacetBindings } from '@nimbus-sh/core/runtime/facet-host.js';
27
+ import type { WorkerLoader } from './vendor/types.js';
28
+ /**
29
+ * A function dispatched into a facet isolate, with the bindings that facet was
30
+ * minted with as its second argument.
31
+ *
32
+ * Declared through a method so the bindings parameter compares BIVARIANTLY: a
33
+ * task body annotates the exact surface it calls (`env.SUPERVISOR` is the
34
+ * embedder's RPC class, which the fabric cannot name), and accepting that
35
+ * narrowing is the whole point of handing the bindings over.
36
+ */
37
+ export type FacetTaskFn<A, R> = {
38
+ task(args: A, env: FacetBindings): R | Promise<R>;
39
+ }['task'];
40
+ /** The one binding a pool needs off whichever env its host hands it. */
41
+ export interface LoaderPoolEnv {
42
+ LOADER?: WorkerLoader;
43
+ }
44
+ /** Options handed to LoaderPool's constructor. */
45
+ export interface LoaderPoolOptions {
46
+ /** Maximum concurrent in-flight facets. Default 4. */
47
+ concurrency?: number;
48
+ /** Per-task timeout in ms. Default 60_000. */
49
+ timeoutMs?: number;
50
+ /**
51
+ * Per-task retry attempts AFTER the initial failure. Default 0.
52
+ * Set to a small number only if transient RPC errors are common.
53
+ */
54
+ retries?: number;
55
+ /**
56
+ * Additional bindings forwarded to each facet. These merge on top of the
57
+ * default `{ SUPERVISOR: supervisorRpc({ doId, pid:0 }) }`. Use this to
58
+ * give facets access to KV, R2, AI, or additional supervisor-level APIs.
59
+ */
60
+ extraBindings?: Record<string, unknown>;
61
+ /**
62
+ * Optional tag used in loader IDs for debugging (e.g. "npm-install").
63
+ * Does NOT affect isolate identity — same fn + same tag = same slot.
64
+ */
65
+ tag?: string;
66
+ /**
67
+ * If true, omit the default SUPERVISOR binding. Use this for pools
68
+ * that don't need DO callbacks (e.g. a pure CPU compute pool).
69
+ */
70
+ omitSupervisor?: boolean;
71
+ /**
72
+ * Loader cache scope. Defaults to `session`, which bakes the owning DO id
73
+ * into the loader key so stateful facets cannot leak bindings or globals
74
+ * across sessions. Use `global` only for stateless compute modules that do
75
+ * not receive a Supervisor binding and do not retain user state.
76
+ */
77
+ cacheScope?: 'session' | 'global';
78
+ /**
79
+ * Override the `doId` baked into the auto-injected SUPERVISOR binding.
80
+ * Default: `ctx.id.toString()` (the DO that constructs the pool).
81
+ *
82
+ * Used by FanoutPool's peer-DO branch (peer-DO fanout): peer DOs
83
+ * construct their per-task LoaderPool from inside
84
+ * `_rpcFanoutExecute`, where `ctx` is the PEER DO's ctx. Without this
85
+ * override the peer's auto-injected SUPERVISOR routes back to the
86
+ * peer DO itself — so writes (e.g. install-batch-facet's
87
+ * writeBatchStream) land in the peer's VFS instead of the
88
+ * COORDINATOR's. The user's terminal session is on the coordinator;
89
+ * writes-to-peer are invisible. See INSTALL-HONESTY-retro.md.
90
+ *
91
+ * When set, the auto-injected supervisor binding uses this string as the
92
+ * `props.doId`, routing all SUPERVISOR.* calls back to the
93
+ * coordinator. Effective only when `omitSupervisor !== true`.
94
+ */
95
+ supervisorDoIdOverride?: string;
96
+ /**
97
+ * Process pid baked into the auto-injected SUPERVISOR binding's props.
98
+ * The supervisor derives the write credential from this pid
99
+ * (`SupervisorRPC._pid()` → `processes.cred(pid)`), so any facet that
100
+ * calls a filesystem RPC (`writeBatchStream`, `writeFile`, …) must be
101
+ * dispatched with the invoking process's real pid — otherwise the RPC
102
+ * throws "missing or invalid process pid in props". npm install threads
103
+ * the shell command's `ctx.pid` here so package files land as the user.
104
+ * Left 0 (default) for pools whose facets touch only cache/registry RPCs
105
+ * (npm resolve, pre-bundle), which never call `_pid()`.
106
+ */
107
+ supervisorPid?: number;
108
+ /**
109
+ * Raw JavaScript source prepended to every generated worker module.
110
+ * Lets callers inject bundled helpers, such as a tar parser. The user
111
+ * function can reference top-level names declared in the preamble as if
112
+ * they were in lexical scope.
113
+ *
114
+ * Example: `preamble: 'export const parse = ...; const helper = ...;'`
115
+ * — the preamble runs at module-load time; any side effects happen
116
+ * inside the facet isolate.
117
+ *
118
+ * Preamble text is bytes-stable for a given pool — it's part of the
119
+ * loader-cache key (fnHash), so changing the preamble invalidates all
120
+ * warm slots.
121
+ */
122
+ preamble?: string;
123
+ /**
124
+ * WebAssembly modules to ship into the facet via the LOADER's
125
+ * `modules` map. Map keys are module specifier paths (e.g.
126
+ * `'esbuild.wasm'`); values are the raw bytes.
127
+ *
128
+ * Workerd registers each entry as `{ wasm: ArrayBuffer }` in the
129
+ * worker's modules map. The pool prepends a static
130
+ * `import __NIMBUS_WASM_<id> from './<key>';` to the generated
131
+ * worker.js so workerd compiles each at module-load (startup phase,
132
+ * where wasm code generation is permitted). The compiled Modules
133
+ * are exposed via `globalThis.__NIMBUS_WASM[<key>]` for the user
134
+ * function to read at request time.
135
+ *
136
+ * Why this works when other paths don't:
137
+ * - request-time `WebAssembly.compile()` — disallowed by workerd
138
+ * in this deploy.
139
+ * - request-time RPC of a pre-compiled Module — workerd
140
+ * structured-clone refuses ("Unable to deserialize cloned data").
141
+ * - inlining bytes in the preamble — 16 MiB string per dispatch
142
+ * OOMs the supervisor at module-source allocation time.
143
+ * - LOADER modules-map (this) — bytes ride INSIDE the worker code
144
+ * blob; workerd compiles wasm during its own startup pipeline,
145
+ * never crossing structured-clone, never executing JS eval.
146
+ *
147
+ * The bytes ARE part of the loader-cache key (workerd hashes the
148
+ * whole WorkerCode), so changing the wasm bytes invalidates warm
149
+ * slots — desirable when the bundled wasm version changes.
150
+ */
151
+ wasmModules?: Record<string, ArrayBuffer>;
152
+ }
153
+ /** Per-call override (merged with pool defaults). */
154
+ export interface LoaderCallOptions {
155
+ timeoutMs?: number;
156
+ retries?: number;
157
+ /**
158
+ * Per-call WebAssembly modules. Merged with the pool's
159
+ * constructor-time `wasmModules` at dispatch time and shipped via
160
+ * the LOADER's modules map (same `{ wasm: ArrayBuffer }` shape).
161
+ *
162
+ * Shipping path validated empirically against prod (see
163
+ * `WebAssembly.instantiate(bytes)` is blocked at request-time but
164
+ * the LOADER-modules path compiles bytes during the inner
165
+ * worker's module-load phase, where wasm code generation IS
166
+ * permitted. The bytes ride INSIDE the worker code blob; workerd
167
+ * never crosses structured-clone, never executes user-eval.
168
+ *
169
+ * Cache key impact: per-call bytes are fingerprinted (length +
170
+ * first/last byte per module) and folded into the loader cache
171
+ * key. Identical bytes on the same slot → warm reuse; different
172
+ * bytes → fresh isolate. The pool's existing `wasmHash` field
173
+ * captures CONSTRUCTOR-time bytes only; per-call bytes get an
174
+ * independent fingerprint mixed into the slot id at dispatch.
175
+ *
176
+ * Naming collision rule: a per-call key MUST NOT collide with a
177
+ * constructor-time key (after identifier sanitisation). The
178
+ * dispatch path throws BindingError if it does — silently
179
+ * shadowing the constructor's wasm would break the cache-key
180
+ * invariant downstream callers rely on.
181
+ *
182
+ * Used by the `wasm-runner` shell command in src/runtime/
183
+ * wasm-runner.ts to ship user-supplied .wasm bytes from VFS into
184
+ * a fresh facet isolate per invocation.
185
+ */
186
+ wasmModules?: Record<string, ArrayBuffer>;
187
+ }
188
+ /** Per-map override. Adds onError strategy for partial failures. */
189
+ export interface LoaderMapOptions extends LoaderCallOptions {
190
+ /** Concurrency override for this call. Defaults to pool's concurrency. */
191
+ concurrency?: number;
192
+ /**
193
+ * What to do when an individual item fails:
194
+ * - 'throw' (default): reject whole map on first failure.
195
+ * - 'null': replace failed items with null in the result array.
196
+ * - 'skip': omit failed items from the result array.
197
+ * We default to 'throw' — install-time failures are not silently ignored.
198
+ */
199
+ onError?: 'throw' | 'null' | 'skip';
200
+ }
201
+ export interface LoaderWorkerModuleSourceOptions {
202
+ fnSource: string;
203
+ preamble?: string;
204
+ wasmEntries?: ReadonlyArray<{
205
+ name: string;
206
+ id: string;
207
+ }>;
208
+ hasBindings: boolean;
209
+ }
210
+ /** Assemble the exact JavaScript module parsed by a dynamic loader worker. */
211
+ export declare function assembleLoaderWorkerModuleSource(options: LoaderWorkerModuleSourceOptions): string;
212
+ /**
213
+ * Nimbus-scoped parallel dispatch over `env.LOADER`. Tasks are pure
214
+ * functions whose last argument is an `env` object containing the
215
+ * forwarded bindings (default: `{ SUPERVISOR }`).
216
+ *
217
+ * Typical use:
218
+ *
219
+ * const pool = new LoaderPool(env, ctx, {
220
+ * concurrency: 4,
221
+ * tag: 'npm-install',
222
+ * });
223
+ * const results = await pool.map(
224
+ * async (pkg, env) => env.SUPERVISOR.writeBatch(buildPayload(pkg)),
225
+ * toFetch,
226
+ * );
227
+ */
228
+ export declare class LoaderPool {
229
+ #private;
230
+ private readonly loader;
231
+ /** The hosting actor, as the loader-ledger's per-DO key. */
232
+ private readonly ctx;
233
+ private readonly concurrency;
234
+ private readonly defaultTimeoutMs;
235
+ private readonly defaultRetries;
236
+ private readonly tag;
237
+ private readonly slotGenerations;
238
+ private bindings;
239
+ private readonly preamble;
240
+ private readonly preambleHash;
241
+ /**
242
+ * WASM modules to ship in the LOADER `modules` map. See
243
+ * LoaderPoolOptions.wasmModules for the rationale. Stored in
244
+ * insertion order so the per-import preamble we generate matches
245
+ * across pool dispatches (cache-key stability).
246
+ */
247
+ private readonly wasmModules;
248
+ /** Hash of (name + byte length + first/last bytes) of every wasm
249
+ * module, folded into the loader cache key so changes invalidate
250
+ * warm slots. Hashing the FULL bytes would be O(20+ MiB) per dispatch
251
+ * and is unnecessary — wasm bytes are pinned at deploy time, the
252
+ * length+endpoints are a strong-enough fingerprint. */
253
+ private readonly wasmHash;
254
+ /**
255
+ * Short prefix of the owning DO's id, baked into the loader.get()
256
+ * cache key so warm isolates are scoped to ONE session. Without this,
257
+ * session A's pool and session B's pool (same `tag` + `fnHash`) share
258
+ * an isolate — which means B's writeBatch RPCs routed through A's
259
+ * env.SUPERVISOR binding (minted with A's doId at construction
260
+ * time). B's install reports success but the writes land in A's VFS,
261
+ * leaving B with only the git-clone seed files (~119 instead of ~1491).
262
+ * 12 chars is enough entropy for DO ids to collide-free per process.
263
+ */
264
+ private readonly doIdShort;
265
+ constructor(env: unknown, ctx: DurableObjectState, opts?: LoaderPoolOptions);
266
+ /** Effective concurrency used when no per-call override is supplied. */
267
+ get defaultConcurrency(): number;
268
+ /**
269
+ * Run `fn` once with `arg` on a slot isolate. Returns the result or
270
+ * throws TimeoutError / RetryExhaustedError / ExecutionError.
271
+ */
272
+ submit<T, R>(fn: FacetTaskFn<T, R>, arg: T, opts?: LoaderCallOptions): Promise<Awaited<R>>;
273
+ /**
274
+ * Run `fn` on every item in `items`, at most `concurrency` at a time,
275
+ * pinned to stable slots so warm isolates are reused.
276
+ *
277
+ * Results are returned in input order. Failure handling per `onError`.
278
+ */
279
+ map<T, R>(fn: FacetTaskFn<T, R>, items: T[], opts?: LoaderMapOptions): Promise<Array<Awaited<R> | null>>;
280
+ /**
281
+ * Same shape as `map`, but accepts a pre-serialized function source
282
+ * string instead of a live function reference. Used by
283
+ * `FanoutPool`'s peer-DO leg, where the function was already
284
+ * serialized on the coordinator side and forwarded over RPC.
285
+ *
286
+ * The fnSource MUST be the output of `serializeFunction(fn)`
287
+ * (typically forwarded directly from a coordinator RPC). Bytes-
288
+ * stable invariants:
289
+ * - `fnHash = hashSource(fnSource)` must be deterministic so
290
+ * warm slots are correctly keyed.
291
+ * - `fnSource` must NOT reference `this` — same rule as
292
+ * `serializeFunction`.
293
+ *
294
+ * No fn-validation runs here (it already ran on the coordinator);
295
+ * the peer trusts the caller to forward a valid serialization.
296
+ */
297
+ mapSource<T, R>(fnSource: string, items: T[], opts?: LoaderMapOptions): Promise<Array<Awaited<R> | null>>;
298
+ /**
299
+ * Release any RPC stubs held by the pool. Call this once the caller
300
+ * is done with the pool (post-`map`/`submit`) so the underlying
301
+ * stubs don't linger in workerd's deferred-destruction queue.
302
+ *
303
+ * Primary target: the SUPERVISOR binding stub we minted at
304
+ * construction time (via the registered supervisor entrypoint). It's
305
+ * a cross-isolate RPC stub — without explicit disposal it stays
306
+ * referenced until the parent isolate's event-handler context
307
+ * finishes, which during npm install means "until the whole install
308
+ * completes" — long enough to accumulate alongside other leaked
309
+ * stubs and trip the QueueState::ACTIVE fatal.
310
+ *
311
+ * Safe to call more than once; idempotent.
312
+ */
313
+ dispose(): void;
314
+ }
315
+ //# sourceMappingURL=loader-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loader-pool.d.ts","sourceRoot":"","sources":["../src/loader-pool.ts"],"names":[],"mappings":"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"}