@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,47 @@
1
+ /**
2
+ * ctx-exports.ts — leaf module holding the ctx.exports reference.
3
+ *
4
+ * Isolated from the embedder's entry module so helpers (notably
5
+ * loader-pool.ts) can read `ctx.exports` without transitively importing the
6
+ * Durable Object classes. Keeping this a leaf (no imports) lets the pool be
7
+ * unit-tested in a plain Node/Bun process.
8
+ *
9
+ * The embedder's fetch handler calls `setCtxExports(ctx.exports)` on the
10
+ * first request; callers like the loader pool read via `getCtxExports()`.
11
+ * If the pool is constructed before the first fetch (unlikely) it just gets
12
+ * null — the caller decides how to degrade.
13
+ */
14
+ /**
15
+ * One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
16
+ * mints a Service Binding stub for that entrypoint when called with props.
17
+ *
18
+ * The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
19
+ * cannot see — `Cloudflare.Exports` is derived from the embedder's own main
20
+ * module, so for a library it evaluates to `{}`. A caller that knows the class
21
+ * names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
22
+ * that does not gets `unknown` and has to narrow, same as
23
+ * `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
24
+ */
25
+ export type EntrypointLoopbackFactory = <Stub = unknown>(options: {
26
+ props: object;
27
+ }) => Stub;
28
+ /**
29
+ * `ctx.exports` itself — one factory per top-level entrypoint export, keyed by
30
+ * export name. Absent names read as undefined, which is how a caller finds out
31
+ * the embedder's entry module does not re-export the class it needs.
32
+ */
33
+ export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
34
+ export declare function setCtxExports(value: CtxExports): void;
35
+ export declare function getCtxExports(): CtxExports | null;
36
+ export declare function setSupervisorEntrypointName(name: string): void;
37
+ /**
38
+ * Resolve the registered supervisor entrypoint on an exports object —
39
+ * `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
40
+ * the held ctx.exports otherwise. Calling the result with props mints one
41
+ * supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
42
+ * either half is missing; the caller decides whether that degrades or throws.
43
+ */
44
+ export declare function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null;
45
+ /** The registered name, for error messages that point at the missing export. */
46
+ export declare function supervisorEntrypointName(): string | null;
47
+ //# sourceMappingURL=ctx-exports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ctx-exports.d.ts","sourceRoot":"","sources":["../src/ctx-exports.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,IAAI,GAAG,OAAO,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAC;AAE7F;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,yBAAyB,GAAG,SAAS,CAAC,CAAC;AAI/E,wBAAgB,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAGrD;AAED,wBAAgB,aAAa,IAAI,UAAU,GAAG,IAAI,CAEjD;AAUD,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAG9D;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,CAAC,EAAE,OAAO,GAAG,yBAAyB,GAAG,IAAI,CAM3F;AAED,gFAAgF;AAChF,wBAAgB,wBAAwB,IAAI,MAAM,GAAG,IAAI,CAExD"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * ctx-exports.ts — leaf module holding the ctx.exports reference.
3
+ *
4
+ * Isolated from the embedder's entry module so helpers (notably
5
+ * loader-pool.ts) can read `ctx.exports` without transitively importing the
6
+ * Durable Object classes. Keeping this a leaf (no imports) lets the pool be
7
+ * unit-tested in a plain Node/Bun process.
8
+ *
9
+ * The embedder's fetch handler calls `setCtxExports(ctx.exports)` on the
10
+ * first request; callers like the loader pool read via `getCtxExports()`.
11
+ * If the pool is constructed before the first fetch (unlikely) it just gets
12
+ * null — the caller decides how to degrade.
13
+ */
14
+ let _ctxExports = null;
15
+ export function setCtxExports(value) {
16
+ if (_ctxExports)
17
+ return; // first-write-wins, same as the prior inline impl
18
+ _ctxExports = value;
19
+ }
20
+ export function getCtxExports() {
21
+ return _ctxExports;
22
+ }
23
+ /**
24
+ * The fabric mints supervisor bindings for the programs it hosts, but the
25
+ * entrypoint class that answers them belongs to the embedder, so its
26
+ * ctx.exports name is registered once at composition time rather than
27
+ * hardcoded here. First-write-wins, same as the ctx.exports holder above.
28
+ */
29
+ let _supervisorEntrypointName = null;
30
+ export function setSupervisorEntrypointName(name) {
31
+ if (_supervisorEntrypointName)
32
+ return;
33
+ _supervisorEntrypointName = name;
34
+ }
35
+ /**
36
+ * Resolve the registered supervisor entrypoint on an exports object —
37
+ * `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
38
+ * the held ctx.exports otherwise. Calling the result with props mints one
39
+ * supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
40
+ * either half is missing; the caller decides whether that degrades or throws.
41
+ */
42
+ export function supervisorEntrypoint(exportsObj) {
43
+ const exports = exportsObj ?? _ctxExports;
44
+ if (!_supervisorEntrypointName)
45
+ return null;
46
+ if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null)
47
+ return null;
48
+ const factory = exports[_supervisorEntrypointName];
49
+ return typeof factory === 'function' ? factory : null;
50
+ }
51
+ /** The registered name, for error messages that point at the missing export. */
52
+ export function supervisorEntrypointName() {
53
+ return _supervisorEntrypointName;
54
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * facet-image-store.ts — materializing resident-process boot images into the
3
+ * content-addressed image store, and sweeping the ones nothing boots from.
4
+ *
5
+ * A resident process's module map is sized by the user's disk, so it does not
6
+ * ride inside the boot spec — the store writes it once and the session keeps
7
+ * only a path (see process-fabric.ts, ResidentCodeSpec.vfsTextModules). This
8
+ * module owns the write protocol: paced slicing so no one turn holds a
9
+ * transaction the platform resets the object over, register-roots-before-
10
+ * first-byte so the sweep can never observe an unrooted image, size-equality
11
+ * as the completeness test, and a mark-sweep rooted off the live process
12
+ * table.
13
+ *
14
+ * The filesystem itself stays the embedder's, reached through the
15
+ * {@link FacetImageBlobStore} port — the store decides what is written where
16
+ * and when; the port decides how bytes land on a disk and with what modes and
17
+ * credentials.
18
+ */
19
+ import type { LaunchPacer } from './launch-pacer.js';
20
+ /**
21
+ * Bytes of an image written in one storage transaction.
22
+ *
23
+ * The bound is the VFS's own, not a knob: a `writeRange` whose chunks fit
24
+ * inside one transaction is committed in place, and one that does not falls
25
+ * back to copy-on-write — which rewrites every chunk of the file, per slice,
26
+ * making a sliced write quadratic in its size. A whole number of chunks is
27
+ * the other half of that: a slice that ends mid-chunk makes the next one read
28
+ * the partial chunk back to complete it.
29
+ */
30
+ export declare const FACET_IMAGE_WRITE_SLICE_BYTES: number;
31
+ /**
32
+ * What the store needs from the disk it writes images to — derived from the
33
+ * writes it actually performs, nothing more. Modes, credentials and path
34
+ * normalization are the implementation's: the store passes the same
35
+ * store-relative paths it later roots and sweeps by.
36
+ */
37
+ export interface FacetImageBlobStore {
38
+ /** Create a directory (and its parents) if it does not exist. */
39
+ mkdirp(dir: string): void;
40
+ /** The file's current size in bytes, or null when it does not exist. */
41
+ sizeOf(path: string): number | null;
42
+ /** Create or REPLACE the file with exactly these bytes (truncating). */
43
+ writeFile(path: string, bytes: Uint8Array): void;
44
+ /** Write bytes at an offset, growing the file. */
45
+ writeRange(path: string, offset: number, bytes: Uint8Array): void;
46
+ /** Entry names directly under `dir`. Throws when the dir is unreadable. */
47
+ list(dir: string): string[];
48
+ /** Remove a file. Throws when it is already gone. */
49
+ unlink(path: string): void;
50
+ }
51
+ /**
52
+ * The content-addressed image store of one hosting Durable Object.
53
+ *
54
+ * The store is written by the kernel and read by the process, so nothing
55
+ * here depends on which credential spawned what. Digest collisions are the
56
+ * hash's problem; everything else is idempotent — an image already present
57
+ * at its own digest is already the bytes we were about to write.
58
+ */
59
+ export declare class FacetImageStore {
60
+ private readonly blobs;
61
+ private readonly isLive;
62
+ /** pid → the boot images its facet loads from; the image sweep's root set. */
63
+ private residentImages;
64
+ private dirReady;
65
+ /**
66
+ * @param blobs The disk the images land on, resolved per use — the embedder
67
+ * may not have a filesystem yet when the store is constructed, and throws
68
+ * from here when a write is asked for without one.
69
+ * @param isLive Whether a pid still names a running process. The root set
70
+ * is the process table, reached through this one predicate.
71
+ */
72
+ constructor(blobs: () => FacetImageBlobStore, isLive: (pid: number) => boolean);
73
+ /**
74
+ * The image store's directory, created before the first filesystem view is
75
+ * built rather than on the first image write.
76
+ *
77
+ * Lazily created, it made the store perturb the very view every manifest is
78
+ * built from: the root listing gained an entry the moment an image landed,
79
+ * so the next spawn of an identical program generated different text and
80
+ * addressed a different image. Existing before the first walk makes it
81
+ * stable.
82
+ *
83
+ * Sited on the embedder's exec path and not where its filesystem is
84
+ * attached, because that runs while the Durable Object is coming up —
85
+ * including on every wake — and a synchronous filesystem write there costs
86
+ * the session its startup. Measured: a throwaway built that way stopped
87
+ * accepting terminal connections at all, while the same build without it
88
+ * served them.
89
+ */
90
+ ensureDir(): void;
91
+ /**
92
+ * Materialize generated module sources in the content-addressed image store
93
+ * and return the module-name → path map naming them.
94
+ *
95
+ * Writing the sources here, once, is what lets the session stop holding
96
+ * them: after this returns, the only thing it keeps is a path.
97
+ */
98
+ materialize(pid: number, modules: Record<string, string>, pacer: LaunchPacer): Promise<Record<string, string>>;
99
+ /**
100
+ * Drop every image no running process boots from.
101
+ *
102
+ * Content addressing means a changed program writes a NEW image rather than
103
+ * replacing one, so a watch loop — or simply a session that runs a few
104
+ * different programs — would otherwise leave one bundle-sized file behind
105
+ * per distinct version. The root set is the process table, which is exact:
106
+ * an image is live for precisely as long as the process that boots from it.
107
+ * Nothing is left for a TTL or an eviction heuristic to guess at, and after
108
+ * a DO reset the table is empty so every orphan goes.
109
+ */
110
+ private sweep;
111
+ }
112
+ //# sourceMappingURL=facet-image-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"facet-image-store.d.ts","sourceRoot":"","sources":["../src/facet-image-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAErD;;;;;;;;;GASG;AACH,eAAO,MAAM,6BAA6B,QAA0D,CAAC;AAErG;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,wEAAwE;IACxE,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,wEAAwE;IACxE,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IACjD,kDAAkD;IAClD,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,GAAG,IAAI,CAAC;IAClE,2EAA2E;IAC3E,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAC5B,qDAAqD;IACrD,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED;;;;;;;GAOG;AACH,qBAAa,eAAe;IAaxB,OAAO,CAAC,QAAQ,CAAC,KAAK;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAbzB,8EAA8E;IAC9E,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,QAAQ,CAAS;IAEzB;;;;;;OAMG;gBAEgB,KAAK,EAAE,MAAM,mBAAmB,EAChC,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO;IAGnD;;;;;;;;;;;;;;;;OAgBG;IACH,SAAS,IAAI,IAAI;IAOjB;;;;;;OAMG;IACG,WAAW,CACf,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAoDlC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,KAAK;CAgBd"}
@@ -0,0 +1,181 @@
1
+ /**
2
+ * facet-image-store.ts — materializing resident-process boot images into the
3
+ * content-addressed image store, and sweeping the ones nothing boots from.
4
+ *
5
+ * A resident process's module map is sized by the user's disk, so it does not
6
+ * ride inside the boot spec — the store writes it once and the session keeps
7
+ * only a path (see process-fabric.ts, ResidentCodeSpec.vfsTextModules). This
8
+ * module owns the write protocol: paced slicing so no one turn holds a
9
+ * transaction the platform resets the object over, register-roots-before-
10
+ * first-byte so the sweep can never observe an unrooted image, size-equality
11
+ * as the completeness test, and a mark-sweep rooted off the live process
12
+ * table.
13
+ *
14
+ * The filesystem itself stays the embedder's, reached through the
15
+ * {@link FacetImageBlobStore} port — the store decides what is written where
16
+ * and when; the port decides how bytes land on a disk and with what modes and
17
+ * credentials.
18
+ */
19
+ import { MAX_TX_BLOB_BYTES, CHUNK_SIZE } from '@nimbus-sh/core/constants.js';
20
+ import { FACET_IMAGE_DIR, facetImageDigest, facetImagePath } from './process-fabric.js';
21
+ /**
22
+ * Bytes of an image written in one storage transaction.
23
+ *
24
+ * The bound is the VFS's own, not a knob: a `writeRange` whose chunks fit
25
+ * inside one transaction is committed in place, and one that does not falls
26
+ * back to copy-on-write — which rewrites every chunk of the file, per slice,
27
+ * making a sliced write quadratic in its size. A whole number of chunks is
28
+ * the other half of that: a slice that ends mid-chunk makes the next one read
29
+ * the partial chunk back to complete it.
30
+ */
31
+ export const FACET_IMAGE_WRITE_SLICE_BYTES = Math.floor(MAX_TX_BLOB_BYTES / CHUNK_SIZE) * CHUNK_SIZE;
32
+ /**
33
+ * The content-addressed image store of one hosting Durable Object.
34
+ *
35
+ * The store is written by the kernel and read by the process, so nothing
36
+ * here depends on which credential spawned what. Digest collisions are the
37
+ * hash's problem; everything else is idempotent — an image already present
38
+ * at its own digest is already the bytes we were about to write.
39
+ */
40
+ export class FacetImageStore {
41
+ blobs;
42
+ isLive;
43
+ /** pid → the boot images its facet loads from; the image sweep's root set. */
44
+ residentImages = new Map();
45
+ dirReady = false;
46
+ /**
47
+ * @param blobs The disk the images land on, resolved per use — the embedder
48
+ * may not have a filesystem yet when the store is constructed, and throws
49
+ * from here when a write is asked for without one.
50
+ * @param isLive Whether a pid still names a running process. The root set
51
+ * is the process table, reached through this one predicate.
52
+ */
53
+ constructor(blobs, isLive) {
54
+ this.blobs = blobs;
55
+ this.isLive = isLive;
56
+ }
57
+ /**
58
+ * The image store's directory, created before the first filesystem view is
59
+ * built rather than on the first image write.
60
+ *
61
+ * Lazily created, it made the store perturb the very view every manifest is
62
+ * built from: the root listing gained an entry the moment an image landed,
63
+ * so the next spawn of an identical program generated different text and
64
+ * addressed a different image. Existing before the first walk makes it
65
+ * stable.
66
+ *
67
+ * Sited on the embedder's exec path and not where its filesystem is
68
+ * attached, because that runs while the Durable Object is coming up —
69
+ * including on every wake — and a synchronous filesystem write there costs
70
+ * the session its startup. Measured: a throwaway built that way stopped
71
+ * accepting terminal connections at all, while the same build without it
72
+ * served them.
73
+ */
74
+ ensureDir() {
75
+ if (this.dirReady)
76
+ return;
77
+ this.dirReady = true;
78
+ try {
79
+ this.blobs().mkdirp(FACET_IMAGE_DIR);
80
+ }
81
+ catch { /* a session whose disk is not writable has no images to store */ }
82
+ }
83
+ /**
84
+ * Materialize generated module sources in the content-addressed image store
85
+ * and return the module-name → path map naming them.
86
+ *
87
+ * Writing the sources here, once, is what lets the session stop holding
88
+ * them: after this returns, the only thing it keeps is a path.
89
+ */
90
+ async materialize(pid, modules, pacer) {
91
+ const fs = this.blobs();
92
+ const images = {};
93
+ const sources = new Map();
94
+ for (const [moduleName, source] of Object.entries(modules)) {
95
+ const path = facetImagePath(await facetImageDigest(source));
96
+ images[moduleName] = path;
97
+ sources.set(path, source);
98
+ await pacer.spend(source.length);
99
+ }
100
+ // Register the WHOLE root set here, in one synchronous step, before any
101
+ // byte of it exists on disk. That ordering is the entire protocol between
102
+ // a launch and the sweep: an image is rooted from before it is written, so
103
+ // a sweep can never observe a file this launch has written but not yet
104
+ // claimed. The old loop achieved it by not awaiting at all, which read as
105
+ // "the writes must not be interrupted" — they may be. What must not be
106
+ // interrupted is the gap between writing and rooting, and rooting first
107
+ // closes it for every write that follows, however many turns they span.
108
+ this.residentImages.set(pid, [...sources.keys()]);
109
+ fs.mkdirp(FACET_IMAGE_DIR);
110
+ for (const [path, source] of sources) {
111
+ const stored = path.replace(/^\/+/, '');
112
+ const bytes = new TextEncoder().encode(source);
113
+ // An image at its full size is a COMPLETE one: a write only ever grows
114
+ // the file from offset zero, so a write cut short by a reset leaves a
115
+ // strictly shorter file and fails this test. Size is enough of a check
116
+ // because the reader verifies the digest before the loader sees it.
117
+ if (fs.sizeOf(stored) === bytes.byteLength) {
118
+ await pacer.spend(bytes.byteLength);
119
+ continue;
120
+ }
121
+ // Sliced because the platform resets the object over what ONE TURN has
122
+ // outstanding, not over what it eventually writes — pi's 22.9 MB map
123
+ // went in as a single write and took the session down with it ~25% of
124
+ // the time. Spending between slices is what puts the rest of the image
125
+ // on later turns; the slice bound is what keeps any one of them small.
126
+ let offset = 0;
127
+ do {
128
+ const slice = bytes.subarray(offset, offset + FACET_IMAGE_WRITE_SLICE_BYTES);
129
+ // The first slice REPLACES the file, so an interrupted write's remains
130
+ // are truncated to a known length rather than left as a tail past this
131
+ // content.
132
+ if (offset === 0)
133
+ fs.writeFile(stored, slice);
134
+ else
135
+ fs.writeRange(stored, offset, slice);
136
+ offset += slice.byteLength;
137
+ await pacer.spend(slice.byteLength);
138
+ } while (offset < bytes.byteLength);
139
+ }
140
+ this.sweep(fs);
141
+ return images;
142
+ }
143
+ /**
144
+ * Drop every image no running process boots from.
145
+ *
146
+ * Content addressing means a changed program writes a NEW image rather than
147
+ * replacing one, so a watch loop — or simply a session that runs a few
148
+ * different programs — would otherwise leave one bundle-sized file behind
149
+ * per distinct version. The root set is the process table, which is exact:
150
+ * an image is live for precisely as long as the process that boots from it.
151
+ * Nothing is left for a TTL or an eviction heuristic to guess at, and after
152
+ * a DO reset the table is empty so every orphan goes.
153
+ */
154
+ sweep(fs) {
155
+ const live = new Set();
156
+ for (const [pid, paths] of this.residentImages) {
157
+ if (this.isLive(pid)) {
158
+ for (const path of paths)
159
+ live.add(path);
160
+ }
161
+ else {
162
+ this.residentImages.delete(pid);
163
+ }
164
+ }
165
+ let names;
166
+ try {
167
+ names = fs.list(FACET_IMAGE_DIR);
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const name of names) {
173
+ if (live.has(`/${FACET_IMAGE_DIR}/${name}`))
174
+ continue;
175
+ try {
176
+ fs.unlink(`${FACET_IMAGE_DIR}/${name}`);
177
+ }
178
+ catch { /* already gone */ }
179
+ }
180
+ }
181
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Two-tier fan-out primitive for work that must execute in Worker Loader
3
+ * facets without tripping workerd's per-DO dynamic-worker ceiling.
4
+ *
5
+ * A single Durable Object method can drive at most four concurrent
6
+ * Worker Loader fetches before extra dispatches serialize or fail. Small
7
+ * batches therefore run in the coordinator DO through LoaderPool.
8
+ * Wider batches are sharded across sibling NimbusSession DOs, each of
9
+ * which owns its own four-loader budget.
10
+ *
11
+ * Routing is deterministic: each task has a stable key, and the key maps
12
+ * to a sibling DO shard. There is no silent fallback to width-1 execution;
13
+ * missing LOADER or NIMBUS_SESSION bindings fail loudly so install and
14
+ * runtime operations do not appear successful after partial dispatch.
15
+ */
16
+ import { type FacetTaskFn } from './loader-pool.js';
17
+ import type { WorkerLoader } from './vendor/types.js';
18
+ /**
19
+ * The sibling-session namespace the peer-DO topology routes through. Ids are
20
+ * derived from a name so a task key always lands on the same peer.
21
+ */
22
+ interface PeerSessionNamespace {
23
+ idFromName(name: string): DurableObjectId;
24
+ get(id: DurableObjectId): unknown;
25
+ }
26
+ /** The bindings a fan-out needs off the coordinator DO's env. */
27
+ export interface FanoutPoolEnv {
28
+ LOADER?: WorkerLoader;
29
+ NIMBUS_SESSION?: PeerSessionNamespace;
30
+ }
31
+ /**
32
+ * Threshold at which routing switches from coordinator-local loaders to
33
+ * sibling Durable Objects.
34
+ *
35
+ * Set to **5** so the in-DO path stays below the V8 4-loaders-per-method
36
+ * cap by construction. width < 5 stays local; width >= 5 uses sibling DOs.
37
+ */
38
+ export declare const IN_DO_THRESHOLD = 5;
39
+ /**
40
+ * Hard cap on concurrent peer DOs per single submitMany call. Throughput stays
41
+ * flat through this width while keeping per-request scheduler pressure bounded.
42
+ */
43
+ export declare const MAX_PEER_FANOUT = 32;
44
+ /**
45
+ * Bounded retries for a peer-DO shard dispatch that rejects with a
46
+ * transient platform reset (code roll-over, storage cold-start hiccup).
47
+ * Sibling DOs are addressed by stable name, so the retry re-dispatches
48
+ * the SAME shard to the re-provisioning object; the fanned-out work
49
+ * (packument resolution, tarball materialisation) is idempotent, so
50
+ * re-running a shard is safe. Budget mirrors the resolve-facet's own
51
+ * per-fetch retry policy so a single flaky cold start no longer fails a
52
+ * whole install. The same budget covers an overloaded peer, on the longer
53
+ * schedule below. Non-transient rejections (OOM, count mismatch, genuine
54
+ * task throw) are NOT retried — they propagate on the first hit.
55
+ */
56
+ export declare const PEER_TRANSIENT_RESET_RETRIES = 3;
57
+ export declare const PEER_RETRY_BACKOFF_MS: number[];
58
+ /**
59
+ * Backoff for a shard whose peer DO was shed as overloaded. The object is
60
+ * alive and the shard never ran; what it needs is time for the input-gate
61
+ * queue to drain, so the schedule is an order of magnitude longer than the
62
+ * reset schedule. A whole-batch abort here used to fail an entire install.
63
+ */
64
+ export declare const PEER_OVERLOAD_BACKOFF_MS: number[];
65
+ /**
66
+ * Peer shards dispatched per phase. Each phase is a barrier that costs its
67
+ * slowest member, so a wide fan-out pays ⌈shards / FANOUT_PHASE_SIZE⌉ serial
68
+ * round-trips; the size trades that serialization against simultaneous cold
69
+ * sibling DO starts.
70
+ *
71
+ * The six-barrier profile once measured on a 123-package install (21 shards of
72
+ * ~6 packages, 10.6/6.8/21.8/7.9/34.6/4.8 s) came from the shard count, not
73
+ * from this width. Capping install shards at INSTALL_PEER_CAP fixed it at the
74
+ * source and that install now clears in two phases. Widening to 8 on top of
75
+ * that bought one further barrier and doubled the simultaneous cold sibling-DO
76
+ * starts, which is the account-level pressure the phasing exists for: twelve
77
+ * concurrent Markflow installs went from 48 simultaneous peer starts to 96 and
78
+ * began timing out. Phasing does not change how many peers start, only how
79
+ * many start at once, so this width is set by the burst the scheduler
80
+ * tolerates rather than by the barrier count.
81
+ */
82
+ export declare const FANOUT_PHASE_SIZE = 4;
83
+ /** Argument shape for `submitMany`. */
84
+ export interface FanoutTask<A> {
85
+ /**
86
+ * Routing key for the stable-id router. Same key → same peer DO
87
+ * (when on the peer-DO path). Tests use this to predict placement.
88
+ */
89
+ key: string;
90
+ /** Argument passed to the user fn. */
91
+ args: A;
92
+ }
93
+ /** Options handed to FanoutPool's constructor. */
94
+ export interface FanoutPoolOptions {
95
+ /**
96
+ * Tag prepended to peer-DO ids and in-DO loader ids for debugging
97
+ * (e.g. "npm-install-batch"). Affects neither isolate identity (in-DO
98
+ * path uses the existing LoaderPool's tag-fold) nor peer-DO
99
+ * deterministic placement (peer ids fold tag + key).
100
+ */
101
+ tag: string;
102
+ /**
103
+ * Per-task timeout in ms. Default 60_000. Forwarded to the in-DO
104
+ * LoaderPool's submit calls and to the peer-DO RPC's own
105
+ * LoaderPool.
106
+ */
107
+ timeoutMs?: number;
108
+ /**
109
+ * Preamble bundled into every facet (in-DO and inside each peer
110
+ * DO). Same semantics as LoaderPool's preamble option.
111
+ */
112
+ preamble?: string;
113
+ /**
114
+ * Wasm modules forwarded to every facet. Same semantics as
115
+ * LoaderPool's wasmModules option.
116
+ */
117
+ wasmModules?: Record<string, ArrayBuffer>;
118
+ /**
119
+ * Extra bindings forwarded to every facet. Same semantics as
120
+ * LoaderPool's extraBindings option.
121
+ */
122
+ extraBindings?: Record<string, unknown>;
123
+ /**
124
+ * If set, skip the supervisor-RPC binding injection (mirrors
125
+ * LoaderPool's omitSupervisor flag).
126
+ */
127
+ omitSupervisor?: boolean;
128
+ /**
129
+ * Invoking process pid, baked into each facet's SUPERVISOR binding so
130
+ * filesystem RPCs (writeBatchStream) are authorized under the caller's
131
+ * credential (mirrors LoaderPool's supervisorPid). Threaded to both
132
+ * the in-DO loader pool and, via `_rpcFanoutExecute`, the peer-DO pools.
133
+ * npm install passes the shell command's `ctx.pid`; resolve leaves it 0.
134
+ */
135
+ supervisorPid?: number;
136
+ /**
137
+ * Called once per completed peer-DO dispatch phase with that phase's shard
138
+ * count and elapsed ms. Phases are barriers, so this is what tells a caller
139
+ * whether its fan-out is bounded by shard work or by the number of barriers.
140
+ * Not called on the in-DO path, which has no phases.
141
+ */
142
+ onDispatchPhase?: (width: number, elapsedMs: number) => void;
143
+ /**
144
+ * Cap on peer DOs this pool will spread one submitMany across. Defaults to
145
+ * MAX_PEER_FANOUT. Tasks beyond the cap bucket into the peers that exist and
146
+ * run through their in-peer pool, so lowering it trades peers for barriers
147
+ * without lowering total concurrency: each peer runs its bucket at
148
+ * concurrency 4, so N peers still resolve 4N tasks at once.
149
+ *
150
+ * A caller sets this when its per-task work is small enough that a peer per
151
+ * task buys nothing but round-trips — one task per peer costs ⌈tasks/
152
+ * FANOUT_PHASE_SIZE⌉ barriers, and each barrier costs a cold sibling start.
153
+ */
154
+ maxPeers?: number;
155
+ }
156
+ /**
157
+ * Two-tier fan-out pool. Constructed by the supervisor DO; routes
158
+ * each `submitMany` call automatically based on width.
159
+ *
160
+ * Lifetime: cheap to construct (no async init). Multiple submitMany
161
+ * calls share NO state — each is dispatched fresh. The class
162
+ * exists primarily as a clean API surface; per-call dispatch state
163
+ * lives only inside submitMany's promise.
164
+ */
165
+ export declare class FanoutPool {
166
+ private readonly env;
167
+ private readonly ctx;
168
+ private readonly opts;
169
+ private readonly coordDoId;
170
+ private readonly coordDoIdShort;
171
+ constructor(rawEnv: unknown, ctx: DurableObjectState, opts: FanoutPoolOptions);
172
+ /**
173
+ * Dispatch `tasks` across the appropriate topology and return
174
+ * results in input order.
175
+ *
176
+ * Routing:
177
+ * tasks.length < 5 -> coordinator-local LoaderPool
178
+ * tasks.length >= 5 -> sibling NimbusSession DOs
179
+ *
180
+ * Backpressure: if `tasks.length > MAX_PEER_FANOUT (32)`, tasks
181
+ * are sharded modulo `MAX_PEER_FANOUT` and each shard's bucket
182
+ * runs serially inside its assigned peer DO via the in-peer
183
+ * LoaderPool's concurrency (capped at 4 there too). A
184
+ * single submitMany call returns when ALL tasks complete (or any
185
+ * throws).
186
+ *
187
+ * `fn` is the user function executed per task. It runs INSIDE a
188
+ * Worker Loader isolate (in the in-DO path) or inside a peer DO's
189
+ * Worker Loader isolate (in the peer-DO path); same trust posture
190
+ * as LoaderPool.submit. The function is serialized via
191
+ * the vendored serializeFunction (same as LoaderPool#prepare).
192
+ */
193
+ submitMany<A, R>(tasks: FanoutTask<A>[], fn: FacetTaskFn<A, R>): Promise<R[]>;
194
+ /** Report which topology a task count uses without dispatching. */
195
+ topologyFor(taskCount: number): 'in-do' | 'peer-do' | 'empty';
196
+ /**
197
+ * Compute the deterministic peer-DO id for a task key and peer count.
198
+ *
199
+ * Shape: `nbf:${tag}:${coordDoIdShort}:${shard}` where
200
+ * `shard = hash(key) mod peerCount`. Peer count is
201
+ * `min(tasks.length, MAX_PEER_FANOUT)`.
202
+ */
203
+ peerSiblingId(key: string, peerCount: number): string;
204
+ private _dispatchInDo;
205
+ private _dispatchPeerDo;
206
+ }
207
+ /**
208
+ * Stable hash → shard. Uses a fresh djb2 over the key (NOT
209
+ * hashSource) and modulos by peerCount.
210
+ *
211
+ * Why not reuse hashSource: hashSource returns a base-36 string,
212
+ * NOT hex — its alphabet is `[0-9a-z]`. parseInt(str, 16) on a
213
+ * base-36 string aborts at the first non-hex char (any of g-z),
214
+ * which produces extremely poor distribution: keys with the same
215
+ * leading-hex-prefix collide regardless of their suffix. (Seen in
216
+ * the wild: `task-0 .. task-7` all collided onto shard 4.)
217
+ *
218
+ * Deterministic: same key + same peerCount → same shard, every run.
219
+ * Tests use this to predict placement.
220
+ */
221
+ export declare function hashKeyToShard(key: string, peerCount: number): number;
222
+ export {};
223
+ //# sourceMappingURL=fanout-pool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fanout-pool.d.ts","sourceRoot":"","sources":["../src/fanout-pool.ts"],"names":[],"mappings":"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"}