@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.
- package/README.md +84 -55
- package/dist/bindings.js +5 -5
- package/dist/budgets.d.ts +132 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +248 -0
- package/dist/composition.d.ts +87 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +76 -0
- package/dist/connections.d.ts +81 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +114 -0
- package/dist/derived.d.ts +65 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +95 -0
- package/dist/do-calls.d.ts +94 -0
- package/dist/do-calls.d.ts.map +1 -0
- package/dist/do-calls.js +111 -0
- package/dist/facet-pool.d.ts +90 -0
- package/dist/facet-pool.d.ts.map +1 -0
- package/dist/facet-pool.js +113 -0
- package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
- package/dist/fanout.d.ts.map +1 -0
- package/dist/{fanout-pool.js → fanout.js} +20 -20
- package/dist/{launch-journal.d.ts → fenced-work.d.ts} +25 -13
- package/dist/fenced-work.d.ts.map +1 -0
- package/dist/{launch-journal.js → fenced-work.js} +47 -13
- package/dist/generation.d.ts +69 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +118 -0
- package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
- package/dist/image-store.d.ts.map +1 -0
- package/dist/{facet-image-store.js → image-store.js} +4 -4
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -8
- package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
- package/dist/isolate-pool.d.ts.map +1 -0
- package/dist/{loader-pool.js → isolate-pool.js} +20 -20
- package/dist/journal.d.ts +111 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +177 -0
- package/dist/outbox.d.ts +249 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/process-fabric.d.ts +2 -14
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +6 -15
- package/dist/process-host.d.ts +1 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +10 -9
- package/dist/sealed.d.ts +78 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +145 -0
- package/dist/timers.d.ts +138 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +231 -0
- package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +19 -21
- package/dist/turn-budget.d.ts.map +1 -0
- package/dist/{launch-pacer.js → turn-budget.js} +22 -11
- package/dist/workerd-facet-host.d.ts +28 -67
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +49 -171
- package/examples/agent-core-adapter.ts +191 -0
- package/package.json +4 -2
- package/src/bindings.ts +6 -6
- package/src/budgets.ts +308 -0
- package/src/composition.ts +127 -0
- package/src/connections.ts +140 -0
- package/src/derived.ts +135 -0
- package/src/do-calls.ts +156 -0
- package/src/facet-pool.ts +157 -0
- package/src/{fanout-pool.ts → fanout.ts} +35 -35
- package/src/{launch-journal.ts → fenced-work.ts} +58 -22
- package/src/generation.ts +144 -0
- package/src/{facet-image-store.ts → image-store.ts} +9 -9
- package/src/index.ts +16 -8
- package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
- package/src/journal.ts +242 -0
- package/src/node-async-hooks.d.ts +14 -0
- package/src/outbox.ts +520 -0
- package/src/process-fabric.ts +6 -33
- package/src/process-host.ts +10 -15
- package/src/sealed.ts +150 -0
- package/src/timers.ts +294 -0
- package/src/{launch-pacer.ts → turn-budget.ts} +30 -24
- package/src/workerd-facet-host.ts +67 -193
- package/dist/alarms.d.ts +0 -134
- package/dist/alarms.d.ts.map +0 -1
- package/dist/alarms.js +0 -214
- package/dist/ctx-exports.d.ts +0 -47
- package/dist/ctx-exports.d.ts.map +0 -1
- package/dist/ctx-exports.js +0 -54
- package/dist/facet-image-store.d.ts.map +0 -1
- package/dist/fanout-pool.d.ts.map +0 -1
- package/dist/launch-journal.d.ts.map +0 -1
- package/dist/launch-pacer.d.ts.map +0 -1
- package/dist/loader-ledger.d.ts +0 -57
- package/dist/loader-ledger.d.ts.map +0 -1
- package/dist/loader-ledger.js +0 -91
- package/dist/loader-pool.d.ts.map +0 -1
- package/src/alarms.ts +0 -275
- package/src/ctx-exports.ts +0 -77
- 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.
|
|
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.
|
|
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/
|
|
29
|
-
import { supervisorEntrypoint, supervisorEntrypointName } from './
|
|
30
|
-
import {
|
|
31
|
-
import { assertModuleMapWithinCodeLimit } from './
|
|
32
|
-
import type { EntrypointLoopbackFactory } from './
|
|
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
|
|
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
|
+
}
|