@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,77 @@
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
+ /**
16
+ * One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
17
+ * mints a Service Binding stub for that entrypoint when called with props.
18
+ *
19
+ * The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
20
+ * cannot see — `Cloudflare.Exports` is derived from the embedder's own main
21
+ * module, so for a library it evaluates to `{}`. A caller that knows the class
22
+ * names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
23
+ * that does not gets `unknown` and has to narrow, same as
24
+ * `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
25
+ */
26
+ export type EntrypointLoopbackFactory = <Stub = unknown>(options: { props: object }) => Stub;
27
+
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
+
35
+ let _ctxExports: CtxExports | null = null;
36
+
37
+ export function setCtxExports(value: CtxExports): void {
38
+ if (_ctxExports) return; // first-write-wins, same as the prior inline impl
39
+ _ctxExports = value;
40
+ }
41
+
42
+ export function getCtxExports(): CtxExports | null {
43
+ return _ctxExports;
44
+ }
45
+
46
+ /**
47
+ * The fabric mints supervisor bindings for the programs it hosts, but the
48
+ * entrypoint class that answers them belongs to the embedder, so its
49
+ * ctx.exports name is registered once at composition time rather than
50
+ * hardcoded here. First-write-wins, same as the ctx.exports holder above.
51
+ */
52
+ let _supervisorEntrypointName: string | null = null;
53
+
54
+ export function setSupervisorEntrypointName(name: string): void {
55
+ if (_supervisorEntrypointName) return;
56
+ _supervisorEntrypointName = name;
57
+ }
58
+
59
+ /**
60
+ * Resolve the registered supervisor entrypoint on an exports object —
61
+ * `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
62
+ * the held ctx.exports otherwise. Calling the result with props mints one
63
+ * supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
64
+ * either half is missing; the caller decides whether that degrades or throws.
65
+ */
66
+ export function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null {
67
+ const exports = exportsObj ?? _ctxExports;
68
+ if (!_supervisorEntrypointName) return null;
69
+ if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null) return null;
70
+ const factory = (exports as Record<string, unknown>)[_supervisorEntrypointName];
71
+ return typeof factory === 'function' ? (factory as EntrypointLoopbackFactory) : null;
72
+ }
73
+
74
+ /** The registered name, for error messages that point at the missing export. */
75
+ export function supervisorEntrypointName(): string | null {
76
+ return _supervisorEntrypointName;
77
+ }
@@ -0,0 +1,196 @@
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
+
20
+ import { MAX_TX_BLOB_BYTES, CHUNK_SIZE } from '@nimbus-sh/core/constants.js';
21
+ import { FACET_IMAGE_DIR, facetImageDigest, facetImagePath } from './process-fabric.js';
22
+ import type { LaunchPacer } from './launch-pacer.js';
23
+
24
+ /**
25
+ * Bytes of an image written in one storage transaction.
26
+ *
27
+ * The bound is the VFS's own, not a knob: a `writeRange` whose chunks fit
28
+ * inside one transaction is committed in place, and one that does not falls
29
+ * back to copy-on-write — which rewrites every chunk of the file, per slice,
30
+ * making a sliced write quadratic in its size. A whole number of chunks is
31
+ * the other half of that: a slice that ends mid-chunk makes the next one read
32
+ * the partial chunk back to complete it.
33
+ */
34
+ export const FACET_IMAGE_WRITE_SLICE_BYTES = Math.floor(MAX_TX_BLOB_BYTES / CHUNK_SIZE) * CHUNK_SIZE;
35
+
36
+ /**
37
+ * What the store needs from the disk it writes images to — derived from the
38
+ * writes it actually performs, nothing more. Modes, credentials and path
39
+ * normalization are the implementation's: the store passes the same
40
+ * store-relative paths it later roots and sweeps by.
41
+ */
42
+ export interface FacetImageBlobStore {
43
+ /** Create a directory (and its parents) if it does not exist. */
44
+ mkdirp(dir: string): void;
45
+ /** The file's current size in bytes, or null when it does not exist. */
46
+ sizeOf(path: string): number | null;
47
+ /** Create or REPLACE the file with exactly these bytes (truncating). */
48
+ writeFile(path: string, bytes: Uint8Array): void;
49
+ /** Write bytes at an offset, growing the file. */
50
+ writeRange(path: string, offset: number, bytes: Uint8Array): void;
51
+ /** Entry names directly under `dir`. Throws when the dir is unreadable. */
52
+ list(dir: string): string[];
53
+ /** Remove a file. Throws when it is already gone. */
54
+ unlink(path: string): void;
55
+ }
56
+
57
+ /**
58
+ * The content-addressed image store of one hosting Durable Object.
59
+ *
60
+ * The store is written by the kernel and read by the process, so nothing
61
+ * here depends on which credential spawned what. Digest collisions are the
62
+ * hash's problem; everything else is idempotent — an image already present
63
+ * at its own digest is already the bytes we were about to write.
64
+ */
65
+ export class FacetImageStore {
66
+ /** pid → the boot images its facet loads from; the image sweep's root set. */
67
+ private residentImages = new Map<number, string[]>();
68
+ private dirReady = false;
69
+
70
+ /**
71
+ * @param blobs The disk the images land on, resolved per use — the embedder
72
+ * may not have a filesystem yet when the store is constructed, and throws
73
+ * from here when a write is asked for without one.
74
+ * @param isLive Whether a pid still names a running process. The root set
75
+ * is the process table, reached through this one predicate.
76
+ */
77
+ constructor(
78
+ private readonly blobs: () => FacetImageBlobStore,
79
+ private readonly isLive: (pid: number) => boolean,
80
+ ) {}
81
+
82
+ /**
83
+ * The image store's directory, created before the first filesystem view is
84
+ * built rather than on the first image write.
85
+ *
86
+ * Lazily created, it made the store perturb the very view every manifest is
87
+ * built from: the root listing gained an entry the moment an image landed,
88
+ * so the next spawn of an identical program generated different text and
89
+ * addressed a different image. Existing before the first walk makes it
90
+ * stable.
91
+ *
92
+ * Sited on the embedder's exec path and not where its filesystem is
93
+ * attached, because that runs while the Durable Object is coming up —
94
+ * including on every wake — and a synchronous filesystem write there costs
95
+ * the session its startup. Measured: a throwaway built that way stopped
96
+ * accepting terminal connections at all, while the same build without it
97
+ * served them.
98
+ */
99
+ ensureDir(): void {
100
+ if (this.dirReady) return;
101
+ this.dirReady = true;
102
+ try { this.blobs().mkdirp(FACET_IMAGE_DIR); }
103
+ catch { /* a session whose disk is not writable has no images to store */ }
104
+ }
105
+
106
+ /**
107
+ * Materialize generated module sources in the content-addressed image store
108
+ * and return the module-name → path map naming them.
109
+ *
110
+ * Writing the sources here, once, is what lets the session stop holding
111
+ * them: after this returns, the only thing it keeps is a path.
112
+ */
113
+ async materialize(
114
+ pid: number,
115
+ modules: Record<string, string>,
116
+ pacer: LaunchPacer,
117
+ ): Promise<Record<string, string>> {
118
+ const fs = this.blobs();
119
+ const images: Record<string, string> = {};
120
+ const sources = new Map<string, string>();
121
+ for (const [moduleName, source] of Object.entries(modules)) {
122
+ const path = facetImagePath(await facetImageDigest(source));
123
+ images[moduleName] = path;
124
+ sources.set(path, source);
125
+ await pacer.spend(source.length);
126
+ }
127
+ // Register the WHOLE root set here, in one synchronous step, before any
128
+ // byte of it exists on disk. That ordering is the entire protocol between
129
+ // a launch and the sweep: an image is rooted from before it is written, so
130
+ // a sweep can never observe a file this launch has written but not yet
131
+ // claimed. The old loop achieved it by not awaiting at all, which read as
132
+ // "the writes must not be interrupted" — they may be. What must not be
133
+ // interrupted is the gap between writing and rooting, and rooting first
134
+ // closes it for every write that follows, however many turns they span.
135
+ this.residentImages.set(pid, [...sources.keys()]);
136
+ fs.mkdirp(FACET_IMAGE_DIR);
137
+ for (const [path, source] of sources) {
138
+ const stored = path.replace(/^\/+/, '');
139
+ const bytes = new TextEncoder().encode(source);
140
+ // An image at its full size is a COMPLETE one: a write only ever grows
141
+ // the file from offset zero, so a write cut short by a reset leaves a
142
+ // strictly shorter file and fails this test. Size is enough of a check
143
+ // because the reader verifies the digest before the loader sees it.
144
+ if (fs.sizeOf(stored) === bytes.byteLength) {
145
+ await pacer.spend(bytes.byteLength);
146
+ continue;
147
+ }
148
+ // Sliced because the platform resets the object over what ONE TURN has
149
+ // outstanding, not over what it eventually writes — pi's 22.9 MB map
150
+ // went in as a single write and took the session down with it ~25% of
151
+ // the time. Spending between slices is what puts the rest of the image
152
+ // on later turns; the slice bound is what keeps any one of them small.
153
+ let offset = 0;
154
+ do {
155
+ const slice = bytes.subarray(offset, offset + FACET_IMAGE_WRITE_SLICE_BYTES);
156
+ // The first slice REPLACES the file, so an interrupted write's remains
157
+ // are truncated to a known length rather than left as a tail past this
158
+ // content.
159
+ if (offset === 0) fs.writeFile(stored, slice);
160
+ else fs.writeRange(stored, offset, slice);
161
+ offset += slice.byteLength;
162
+ await pacer.spend(slice.byteLength);
163
+ } while (offset < bytes.byteLength);
164
+ }
165
+ this.sweep(fs);
166
+ return images;
167
+ }
168
+
169
+ /**
170
+ * Drop every image no running process boots from.
171
+ *
172
+ * Content addressing means a changed program writes a NEW image rather than
173
+ * replacing one, so a watch loop — or simply a session that runs a few
174
+ * different programs — would otherwise leave one bundle-sized file behind
175
+ * per distinct version. The root set is the process table, which is exact:
176
+ * an image is live for precisely as long as the process that boots from it.
177
+ * Nothing is left for a TTL or an eviction heuristic to guess at, and after
178
+ * a DO reset the table is empty so every orphan goes.
179
+ */
180
+ private sweep(fs: FacetImageBlobStore): void {
181
+ const live = new Set<string>();
182
+ for (const [pid, paths] of this.residentImages) {
183
+ if (this.isLive(pid)) {
184
+ for (const path of paths) live.add(path);
185
+ } else {
186
+ this.residentImages.delete(pid);
187
+ }
188
+ }
189
+ let names: string[];
190
+ try { names = fs.list(FACET_IMAGE_DIR); } catch { return; }
191
+ for (const name of names) {
192
+ if (live.has(`/${FACET_IMAGE_DIR}/${name}`)) continue;
193
+ try { fs.unlink(`${FACET_IMAGE_DIR}/${name}`); } catch { /* already gone */ }
194
+ }
195
+ }
196
+ }