@nimbus-sh/fabric 0.1.0 → 0.2.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 (103) hide show
  1. package/README.md +84 -55
  2. package/dist/bindings.js +5 -5
  3. package/dist/budgets.d.ts +132 -0
  4. package/dist/budgets.d.ts.map +1 -0
  5. package/dist/budgets.js +248 -0
  6. package/dist/composition.d.ts +87 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +76 -0
  9. package/dist/connections.d.ts +81 -0
  10. package/dist/connections.d.ts.map +1 -0
  11. package/dist/connections.js +114 -0
  12. package/dist/derived.d.ts +65 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +95 -0
  15. package/dist/do-calls.d.ts +94 -0
  16. package/dist/do-calls.d.ts.map +1 -0
  17. package/dist/do-calls.js +111 -0
  18. package/dist/facet-pool.d.ts +90 -0
  19. package/dist/facet-pool.d.ts.map +1 -0
  20. package/dist/facet-pool.js +113 -0
  21. package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
  22. package/dist/fanout.d.ts.map +1 -0
  23. package/dist/{fanout-pool.js → fanout.js} +20 -20
  24. package/dist/{launch-journal.d.ts → fenced-work.d.ts} +25 -13
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/{launch-journal.js → fenced-work.js} +47 -13
  27. package/dist/generation.d.ts +69 -0
  28. package/dist/generation.d.ts.map +1 -0
  29. package/dist/generation.js +118 -0
  30. package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
  31. package/dist/image-store.d.ts.map +1 -0
  32. package/dist/{facet-image-store.js → image-store.js} +4 -4
  33. package/dist/index.d.ts +16 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -8
  36. package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
  37. package/dist/isolate-pool.d.ts.map +1 -0
  38. package/dist/{loader-pool.js → isolate-pool.js} +20 -20
  39. package/dist/journal.d.ts +111 -0
  40. package/dist/journal.d.ts.map +1 -0
  41. package/dist/journal.js +177 -0
  42. package/dist/outbox.d.ts +249 -0
  43. package/dist/outbox.d.ts.map +1 -0
  44. package/dist/outbox.js +355 -0
  45. package/dist/process-fabric.d.ts +2 -14
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +6 -15
  48. package/dist/process-host.d.ts +1 -1
  49. package/dist/process-host.d.ts.map +1 -1
  50. package/dist/process-host.js +10 -9
  51. package/dist/sealed.d.ts +78 -0
  52. package/dist/sealed.d.ts.map +1 -0
  53. package/dist/sealed.js +145 -0
  54. package/dist/timers.d.ts +138 -0
  55. package/dist/timers.d.ts.map +1 -0
  56. package/dist/timers.js +231 -0
  57. package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +19 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +22 -11
  60. package/dist/workerd-facet-host.d.ts +28 -67
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +49 -171
  63. package/examples/agent-core-adapter.ts +191 -0
  64. package/package.json +4 -2
  65. package/src/bindings.ts +6 -6
  66. package/src/budgets.ts +308 -0
  67. package/src/composition.ts +127 -0
  68. package/src/connections.ts +140 -0
  69. package/src/derived.ts +135 -0
  70. package/src/do-calls.ts +156 -0
  71. package/src/facet-pool.ts +157 -0
  72. package/src/{fanout-pool.ts → fanout.ts} +35 -35
  73. package/src/{launch-journal.ts → fenced-work.ts} +58 -22
  74. package/src/generation.ts +144 -0
  75. package/src/{facet-image-store.ts → image-store.ts} +9 -9
  76. package/src/index.ts +16 -8
  77. package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
  78. package/src/journal.ts +242 -0
  79. package/src/node-async-hooks.d.ts +14 -0
  80. package/src/outbox.ts +520 -0
  81. package/src/process-fabric.ts +6 -33
  82. package/src/process-host.ts +10 -15
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +30 -24
  86. package/src/workerd-facet-host.ts +67 -193
  87. package/dist/alarms.d.ts +0 -134
  88. package/dist/alarms.d.ts.map +0 -1
  89. package/dist/alarms.js +0 -214
  90. package/dist/ctx-exports.d.ts +0 -47
  91. package/dist/ctx-exports.d.ts.map +0 -1
  92. package/dist/ctx-exports.js +0 -54
  93. package/dist/facet-image-store.d.ts.map +0 -1
  94. package/dist/fanout-pool.d.ts.map +0 -1
  95. package/dist/launch-journal.d.ts.map +0 -1
  96. package/dist/launch-pacer.d.ts.map +0 -1
  97. package/dist/loader-ledger.d.ts +0 -57
  98. package/dist/loader-ledger.d.ts.map +0 -1
  99. package/dist/loader-ledger.js +0 -91
  100. package/dist/loader-pool.d.ts.map +0 -1
  101. package/src/alarms.ts +0 -275
  102. package/src/ctx-exports.ts +0 -77
  103. package/src/loader-ledger.ts +0 -112
@@ -0,0 +1,191 @@
1
+ /**
2
+ * agent-core-adapter.ts — EXAMPLE: agent-core's process and environment
3
+ * seams, implemented over the fabric.
4
+ *
5
+ * agent-core defines two pure seams with no Cloudflare implementation:
6
+ * `ShellProcessBackend` (packages/agent-core/src/facets/shell/facet.ts:38-43
7
+ * — exactly `completion`, `forceTerminate`, `confirmTerminated`, `fence`)
8
+ * and `EnvironmentProvider` (src/environments/provider.ts:195-225 — every
9
+ * mutating verb paired with an inspect twin, requests generation-pinned,
10
+ * outcomes `ready | absent | failed | indeterminate`). This file shows the
11
+ * mapping onto fabric's `ResidentProcessHandle`, `cloneStorage`, and the
12
+ * hostname-per-port preview scheme. It is a worked example, not a shipped
13
+ * runtime: the seam types are mirrored here structurally (agent-core's repo
14
+ * is its own), and an embedder writes this file's equivalent against the
15
+ * real imports.
16
+ *
17
+ * The lifecycle facts the mapping rests on, from agent-core's own boundary
18
+ * (`ShellExecutionBoundary`, facet.ts:60-154):
19
+ * - a rejected `completion` is treated as exit 1 by the boundary;
20
+ * - `forceTerminate` may throw — the boundary then fences immediately;
21
+ * - `confirmTerminated() === false` means "still deciding" and defers to
22
+ * the boundary's timeout, never "failed";
23
+ * - a throwing `fence` is swallowed: "The local fence still closes this
24
+ * handle even if remote cleanup reports failure."
25
+ */
26
+
27
+ import type { ResidentProcessHandle } from '../src/process-fabric.js';
28
+ import { cloneStorage } from '../src/workerd-facet-host.js';
29
+
30
+ // ── agent-core's seams, mirrored structurally (do not import their repo) ────
31
+
32
+ /** facet.ts:38-43, verbatim shape. */
33
+ export interface ShellProcessBackend {
34
+ readonly completion: Promise<number>;
35
+ forceTerminate(): void;
36
+ confirmTerminated(): boolean | Promise<boolean>;
37
+ fence(): void;
38
+ }
39
+
40
+ /** provider.ts:37-61 — the outcome unions the provider seam speaks. */
41
+ export type ProviderActionOutcome = { readonly name: 'succeeded' | 'failed' | 'indeterminate' };
42
+ export type ProviderResourceOutcome<Value> =
43
+ | { readonly name: 'ready'; readonly value: Value }
44
+ | { readonly name: 'absent' }
45
+ | { readonly name: 'failed' }
46
+ | { readonly name: 'indeterminate' };
47
+
48
+ /** provider.ts:138-145 — the live-session handle openSession yields. */
49
+ export interface LiveEnvironmentSession {
50
+ readonly children: ReadonlyArray<{ dispose(): void | Promise<void> }>;
51
+ release(): void | Promise<void>;
52
+ }
53
+
54
+ /** provider.ts:171-193 — every request pins environment identity and
55
+ * generation, so a stale caller cannot act on a successor. */
56
+ export interface OpenSessionRequest {
57
+ readonly environmentId: string;
58
+ readonly environmentRevision: number;
59
+ readonly generation: number;
60
+ readonly sessionId: string;
61
+ }
62
+ export interface SnapshotEnvironmentRequest extends OpenSessionRequest {
63
+ readonly sessionEpoch: number;
64
+ readonly snapshotId: string;
65
+ }
66
+ export interface ExposePortRequest extends OpenSessionRequest {
67
+ readonly sessionEpoch: number;
68
+ readonly exposureId: string;
69
+ readonly port: number;
70
+ }
71
+
72
+ // ── ShellProcessBackend over one resident process ───────────────────────────
73
+
74
+ /**
75
+ * A shell command as a fabric resident. The exit code rides the `lifetime`
76
+ * runner's startProcess payload, whose shape is the embedder's — so the
77
+ * embedder names how to read it.
78
+ */
79
+ export function shellBackendFor(
80
+ handle: ResidentProcessHandle,
81
+ exitCodeOf: (startPayload: unknown) => number,
82
+ ): ShellProcessBackend {
83
+ return {
84
+ // A lifetime runner settles booted() at exit; a host that dies under the
85
+ // process rejects it, and the boundary maps that rejection to exit 1.
86
+ completion: handle.booted().then(exitCodeOf),
87
+ // Synchronous and idempotent, matching ResidentProcessHandle.kill.
88
+ forceTerminate(): void {
89
+ handle.kill();
90
+ },
91
+ // False before a kill was asked for — "still deciding", the boundary
92
+ // waits on its own timeout. After a kill, settle with the teardown: done
93
+ // resolves only once the process is actually gone.
94
+ confirmTerminated(): boolean | Promise<boolean> {
95
+ if (!handle.killed) return false;
96
+ return handle.done.then(() => true, () => true);
97
+ },
98
+ // The local fence: close this handle's side whatever the remote did.
99
+ // kill() is idempotent and swallows teardown throws, which is exactly
100
+ // the "local fence still closes this handle" contract.
101
+ fence(): void {
102
+ handle.kill();
103
+ },
104
+ };
105
+ }
106
+
107
+ // ── The four EnvironmentProvider verbs over the fabric ──────────────────────
108
+
109
+ /** What the example provider needs from its embedder: how to spawn one
110
+ * session process, and where port previews are served. */
111
+ export interface FabricEnvironmentHost {
112
+ /** Spawn the resident process backing one session (the embedder owns the
113
+ * boot spec; `ProcessFabric.startResidentProcess` is the fabric half). */
114
+ spawnSession(request: OpenSessionRequest): Promise<ResidentProcessHandle>;
115
+ /** The hosting Durable Object's ctx — snapshots clone same-object only. */
116
+ ctx: Parameters<typeof cloneStorage>[0];
117
+ /** Positive populated-ness probe for a facet name (the caller's schema —
118
+ * see cloneStorage: a clone of an unresolvable source silently EMPTIES
119
+ * the destination, so both ends are verified). */
120
+ facetPopulated(name: string): boolean | Promise<boolean>;
121
+ /** The facet name holding a session's storage. */
122
+ sessionFacetName(sessionId: string): string;
123
+ /** Preview apex, e.g. 'nimbus-os.dev' — ports serve as `<port>--<id>.<apex>`. */
124
+ previewApex: string;
125
+ }
126
+
127
+ export class FabricEnvironmentProvider {
128
+ /** exposureId → preview URL. Inbound requests for these hosts route into
129
+ * the session's `ResidentProcessHandle.routeTarget.handleHttpRequest`. */
130
+ private readonly exposures = new Map<string, string>();
131
+
132
+ constructor(private readonly host: FabricEnvironmentHost) {}
133
+
134
+ async openSession(request: OpenSessionRequest): Promise<ProviderResourceOutcome<LiveEnvironmentSession>> {
135
+ try {
136
+ const handle = await this.host.spawnSession(request);
137
+ return {
138
+ name: 'ready',
139
+ value: { children: [], release: () => handle.kill() },
140
+ };
141
+ } catch {
142
+ // The spawn either failed before any facet existed (failed) or the
143
+ // host died mid-boot (indeterminate); the fabric rejects both loudly.
144
+ return { name: 'failed' };
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Snapshot = same-object copy-on-write of the session facet's SQLite
150
+ * (measured 18-31 ms for a 45.73 MB corpus, flat in size). The ContentRef
151
+ * is the snapshot facet's name; `cloneStorage` verifies BOTH ends are
152
+ * populated, because an unresolvable source silently empties the
153
+ * destination while reporting success.
154
+ */
155
+ async createSnapshot(request: SnapshotEnvironmentRequest): Promise<ProviderResourceOutcome<string>> {
156
+ const src = this.host.sessionFacetName(request.sessionId);
157
+ const dst = `snapshot-${request.snapshotId}`;
158
+ try {
159
+ await cloneStorage(this.host.ctx, {
160
+ src,
161
+ dst,
162
+ populated: (name) => this.host.facetPopulated(name),
163
+ });
164
+ return { name: 'ready', value: dst };
165
+ } catch {
166
+ // A refused clone left nothing to trust in dst — never 'ready'.
167
+ return { name: 'failed' };
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Hostname-per-port: `<port>--<sessionId>.<apex>`, no path rewriting. The
173
+ * URL is derivable, so re-exposing is idempotent and inspection is a map
174
+ * read.
175
+ */
176
+ async exposePort(request: ExposePortRequest): Promise<ProviderResourceOutcome<string>> {
177
+ const url = `https://${request.port}--${request.sessionId}.${this.host.previewApex}/`;
178
+ this.exposures.set(request.exposureId, url);
179
+ return { name: 'ready', value: url };
180
+ }
181
+
182
+ async inspectExposure(request: ExposePortRequest): Promise<ProviderResourceOutcome<string>> {
183
+ const url = this.exposures.get(request.exposureId);
184
+ return url === undefined ? { name: 'absent' } : { name: 'ready', value: url };
185
+ }
186
+
187
+ async revokeExposure(request: ExposePortRequest): Promise<ProviderActionOutcome> {
188
+ this.exposures.delete(request.exposureId);
189
+ return { name: 'succeeded' };
190
+ }
191
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nimbus-sh/fabric",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The Cloudflare half of Nimbus — Durable Object facet hosting, dynamic-worker loader pools, the resident-process fabric, and DO alarm/hibernation machinery.",
5
5
  "keywords": [
6
6
  "cloudflare",
@@ -40,6 +40,7 @@
40
40
  "files": [
41
41
  "dist",
42
42
  "src",
43
+ "examples",
43
44
  "README.md",
44
45
  "LICENSE"
45
46
  ],
@@ -49,7 +50,8 @@
49
50
  "typecheck": "tsc --noEmit"
50
51
  },
51
52
  "dependencies": {
52
- "@nimbus-sh/core": "^0.5.0",
53
+ "@nimbus-sh/core": "^0.6.0",
54
+ "@nimbus-sh/platform": "^0.1.0",
53
55
  "zod": "^4.4.3"
54
56
  },
55
57
  "devDependencies": {
package/src/bindings.ts CHANGED
@@ -25,11 +25,11 @@
25
25
 
26
26
  import { WorkerEntrypoint } from 'cloudflare:workers';
27
27
  import { z } from 'zod/v4';
28
- import { disposeRpcResource, useRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
29
- import { supervisorEntrypoint, supervisorEntrypointName } from './ctx-exports.js';
30
- import { requireStagedBootAssembler } from './process-fabric.js';
31
- import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
32
- import type { EntrypointLoopbackFactory } from './ctx-exports.js';
28
+ import { disposeRpcResource, useRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
29
+ import { supervisorEntrypoint, supervisorEntrypointName } from './composition.js';
30
+ import { stagedBootAssembler } from './composition.js';
31
+ import { assertModuleMapWithinCodeLimit } from './budgets.js';
32
+ import type { EntrypointLoopbackFactory } from './composition.js';
33
33
  import type { WorkerCode } from './vendor/types.js';
34
34
 
35
35
  /**
@@ -578,7 +578,7 @@ export class NimbusLoadedEntrypoint extends WorkerEntrypoint<NimbusLoaderShimEnv
578
578
  // alive.
579
579
  const stage = props.stage;
580
580
  outerStub = outerLoader.get(props.key, async () => {
581
- const assembled = await requireStagedBootAssembler()(this.env, stage);
581
+ const assembled = await stagedBootAssembler()(this.env, stage);
582
582
  assertModuleMapWithinCodeLimit(
583
583
  (assembled as { modules?: Record<string, unknown> }).modules ?? {},
584
584
  );
package/src/budgets.ts ADDED
@@ -0,0 +1,308 @@
1
+ /**
2
+ * budgets.ts — per-DO accounting for the platform budgets the fabric spends:
3
+ * the Worker Loader's two caps, the facet-ID lifetime budget, and the
4
+ * dynamic-worker module-map ceiling.
5
+ *
6
+ * Measured on production workerd: a Durable Object admits ~5–6 concurrent
7
+ * dynamic workers before the platform refuses with "Too many concurrent
8
+ * dynamic workers", one DO method can drive at most 4 concurrent Loader
9
+ * fetches, and loader-cache entries are never released — every DISTINCT
10
+ * `loader.get(id)` permanently consumes one of the dynamic-worker slots for
11
+ * the object's lifetime. Nimbus stays under the caps by construction
12
+ * (`IN_DO_THRESHOLD` = 5 in the fanout pool), which until now meant the slots
13
+ * were counted in prose. This ledger counts them at the fabric's loader call
14
+ * sites instead — the loader pool's slots, a resident process's keyed worker,
15
+ * a one-shot's load — so proximity is measurable and a cap failure can name
16
+ * the ids actually holding slots.
17
+ *
18
+ * Measurement only: no admission control. The caps are the platform's, they
19
+ * are approximate ("~5–6"), and a gate on an approximate number would refuse
20
+ * work the platform would have run.
21
+ *
22
+ * Keyed weakly off the hosting actor's `ctx`, like the facet slot books: the
23
+ * caps are per Durable Object, and dynamic workers die with the isolate that
24
+ * loaded them, so a ledger that goes away with its host describes nothing
25
+ * that still exists.
26
+ */
27
+
28
+ import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
29
+
30
+ interface LoaderLedger {
31
+ /** Distinct loader ids ever gotten — each one a permanently consumed slot. */
32
+ ids: Set<string>;
33
+ /** In-flight calls into dynamic workers, right now. */
34
+ liveFetches: number;
35
+ /** The most that were ever in flight at once. */
36
+ peakLiveFetches: number;
37
+ }
38
+
39
+ const ledgers = new WeakMap<object, LoaderLedger>();
40
+
41
+ function ledger(ctx: object): LoaderLedger {
42
+ let entry = ledgers.get(ctx);
43
+ if (!entry) {
44
+ entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
45
+ ledgers.set(ctx, entry);
46
+ }
47
+ return entry;
48
+ }
49
+
50
+ /** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
51
+ export function recordLoaderId(ctx: object, id: string): void {
52
+ ledger(ctx).ids.add(id);
53
+ }
54
+
55
+ /**
56
+ * Count one call into a dynamic worker as a live Loader fetch; the returned
57
+ * function ends it (idempotently), from the caller's own `finally`.
58
+ *
59
+ * A begin/end pair rather than a wrapper on purpose, and the shape is
60
+ * load-bearing: wrapping the stub call in a ledger-owned async frame
61
+ * (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
62
+ * Durable Object poisoned after every pooled dispatch — the next fabric
63
+ * activity hung the object or reset the instance outright (pid base jumped,
64
+ * every attached WebSocket dropped with no close frame), measured 7/7 on
65
+ * staging and gone 3/3 with the direct call restored. Same seam-quirk class
66
+ * as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
67
+ * workers: an RPC stub call must stay a direct property call awaited by the
68
+ * frame that made it, so the ledger only brackets it.
69
+ */
70
+ export function beginLoaderFetch(ctx: object): () => void {
71
+ const entry = ledger(ctx);
72
+ entry.liveFetches++;
73
+ entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
74
+ let ended = false;
75
+ return () => {
76
+ if (ended) return;
77
+ ended = true;
78
+ entry.liveFetches--;
79
+ };
80
+ }
81
+
82
+ /** Snapshot for the diag surface. Pure read; no I/O. */
83
+ export function loaderLedgerStats(ctx: object): {
84
+ idsEverGotten: string[];
85
+ liveFetches: number;
86
+ peakLiveFetches: number;
87
+ } {
88
+ const entry = ledger(ctx);
89
+ return {
90
+ idsEverGotten: [...entry.ids],
91
+ liveFetches: entry.liveFetches,
92
+ peakLiveFetches: entry.peakLiveFetches,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Name the per-DO accounting on a "Too many concurrent dynamic workers"
98
+ * failure; hand every other error back untouched. The platform's message
99
+ * says only that the cap was hit — which ids hold the slots, and that a
100
+ * keyed id can never give one back, is what the operator needs to know to
101
+ * shrink anything.
102
+ */
103
+ export function withDynamicWorkerCapNamed<E>(ctx: object, error: E): E | Error {
104
+ if (classifyError(error) !== 'dynamic_worker_cap') return error;
105
+ const entry = ledger(ctx);
106
+ const platform = error instanceof Error ? error.message : String(error);
107
+ return new Error(
108
+ `${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
109
+ + `holding dynamic-worker slots (a loader.get id is never released): `
110
+ + `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
111
+ + `peak ${entry.peakLiveFetches}`,
112
+ { cause: error },
113
+ );
114
+ }
115
+
116
+ // ── Dynamic-worker module-map ceiling ───────────────────────────────────────
117
+
118
+ /**
119
+ * Total bytes a dynamic Worker's module map may carry, across every member of
120
+ * it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
121
+ * refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
122
+ * size of 67108864 bytes", confirmed at five sizes with two trials each. The
123
+ * budget is shared, so a ruby process is already 34.3 MiB down before its disk
124
+ * is counted.
125
+ */
126
+ export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
127
+
128
+ /**
129
+ * Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
130
+ * the largest members. The platform's own refusal reports one number for a
131
+ * budget shared across every member of the map, which tells the operator
132
+ * nothing about WHAT to shrink — so every fabric seam that assembles a map
133
+ * runs this before the loader sees it.
134
+ *
135
+ * Costed to its two paths. Under the ceiling: one length read per member —
136
+ * UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
137
+ * text the generators emit and undercount otherwise; the platform's own
138
+ * refusal still backstops the exotic case, because this check exists to name
139
+ * members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
140
+ * then, sorted so the biggest lever is first.
141
+ */
142
+ export function assertModuleMapWithinCodeLimit(modules: Record<string, unknown>): void {
143
+ let estimate = 0;
144
+ for (const content of Object.values(modules)) {
145
+ estimate += memberBytes(content, null);
146
+ }
147
+ if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES) return;
148
+
149
+ const encoder = new TextEncoder();
150
+ const sized = Object.entries(modules)
151
+ .map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
152
+ .sort((a, b) => b.bytes - a.bytes);
153
+ const total = sized.reduce((sum, member) => sum + member.bytes, 0);
154
+ const top = sized.slice(0, 5)
155
+ .map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
156
+ .join(', ');
157
+ throw new Error(
158
+ `Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
159
+ + `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
160
+ + `every member. Largest members: ${top}`,
161
+ );
162
+ }
163
+
164
+ /**
165
+ * Bytes one module-map member carries, across the loader's content kinds
166
+ * (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
167
+ * encoder, text is measured exactly; without one, by code-unit length.
168
+ */
169
+ function memberBytes(content: unknown, encoder: TextEncoder | null): number {
170
+ const textBytes = (text: string): number =>
171
+ encoder ? encoder.encode(text).byteLength : text.length;
172
+ if (typeof content === 'string') return textBytes(content);
173
+ if (content !== null && typeof content === 'object') {
174
+ for (const value of Object.values(content)) {
175
+ if (typeof value === 'string') return textBytes(value);
176
+ if (value instanceof ArrayBuffer) return value.byteLength;
177
+ if (ArrayBuffer.isView(value)) return value.byteLength;
178
+ }
179
+ }
180
+ return 0;
181
+ }
182
+
183
+ // ── Facet-ID lifetime budget ────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
187
+ * never reclaimed, so crossing it is unrecoverable for the object — which is
188
+ * why the ledger below counts consumption durably instead of leaving the
189
+ * bound as prose the slot book merely respects.
190
+ */
191
+ export const FACET_ID_LIFETIME_BUDGET = 65_536;
192
+
193
+ /** Where the ledger persists the count of facet names ever minted. */
194
+ export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
195
+
196
+ /** The slice of storage the facet-name ledger persists through. */
197
+ interface FacetNameLedgerStorage {
198
+ storage: {
199
+ get(key: string): Promise<unknown> | unknown;
200
+ put(key: string, value: unknown): Promise<void>;
201
+ };
202
+ }
203
+
204
+ /**
205
+ * One hosting actor's durable facet-name count, as an adopt-then-advance
206
+ * chain. It starts as the read of {@link FACET_NAME_HIGH_WATER_KEY} and
207
+ * every later link writes only a LARGER count — a fresh incarnation restarts
208
+ * its slot cursor at zero, and a write that had not adopted first would
209
+ * clobber the lifetime count down to this incarnation's. The chain never
210
+ * rejects.
211
+ */
212
+ interface FacetNameLedger {
213
+ chain: Promise<number>;
214
+ /** The largest count the chain has adopted or durably written. */
215
+ known: number;
216
+ /** The largest count ever recorded this incarnation, durable or not. */
217
+ minted: number;
218
+ }
219
+
220
+ const facetNameLedgers = new WeakMap<object, FacetNameLedger>();
221
+
222
+ function facetNameLedger(ctx: FacetNameLedgerStorage): FacetNameLedger {
223
+ let ledger = facetNameLedgers.get(ctx);
224
+ if (!ledger) {
225
+ const created: FacetNameLedger = { chain: Promise.resolve(0), known: 0, minted: 0 };
226
+ created.chain = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
227
+ .then((value) => (typeof value === 'number' ? value : 0))
228
+ .catch(() => 0)
229
+ .then((adopted) => {
230
+ created.known = Math.max(created.known, adopted);
231
+ return adopted;
232
+ });
233
+ ledger = created;
234
+ facetNameLedgers.set(ctx, ledger);
235
+ }
236
+ return ledger;
237
+ }
238
+
239
+ /**
240
+ * Advance the durable ledger to this incarnation's name count, if it is a new
241
+ * lifetime high. Chained behind adoption so the comparison is always against
242
+ * the real persisted value; a failed write leaves the old link's count and the
243
+ * next mint tries again — the ledger may transiently undercount, never over.
244
+ */
245
+ export function recordFacetNameMinted(ctx: FacetNameLedgerStorage, count: number): void {
246
+ const ledger = facetNameLedger(ctx);
247
+ ledger.minted = Math.max(ledger.minted, count);
248
+ ledger.chain = ledger.chain.then(async (durable) => {
249
+ if (count <= durable) return durable;
250
+ try {
251
+ await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
252
+ } catch {
253
+ return durable;
254
+ }
255
+ ledger.known = Math.max(ledger.known, count);
256
+ return count;
257
+ });
258
+ }
259
+
260
+ /** The best count available without awaiting storage: minted or adopted. */
261
+ export function facetNameCount(ctx: FacetNameLedgerStorage): number {
262
+ const ledger = facetNameLedger(ctx);
263
+ return Math.max(ledger.known, ledger.minted);
264
+ }
265
+
266
+ /** The count with adoption awaited, for a first failure on a fresh boot. */
267
+ export async function facetNameCountDurable(ctx: FacetNameLedgerStorage): Promise<number> {
268
+ const ledger = facetNameLedger(ctx);
269
+ const durable = await ledger.chain;
270
+ return Math.max(durable, ledger.minted);
271
+ }
272
+
273
+ /**
274
+ * The lifetime facet-ID ledger: how many facet names this fabric has ever
275
+ * minted on the Durable Object, against the 65,536 the platform will ever
276
+ * grant it. `consumed` only ever counts FIRST uses — a reused name, in this
277
+ * incarnation or any earlier one, cost no new ID, which is the slot book's
278
+ * whole reason to exist. Surfaced so an operator can see proximity to a wall
279
+ * whose crossing is unrecoverable, instead of discovering it from the
280
+ * platform's opaque failure.
281
+ */
282
+ export async function facetIdBudget(
283
+ ctx: FacetNameLedgerStorage,
284
+ ): Promise<{ consumed: number; budget: number }> {
285
+ return {
286
+ consumed: await facetNameCountDurable(ctx),
287
+ budget: FACET_ID_LIFETIME_BUDGET,
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Name the facet-ID budget on a creation failure at the wall; below it, hand
293
+ * the error back untouched. Exhaustion is the one failure here the platform
294
+ * reports opaquely AND that no teardown, retry or reset can undo, so the
295
+ * ledger — the only witness to the real cause — does the naming. Not a
296
+ * threshold: the comparison is against the budget itself.
297
+ */
298
+ export function withFacetBudgetNamed(consumed: number, error: unknown): unknown {
299
+ if (consumed < FACET_ID_LIFETIME_BUDGET) return error;
300
+ const platform = error instanceof Error ? error.message : String(error);
301
+ return new Error(
302
+ `Nimbus: facet creation failed with this Durable Object's `
303
+ + `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
304
+ + `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
305
+ + `so this failure is permanent for the object: ${platform}`,
306
+ { cause: error },
307
+ );
308
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * composition.ts — the ONE seam an embedder wires the fabric through.
3
+ *
4
+ * The fabric mints supervisor bindings and assembles staged boots for the
5
+ * programs it hosts, but the entrypoint class that answers those bindings and
6
+ * the artifact sources a stage names both belong to the embedder. The
7
+ * embedder states them once, in its composition root, with one call:
8
+ *
9
+ * composeFabric({
10
+ * supervisorEntrypoint: 'SupervisorRPC',
11
+ * stagedBootAssembler: (env, stage) => assembleConfig(env, stage),
12
+ * });
13
+ *
14
+ * First-write-wins, like every holder in this module: the composition root's
15
+ * module scope runs once per isolate, before any request.
16
+ *
17
+ * `ctx.exports` is runtime state, not composition: workerd mints it per
18
+ * instance, so the embedder captures it where the platform hands it over —
19
+ * the first fetch, or the DO constructor — with {@link adoptCtxExports}.
20
+ *
21
+ * This module stays a leaf (no fabric imports) so helpers (notably
22
+ * isolate-pool.ts) can read `ctx.exports` without transitively importing the
23
+ * Durable Object classes, which is what lets the pool be unit-tested in a
24
+ * plain Node/Bun process.
25
+ */
26
+
27
+ /**
28
+ * One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
29
+ * mints a Service Binding stub for that entrypoint when called with props.
30
+ *
31
+ * The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
32
+ * cannot see — `Cloudflare.Exports` is derived from the embedder's own main
33
+ * module, so for a library it evaluates to `{}`. A caller that knows the class
34
+ * names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
35
+ * that does not gets `unknown` and has to narrow, same as
36
+ * `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
37
+ */
38
+ export type EntrypointLoopbackFactory = <Stub = unknown>(options: { props: object }) => Stub;
39
+
40
+ /**
41
+ * `ctx.exports` itself — one factory per top-level entrypoint export, keyed by
42
+ * export name. Absent names read as undefined, which is how a caller finds out
43
+ * the embedder's entry module does not re-export the class it needs.
44
+ */
45
+ export type CtxExports = Record<string, EntrypointLoopbackFactory | undefined>;
46
+
47
+ /**
48
+ * Assemble a complete Worker Loader config from a staged-artifact spec. The
49
+ * embedder supplies this: a stage names artifact sources only the embedder
50
+ * knows how to fetch (Nimbus's largest staged artifact is a ~23 MB module map
51
+ * from ASSETS), and the assembler runs inside the loader's cache-miss callback
52
+ * so those sources are materialized only while the facet actually loads.
53
+ * `env` is whichever hosting actor's env the facet is opened with.
54
+ */
55
+ export type StagedBootAssembler = (
56
+ env: unknown,
57
+ stage: unknown,
58
+ ) => Promise<object>;
59
+
60
+ /** What an embedder states about itself, once, in its composition root. */
61
+ export interface FabricComposition {
62
+ /**
63
+ * The ctx.exports name of the embedder's supervisor WorkerEntrypoint. The
64
+ * fabric mints one supervisor binding per hosted program from it
65
+ * (`env.SUPERVISOR` inside the facet).
66
+ */
67
+ supervisorEntrypoint: string;
68
+ /** Only embedders that use 'staged' boot specs supply one. */
69
+ stagedBootAssembler?: StagedBootAssembler;
70
+ }
71
+
72
+ let _composition: FabricComposition | null = null;
73
+
74
+ /** Register the embedder's composition. First-write-wins. */
75
+ export function composeFabric(composition: FabricComposition): void {
76
+ if (_composition) return;
77
+ _composition = composition;
78
+ }
79
+
80
+ let _ctxExports: CtxExports | null = null;
81
+
82
+ /**
83
+ * Capture `ctx.exports` for the helpers that mint loopback bindings. The
84
+ * embedder calls this where the platform hands the bag over — the first
85
+ * fetch, or the DO constructor. First-write-wins.
86
+ */
87
+ export function adoptCtxExports(value: CtxExports): void {
88
+ if (_ctxExports) return;
89
+ _ctxExports = value;
90
+ }
91
+
92
+ export function getCtxExports(): CtxExports | null {
93
+ return _ctxExports;
94
+ }
95
+
96
+ /**
97
+ * Resolve the composed supervisor entrypoint on an exports object —
98
+ * `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
99
+ * the adopted ctx.exports otherwise. Calling the result with props mints one
100
+ * supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
101
+ * either half is missing; the caller decides whether that degrades or throws.
102
+ */
103
+ export function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null {
104
+ const exports = exportsObj ?? _ctxExports;
105
+ const name = _composition?.supervisorEntrypoint;
106
+ if (!name) return null;
107
+ if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null) return null;
108
+ const factory = (exports as Record<string, unknown>)[name];
109
+ return typeof factory === 'function' ? (factory as EntrypointLoopbackFactory) : null;
110
+ }
111
+
112
+ /** The composed name, for error messages that point at the missing export. */
113
+ export function supervisorEntrypointName(): string | null {
114
+ return _composition?.supervisorEntrypoint ?? null;
115
+ }
116
+
117
+ /** The composed assembler; a 'staged' boot spec cannot assemble without one. */
118
+ export function stagedBootAssembler(): StagedBootAssembler {
119
+ const assembler = _composition?.stagedBootAssembler;
120
+ if (!assembler) {
121
+ throw new Error(
122
+ 'fabric: no staged-boot assembler composed; a \'staged\' boot spec '
123
+ + 'cannot be assembled without one (composeFabric)',
124
+ );
125
+ }
126
+ return assembler;
127
+ }