@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
package/dist/budgets.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
28
|
+
const ledgers = new WeakMap();
|
|
29
|
+
function ledger(ctx) {
|
|
30
|
+
let entry = ledgers.get(ctx);
|
|
31
|
+
if (!entry) {
|
|
32
|
+
entry = { ids: new Set(), liveFetches: 0, peakLiveFetches: 0 };
|
|
33
|
+
ledgers.set(ctx, entry);
|
|
34
|
+
}
|
|
35
|
+
return entry;
|
|
36
|
+
}
|
|
37
|
+
/** Record a keyed `loader.get(id)` — a permanent slot if the id is new. */
|
|
38
|
+
export function recordLoaderId(ctx, id) {
|
|
39
|
+
ledger(ctx).ids.add(id);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Count one call into a dynamic worker as a live Loader fetch; the returned
|
|
43
|
+
* function ends it (idempotently), from the caller's own `finally`.
|
|
44
|
+
*
|
|
45
|
+
* A begin/end pair rather than a wrapper on purpose, and the shape is
|
|
46
|
+
* load-bearing: wrapping the stub call in a ledger-owned async frame
|
|
47
|
+
* (`trackLoaderFetch(ctx, () => entrypoint.execute(...))`) left the hosting
|
|
48
|
+
* Durable Object poisoned after every pooled dispatch — the next fabric
|
|
49
|
+
* activity hung the object or reset the instance outright (pid base jumped,
|
|
50
|
+
* every attached WebSocket dropped with no close frame), measured 7/7 on
|
|
51
|
+
* staging and gone 3/3 with the direct call restored. Same seam-quirk class
|
|
52
|
+
* as pipelined `fetch.call`, which workerd refuses for dynamically-loaded
|
|
53
|
+
* workers: an RPC stub call must stay a direct property call awaited by the
|
|
54
|
+
* frame that made it, so the ledger only brackets it.
|
|
55
|
+
*/
|
|
56
|
+
export function beginLoaderFetch(ctx) {
|
|
57
|
+
const entry = ledger(ctx);
|
|
58
|
+
entry.liveFetches++;
|
|
59
|
+
entry.peakLiveFetches = Math.max(entry.peakLiveFetches, entry.liveFetches);
|
|
60
|
+
let ended = false;
|
|
61
|
+
return () => {
|
|
62
|
+
if (ended)
|
|
63
|
+
return;
|
|
64
|
+
ended = true;
|
|
65
|
+
entry.liveFetches--;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Snapshot for the diag surface. Pure read; no I/O. */
|
|
69
|
+
export function loaderLedgerStats(ctx) {
|
|
70
|
+
const entry = ledger(ctx);
|
|
71
|
+
return {
|
|
72
|
+
idsEverGotten: [...entry.ids],
|
|
73
|
+
liveFetches: entry.liveFetches,
|
|
74
|
+
peakLiveFetches: entry.peakLiveFetches,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Name the per-DO accounting on a "Too many concurrent dynamic workers"
|
|
79
|
+
* failure; hand every other error back untouched. The platform's message
|
|
80
|
+
* says only that the cap was hit — which ids hold the slots, and that a
|
|
81
|
+
* keyed id can never give one back, is what the operator needs to know to
|
|
82
|
+
* shrink anything.
|
|
83
|
+
*/
|
|
84
|
+
export function withDynamicWorkerCapNamed(ctx, error) {
|
|
85
|
+
if (classifyError(error) !== 'dynamic_worker_cap')
|
|
86
|
+
return error;
|
|
87
|
+
const entry = ledger(ctx);
|
|
88
|
+
const platform = error instanceof Error ? error.message : String(error);
|
|
89
|
+
return new Error(`${platform} — this Durable Object has ${entry.ids.size} loader id(s) permanently `
|
|
90
|
+
+ `holding dynamic-worker slots (a loader.get id is never released): `
|
|
91
|
+
+ `${[...entry.ids].join(', ') || '(none recorded)'}; live Loader fetches ${entry.liveFetches}, `
|
|
92
|
+
+ `peak ${entry.peakLiveFetches}`, { cause: error });
|
|
93
|
+
}
|
|
94
|
+
// ── Dynamic-worker module-map ceiling ───────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Total bytes a dynamic Worker's module map may carry, across every member of
|
|
97
|
+
* it. A hard platform limit, not a policy knob: 62 MiB lands and 64 MiB is
|
|
98
|
+
* refused with "Dynamic Worker code size (N bytes) exceeds the maximum allowed
|
|
99
|
+
* size of 67108864 bytes", confirmed at five sizes with two trials each. The
|
|
100
|
+
* budget is shared, so a ruby process is already 34.3 MiB down before its disk
|
|
101
|
+
* is counted.
|
|
102
|
+
*/
|
|
103
|
+
export const DYNAMIC_WORKER_CODE_LIMIT_BYTES = 67_108_864;
|
|
104
|
+
/**
|
|
105
|
+
* Refuse a module map over {@link DYNAMIC_WORKER_CODE_LIMIT_BYTES}, naming
|
|
106
|
+
* the largest members. The platform's own refusal reports one number for a
|
|
107
|
+
* budget shared across every member of the map, which tells the operator
|
|
108
|
+
* nothing about WHAT to shrink — so every fabric seam that assembles a map
|
|
109
|
+
* runs this before the loader sees it.
|
|
110
|
+
*
|
|
111
|
+
* Costed to its two paths. Under the ceiling: one length read per member —
|
|
112
|
+
* UTF-16 code units for text, which equal UTF-8 bytes for the ASCII module
|
|
113
|
+
* text the generators emit and undercount otherwise; the platform's own
|
|
114
|
+
* refusal still backstops the exotic case, because this check exists to name
|
|
115
|
+
* members, not to be the ceiling. Over it: exact UTF-8 sizes, computed only
|
|
116
|
+
* then, sorted so the biggest lever is first.
|
|
117
|
+
*/
|
|
118
|
+
export function assertModuleMapWithinCodeLimit(modules) {
|
|
119
|
+
let estimate = 0;
|
|
120
|
+
for (const content of Object.values(modules)) {
|
|
121
|
+
estimate += memberBytes(content, null);
|
|
122
|
+
}
|
|
123
|
+
if (estimate <= DYNAMIC_WORKER_CODE_LIMIT_BYTES)
|
|
124
|
+
return;
|
|
125
|
+
const encoder = new TextEncoder();
|
|
126
|
+
const sized = Object.entries(modules)
|
|
127
|
+
.map(([name, content]) => ({ name, bytes: memberBytes(content, encoder) }))
|
|
128
|
+
.sort((a, b) => b.bytes - a.bytes);
|
|
129
|
+
const total = sized.reduce((sum, member) => sum + member.bytes, 0);
|
|
130
|
+
const top = sized.slice(0, 5)
|
|
131
|
+
.map(({ name, bytes }) => `'${name}' (${bytes.toLocaleString('en-US')} bytes)`)
|
|
132
|
+
.join(', ');
|
|
133
|
+
throw new Error(`Nimbus: dynamic-worker module map is ${total.toLocaleString('en-US')} bytes, over the `
|
|
134
|
+
+ `${DYNAMIC_WORKER_CODE_LIMIT_BYTES.toLocaleString('en-US')}-byte platform ceiling shared by `
|
|
135
|
+
+ `every member. Largest members: ${top}`);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Bytes one module-map member carries, across the loader's content kinds
|
|
139
|
+
* (plain string, `{ js | cjs | py | text }`, `{ wasm | data }`). With an
|
|
140
|
+
* encoder, text is measured exactly; without one, by code-unit length.
|
|
141
|
+
*/
|
|
142
|
+
function memberBytes(content, encoder) {
|
|
143
|
+
const textBytes = (text) => encoder ? encoder.encode(text).byteLength : text.length;
|
|
144
|
+
if (typeof content === 'string')
|
|
145
|
+
return textBytes(content);
|
|
146
|
+
if (content !== null && typeof content === 'object') {
|
|
147
|
+
for (const value of Object.values(content)) {
|
|
148
|
+
if (typeof value === 'string')
|
|
149
|
+
return textBytes(value);
|
|
150
|
+
if (value instanceof ArrayBuffer)
|
|
151
|
+
return value.byteLength;
|
|
152
|
+
if (ArrayBuffer.isView(value))
|
|
153
|
+
return value.byteLength;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
// ── Facet-ID lifetime budget ────────────────────────────────────────────────
|
|
159
|
+
/**
|
|
160
|
+
* Facet IDs a Durable Object is granted over its LIFETIME. Append-only and
|
|
161
|
+
* never reclaimed, so crossing it is unrecoverable for the object — which is
|
|
162
|
+
* why the ledger below counts consumption durably instead of leaving the
|
|
163
|
+
* bound as prose the slot book merely respects.
|
|
164
|
+
*/
|
|
165
|
+
export const FACET_ID_LIFETIME_BUDGET = 65_536;
|
|
166
|
+
/** Where the ledger persists the count of facet names ever minted. */
|
|
167
|
+
export const FACET_NAME_HIGH_WATER_KEY = 'fabric_facet_name_high_water';
|
|
168
|
+
const facetNameLedgers = new WeakMap();
|
|
169
|
+
function facetNameLedger(ctx) {
|
|
170
|
+
let ledger = facetNameLedgers.get(ctx);
|
|
171
|
+
if (!ledger) {
|
|
172
|
+
const created = { chain: Promise.resolve(0), known: 0, minted: 0 };
|
|
173
|
+
created.chain = Promise.resolve(ctx.storage.get(FACET_NAME_HIGH_WATER_KEY))
|
|
174
|
+
.then((value) => (typeof value === 'number' ? value : 0))
|
|
175
|
+
.catch(() => 0)
|
|
176
|
+
.then((adopted) => {
|
|
177
|
+
created.known = Math.max(created.known, adopted);
|
|
178
|
+
return adopted;
|
|
179
|
+
});
|
|
180
|
+
ledger = created;
|
|
181
|
+
facetNameLedgers.set(ctx, ledger);
|
|
182
|
+
}
|
|
183
|
+
return ledger;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Advance the durable ledger to this incarnation's name count, if it is a new
|
|
187
|
+
* lifetime high. Chained behind adoption so the comparison is always against
|
|
188
|
+
* the real persisted value; a failed write leaves the old link's count and the
|
|
189
|
+
* next mint tries again — the ledger may transiently undercount, never over.
|
|
190
|
+
*/
|
|
191
|
+
export function recordFacetNameMinted(ctx, count) {
|
|
192
|
+
const ledger = facetNameLedger(ctx);
|
|
193
|
+
ledger.minted = Math.max(ledger.minted, count);
|
|
194
|
+
ledger.chain = ledger.chain.then(async (durable) => {
|
|
195
|
+
if (count <= durable)
|
|
196
|
+
return durable;
|
|
197
|
+
try {
|
|
198
|
+
await ctx.storage.put(FACET_NAME_HIGH_WATER_KEY, count);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return durable;
|
|
202
|
+
}
|
|
203
|
+
ledger.known = Math.max(ledger.known, count);
|
|
204
|
+
return count;
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** The best count available without awaiting storage: minted or adopted. */
|
|
208
|
+
export function facetNameCount(ctx) {
|
|
209
|
+
const ledger = facetNameLedger(ctx);
|
|
210
|
+
return Math.max(ledger.known, ledger.minted);
|
|
211
|
+
}
|
|
212
|
+
/** The count with adoption awaited, for a first failure on a fresh boot. */
|
|
213
|
+
export async function facetNameCountDurable(ctx) {
|
|
214
|
+
const ledger = facetNameLedger(ctx);
|
|
215
|
+
const durable = await ledger.chain;
|
|
216
|
+
return Math.max(durable, ledger.minted);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The lifetime facet-ID ledger: how many facet names this fabric has ever
|
|
220
|
+
* minted on the Durable Object, against the 65,536 the platform will ever
|
|
221
|
+
* grant it. `consumed` only ever counts FIRST uses — a reused name, in this
|
|
222
|
+
* incarnation or any earlier one, cost no new ID, which is the slot book's
|
|
223
|
+
* whole reason to exist. Surfaced so an operator can see proximity to a wall
|
|
224
|
+
* whose crossing is unrecoverable, instead of discovering it from the
|
|
225
|
+
* platform's opaque failure.
|
|
226
|
+
*/
|
|
227
|
+
export async function facetIdBudget(ctx) {
|
|
228
|
+
return {
|
|
229
|
+
consumed: await facetNameCountDurable(ctx),
|
|
230
|
+
budget: FACET_ID_LIFETIME_BUDGET,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Name the facet-ID budget on a creation failure at the wall; below it, hand
|
|
235
|
+
* the error back untouched. Exhaustion is the one failure here the platform
|
|
236
|
+
* reports opaquely AND that no teardown, retry or reset can undo, so the
|
|
237
|
+
* ledger — the only witness to the real cause — does the naming. Not a
|
|
238
|
+
* threshold: the comparison is against the budget itself.
|
|
239
|
+
*/
|
|
240
|
+
export function withFacetBudgetNamed(consumed, error) {
|
|
241
|
+
if (consumed < FACET_ID_LIFETIME_BUDGET)
|
|
242
|
+
return error;
|
|
243
|
+
const platform = error instanceof Error ? error.message : String(error);
|
|
244
|
+
return new Error(`Nimbus: facet creation failed with this Durable Object's `
|
|
245
|
+
+ `${FACET_ID_LIFETIME_BUDGET.toLocaleString('en-US')} facet-ID lifetime budget consumed `
|
|
246
|
+
+ `(${consumed} facet names ever created). Facet IDs are append-only and never reclaimed, `
|
|
247
|
+
+ `so this failure is permanent for the object: ${platform}`, { cause: error });
|
|
248
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
* One entry of `ctx.exports`: a top-level entrypoint's loopback factory, which
|
|
28
|
+
* mints a Service Binding stub for that entrypoint when called with props.
|
|
29
|
+
*
|
|
30
|
+
* The stub's RPC surface belongs to the entrypoint CLASS, which this leaf
|
|
31
|
+
* cannot see — `Cloudflare.Exports` is derived from the embedder's own main
|
|
32
|
+
* module, so for a library it evaluates to `{}`. A caller that knows the class
|
|
33
|
+
* names the surface it expects (`factory<MySupervisorRpc>({ props })`); one
|
|
34
|
+
* that does not gets `unknown` and has to narrow, same as
|
|
35
|
+
* `DurableObjectNamespace<T>` and `RpcStub<T>` in @cloudflare/workers-types.
|
|
36
|
+
*/
|
|
37
|
+
export type EntrypointLoopbackFactory = <Stub = unknown>(options: {
|
|
38
|
+
props: object;
|
|
39
|
+
}) => Stub;
|
|
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
|
+
* Assemble a complete Worker Loader config from a staged-artifact spec. The
|
|
48
|
+
* embedder supplies this: a stage names artifact sources only the embedder
|
|
49
|
+
* knows how to fetch (Nimbus's largest staged artifact is a ~23 MB module map
|
|
50
|
+
* from ASSETS), and the assembler runs inside the loader's cache-miss callback
|
|
51
|
+
* so those sources are materialized only while the facet actually loads.
|
|
52
|
+
* `env` is whichever hosting actor's env the facet is opened with.
|
|
53
|
+
*/
|
|
54
|
+
export type StagedBootAssembler = (env: unknown, stage: unknown) => Promise<object>;
|
|
55
|
+
/** What an embedder states about itself, once, in its composition root. */
|
|
56
|
+
export interface FabricComposition {
|
|
57
|
+
/**
|
|
58
|
+
* The ctx.exports name of the embedder's supervisor WorkerEntrypoint. The
|
|
59
|
+
* fabric mints one supervisor binding per hosted program from it
|
|
60
|
+
* (`env.SUPERVISOR` inside the facet).
|
|
61
|
+
*/
|
|
62
|
+
supervisorEntrypoint: string;
|
|
63
|
+
/** Only embedders that use 'staged' boot specs supply one. */
|
|
64
|
+
stagedBootAssembler?: StagedBootAssembler;
|
|
65
|
+
}
|
|
66
|
+
/** Register the embedder's composition. First-write-wins. */
|
|
67
|
+
export declare function composeFabric(composition: FabricComposition): void;
|
|
68
|
+
/**
|
|
69
|
+
* Capture `ctx.exports` for the helpers that mint loopback bindings. The
|
|
70
|
+
* embedder calls this where the platform hands the bag over — the first
|
|
71
|
+
* fetch, or the DO constructor. First-write-wins.
|
|
72
|
+
*/
|
|
73
|
+
export declare function adoptCtxExports(value: CtxExports): void;
|
|
74
|
+
export declare function getCtxExports(): CtxExports | null;
|
|
75
|
+
/**
|
|
76
|
+
* Resolve the composed supervisor entrypoint on an exports object —
|
|
77
|
+
* `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
|
|
78
|
+
* the adopted ctx.exports otherwise. Calling the result with props mints one
|
|
79
|
+
* supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
|
|
80
|
+
* either half is missing; the caller decides whether that degrades or throws.
|
|
81
|
+
*/
|
|
82
|
+
export declare function supervisorEntrypoint(exportsObj?: unknown): EntrypointLoopbackFactory | null;
|
|
83
|
+
/** The composed name, for error messages that point at the missing export. */
|
|
84
|
+
export declare function supervisorEntrypointName(): string | null;
|
|
85
|
+
/** The composed assembler; a 'staged' boot spec cannot assemble without one. */
|
|
86
|
+
export declare function stagedBootAssembler(): StagedBootAssembler;
|
|
87
|
+
//# sourceMappingURL=composition.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"composition.d.ts","sourceRoot":"","sources":["../src/composition.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH;;;;;;;;;;GAUG;AACH,MAAM,MAAM,yBAAyB,GAAG,CAAC,IAAI,GAAG,OAAO,EAAE,OAAO,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,KAAK,IAAI,CAAC;AAE7F;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,yBAAyB,GAAG,SAAS,CAAC,CAAC;AAE/E;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAAG,CAChC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,OAAO,KACX,OAAO,CAAC,MAAM,CAAC,CAAC;AAErB,2EAA2E;AAC3E,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,oBAAoB,EAAE,MAAM,CAAC;IAC7B,8DAA8D;IAC9D,mBAAmB,CAAC,EAAE,mBAAmB,CAAC;CAC3C;AAID,6DAA6D;AAC7D,wBAAgB,aAAa,CAAC,WAAW,EAAE,iBAAiB,GAAG,IAAI,CAGlE;AAID;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,CAGvD;AAED,wBAAgB,aAAa,IAAI,UAAU,GAAG,IAAI,CAEjD;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,CAAC,EAAE,OAAO,GAAG,yBAAyB,GAAG,IAAI,CAO3F;AAED,8EAA8E;AAC9E,wBAAgB,wBAAwB,IAAI,MAAM,GAAG,IAAI,CAExD;AAED,gFAAgF;AAChF,wBAAgB,mBAAmB,IAAI,mBAAmB,CASzD"}
|
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
let _composition = null;
|
|
27
|
+
/** Register the embedder's composition. First-write-wins. */
|
|
28
|
+
export function composeFabric(composition) {
|
|
29
|
+
if (_composition)
|
|
30
|
+
return;
|
|
31
|
+
_composition = composition;
|
|
32
|
+
}
|
|
33
|
+
let _ctxExports = null;
|
|
34
|
+
/**
|
|
35
|
+
* Capture `ctx.exports` for the helpers that mint loopback bindings. The
|
|
36
|
+
* embedder calls this where the platform hands the bag over — the first
|
|
37
|
+
* fetch, or the DO constructor. First-write-wins.
|
|
38
|
+
*/
|
|
39
|
+
export function adoptCtxExports(value) {
|
|
40
|
+
if (_ctxExports)
|
|
41
|
+
return;
|
|
42
|
+
_ctxExports = value;
|
|
43
|
+
}
|
|
44
|
+
export function getCtxExports() {
|
|
45
|
+
return _ctxExports;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the composed supervisor entrypoint on an exports object —
|
|
49
|
+
* `exportsObj` when given (a WorkerEntrypoint reads its own ctx.exports),
|
|
50
|
+
* the adopted ctx.exports otherwise. Calling the result with props mints one
|
|
51
|
+
* supervisor binding (`env.SUPERVISOR`) for one hosted program. Null when
|
|
52
|
+
* either half is missing; the caller decides whether that degrades or throws.
|
|
53
|
+
*/
|
|
54
|
+
export function supervisorEntrypoint(exportsObj) {
|
|
55
|
+
const exports = exportsObj ?? _ctxExports;
|
|
56
|
+
const name = _composition?.supervisorEntrypoint;
|
|
57
|
+
if (!name)
|
|
58
|
+
return null;
|
|
59
|
+
if ((typeof exports !== 'object' && typeof exports !== 'function') || exports === null)
|
|
60
|
+
return null;
|
|
61
|
+
const factory = exports[name];
|
|
62
|
+
return typeof factory === 'function' ? factory : null;
|
|
63
|
+
}
|
|
64
|
+
/** The composed name, for error messages that point at the missing export. */
|
|
65
|
+
export function supervisorEntrypointName() {
|
|
66
|
+
return _composition?.supervisorEntrypoint ?? null;
|
|
67
|
+
}
|
|
68
|
+
/** The composed assembler; a 'staged' boot spec cannot assemble without one. */
|
|
69
|
+
export function stagedBootAssembler() {
|
|
70
|
+
const assembler = _composition?.stagedBootAssembler;
|
|
71
|
+
if (!assembler) {
|
|
72
|
+
throw new Error('fabric: no staged-boot assembler composed; a \'staged\' boot spec '
|
|
73
|
+
+ 'cannot be assembled without one (composeFabric)');
|
|
74
|
+
}
|
|
75
|
+
return assembler;
|
|
76
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connections.ts — typed, validated, hibernation-durable per-connection
|
|
3
|
+
* state over the WebSocket attachment.
|
|
4
|
+
*
|
|
5
|
+
* Specified from Proteus's DeviceSocketHub (`cf-backend/src/user/device-hub.ts`)
|
|
6
|
+
* and CLI rpc gate (`cf-backend/src/cli/rpc-gate.ts`), which split the
|
|
7
|
+
* pattern into its two halves:
|
|
8
|
+
* - a TAG is the immutable-at-accept lookup key and authorization — it
|
|
9
|
+
* rides the hibernation state, which is why the rpc gate persists auth
|
|
10
|
+
* scopes as a tag: "an in-memory allowlist would silently widen to full
|
|
11
|
+
* access on wake". That is a security property, and it is why this
|
|
12
|
+
* module keeps NO in-memory mirror: `ctx.getWebSockets` is the only
|
|
13
|
+
* source of truth for liveness, re-derived on every read.
|
|
14
|
+
* - the ATTACHMENT is the mutable per-connection payload. It outlives the
|
|
15
|
+
* code that wrote it — it survives deploys — so what a previous version
|
|
16
|
+
* wrote is untrusted input, and every read is schema-validated, never
|
|
17
|
+
* cast (device-hub.ts:49-53).
|
|
18
|
+
*
|
|
19
|
+
* Two platform facts the wrapper enforces where the consumer only documents:
|
|
20
|
+
* - attachments are STRUCTURED-CLONED, not JSON-encoded: a Set survives as
|
|
21
|
+
* a Set and silently fails an array schema on read (proven by Proteus's
|
|
22
|
+
* workerd test, do-socket-attachment.test.ts). Validating on WRITE turns
|
|
23
|
+
* that silent read-side null into a loud write-side error.
|
|
24
|
+
* - the bound is {@link WS_ATTACHMENT_LIMIT_BYTES} (16,384) on the
|
|
25
|
+
* SERIALIZED bytes — workerd re-serializes on every set to check it
|
|
26
|
+
* (web-socket.h, MAX_ATTACHMENT_SIZE). A JSON-length measurement is an
|
|
27
|
+
* approximation, so this module never pre-refuses on it; it lets the
|
|
28
|
+
* platform be the ceiling and NAMES the failure honestly when it trips.
|
|
29
|
+
*
|
|
30
|
+
* Reconnect replaces: accepting a socket under a key closes every open
|
|
31
|
+
* socket already holding that key (1000, 'replaced by a new connection'),
|
|
32
|
+
* exactly as the device hub does.
|
|
33
|
+
*/
|
|
34
|
+
import { z } from 'zod/v4';
|
|
35
|
+
/** A hibernatable WebSocket, as this module drives it. */
|
|
36
|
+
export interface ConnectionSocket {
|
|
37
|
+
readyState: number;
|
|
38
|
+
close(code?: number, reason?: string): void;
|
|
39
|
+
serializeAttachment(value: unknown): void;
|
|
40
|
+
deserializeAttachment(): unknown;
|
|
41
|
+
}
|
|
42
|
+
/** The hosting actor's hibernation surface. */
|
|
43
|
+
export interface ConnectionsContext {
|
|
44
|
+
acceptWebSocket(ws: ConnectionSocket, tags?: string[]): void;
|
|
45
|
+
getWebSockets(tag?: string): ConnectionSocket[];
|
|
46
|
+
getTags(ws: ConnectionSocket): string[];
|
|
47
|
+
}
|
|
48
|
+
/** The per-connection hub of one hosting actor. Cheap accessor; it holds no
|
|
49
|
+
* state of its own, which is the point. */
|
|
50
|
+
export declare function connections<T>(ctx: ConnectionsContext, schema: z.ZodType<T>): Connections<T>;
|
|
51
|
+
export declare class Connections<T> {
|
|
52
|
+
private readonly ctx;
|
|
53
|
+
private readonly schema;
|
|
54
|
+
constructor(ctx: ConnectionsContext, schema: z.ZodType<T>);
|
|
55
|
+
/**
|
|
56
|
+
* Accept a socket under an identity key, replacing whatever already holds
|
|
57
|
+
* it. The attachment validates BEFORE anything else happens, so a rejected
|
|
58
|
+
* newcomer cannot evict the live connection it failed to replace.
|
|
59
|
+
*/
|
|
60
|
+
accept(ws: ConnectionSocket, opts: {
|
|
61
|
+
key: string;
|
|
62
|
+
tags?: string[];
|
|
63
|
+
attachment: T;
|
|
64
|
+
}): void;
|
|
65
|
+
/** The open socket holding a tag, re-derived from the platform. */
|
|
66
|
+
get(tag: string): ConnectionSocket | null;
|
|
67
|
+
/** Every open socket (optionally: holding a tag). */
|
|
68
|
+
list(tag?: string): ConnectionSocket[];
|
|
69
|
+
/** A socket's tags — identity key first, then whatever accept added. */
|
|
70
|
+
tags(ws: ConnectionSocket): string[];
|
|
71
|
+
/**
|
|
72
|
+
* The attachment, validated. Null when it does not parse — an attachment
|
|
73
|
+
* written by a previous deploy is untrusted input, and a null is honest
|
|
74
|
+
* where a cast would be a lie.
|
|
75
|
+
*/
|
|
76
|
+
read(ws: ConnectionSocket): T | null;
|
|
77
|
+
/** Replace the attachment, validated on the way in. */
|
|
78
|
+
write(ws: ConnectionSocket, attachment: T): void;
|
|
79
|
+
private writeSerialized;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=connections.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connections.d.ts","sourceRoot":"","sources":["../src/connections.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,QAAQ,CAAC;AAK3B,0DAA0D;AAC1D,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,IAAI,CAAC;IAC1C,qBAAqB,IAAI,OAAO,CAAC;CAClC;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,eAAe,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IAC7D,aAAa,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAAC;IAChD,OAAO,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE,CAAC;CACzC;AAED;4CAC4C;AAC5C,wBAAgB,WAAW,CAAC,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAE5F;AAED,qBAAa,WAAW,CAAC,CAAC;IAEtB,OAAO,CAAC,QAAQ,CAAC,GAAG;IACpB,OAAO,CAAC,QAAQ,CAAC,MAAM;gBADN,GAAG,EAAE,kBAAkB,EACvB,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC;IAGvC;;;;OAIG;IACH,MAAM,CAAC,EAAE,EAAE,gBAAgB,EAAE,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;QAAC,UAAU,EAAE,CAAC,CAAA;KAAE,GAAG,IAAI;IASzF,mEAAmE;IACnE,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,gBAAgB,GAAG,IAAI;IAOzC,qDAAqD;IACrD,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,gBAAgB,EAAE;IAItC,wEAAwE;IACxE,IAAI,CAAC,EAAE,EAAE,gBAAgB,GAAG,MAAM,EAAE;IAIpC;;;;OAIG;IACH,IAAI,CAAC,EAAE,EAAE,gBAAgB,GAAG,CAAC,GAAG,IAAI;IAKpC,uDAAuD;IACvD,KAAK,CAAC,EAAE,EAAE,gBAAgB,EAAE,UAAU,EAAE,CAAC,GAAG,IAAI;IAIhD,OAAO,CAAC,eAAe;CAcxB"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* connections.ts — typed, validated, hibernation-durable per-connection
|
|
3
|
+
* state over the WebSocket attachment.
|
|
4
|
+
*
|
|
5
|
+
* Specified from Proteus's DeviceSocketHub (`cf-backend/src/user/device-hub.ts`)
|
|
6
|
+
* and CLI rpc gate (`cf-backend/src/cli/rpc-gate.ts`), which split the
|
|
7
|
+
* pattern into its two halves:
|
|
8
|
+
* - a TAG is the immutable-at-accept lookup key and authorization — it
|
|
9
|
+
* rides the hibernation state, which is why the rpc gate persists auth
|
|
10
|
+
* scopes as a tag: "an in-memory allowlist would silently widen to full
|
|
11
|
+
* access on wake". That is a security property, and it is why this
|
|
12
|
+
* module keeps NO in-memory mirror: `ctx.getWebSockets` is the only
|
|
13
|
+
* source of truth for liveness, re-derived on every read.
|
|
14
|
+
* - the ATTACHMENT is the mutable per-connection payload. It outlives the
|
|
15
|
+
* code that wrote it — it survives deploys — so what a previous version
|
|
16
|
+
* wrote is untrusted input, and every read is schema-validated, never
|
|
17
|
+
* cast (device-hub.ts:49-53).
|
|
18
|
+
*
|
|
19
|
+
* Two platform facts the wrapper enforces where the consumer only documents:
|
|
20
|
+
* - attachments are STRUCTURED-CLONED, not JSON-encoded: a Set survives as
|
|
21
|
+
* a Set and silently fails an array schema on read (proven by Proteus's
|
|
22
|
+
* workerd test, do-socket-attachment.test.ts). Validating on WRITE turns
|
|
23
|
+
* that silent read-side null into a loud write-side error.
|
|
24
|
+
* - the bound is {@link WS_ATTACHMENT_LIMIT_BYTES} (16,384) on the
|
|
25
|
+
* SERIALIZED bytes — workerd re-serializes on every set to check it
|
|
26
|
+
* (web-socket.h, MAX_ATTACHMENT_SIZE). A JSON-length measurement is an
|
|
27
|
+
* approximation, so this module never pre-refuses on it; it lets the
|
|
28
|
+
* platform be the ceiling and NAMES the failure honestly when it trips.
|
|
29
|
+
*
|
|
30
|
+
* Reconnect replaces: accepting a socket under a key closes every open
|
|
31
|
+
* socket already holding that key (1000, 'replaced by a new connection'),
|
|
32
|
+
* exactly as the device hub does.
|
|
33
|
+
*/
|
|
34
|
+
import { WS_ATTACHMENT_LIMIT_BYTES } from '@nimbus-sh/platform/limits.js';
|
|
35
|
+
const WS_OPEN = 1;
|
|
36
|
+
/** The per-connection hub of one hosting actor. Cheap accessor; it holds no
|
|
37
|
+
* state of its own, which is the point. */
|
|
38
|
+
export function connections(ctx, schema) {
|
|
39
|
+
return new Connections(ctx, schema);
|
|
40
|
+
}
|
|
41
|
+
export class Connections {
|
|
42
|
+
ctx;
|
|
43
|
+
schema;
|
|
44
|
+
constructor(ctx, schema) {
|
|
45
|
+
this.ctx = ctx;
|
|
46
|
+
this.schema = schema;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Accept a socket under an identity key, replacing whatever already holds
|
|
50
|
+
* it. The attachment validates BEFORE anything else happens, so a rejected
|
|
51
|
+
* newcomer cannot evict the live connection it failed to replace.
|
|
52
|
+
*/
|
|
53
|
+
accept(ws, opts) {
|
|
54
|
+
const validated = this.schema.parse(opts.attachment);
|
|
55
|
+
for (const old of this.ctx.getWebSockets(opts.key)) {
|
|
56
|
+
if (old.readyState === WS_OPEN)
|
|
57
|
+
old.close(1000, 'replaced by a new connection');
|
|
58
|
+
}
|
|
59
|
+
this.ctx.acceptWebSocket(ws, [opts.key, ...(opts.tags ?? [])]);
|
|
60
|
+
this.writeSerialized(ws, validated);
|
|
61
|
+
}
|
|
62
|
+
/** The open socket holding a tag, re-derived from the platform. */
|
|
63
|
+
get(tag) {
|
|
64
|
+
for (const ws of this.ctx.getWebSockets(tag)) {
|
|
65
|
+
if (ws.readyState === WS_OPEN)
|
|
66
|
+
return ws;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** Every open socket (optionally: holding a tag). */
|
|
71
|
+
list(tag) {
|
|
72
|
+
return this.ctx.getWebSockets(tag).filter((ws) => ws.readyState === WS_OPEN);
|
|
73
|
+
}
|
|
74
|
+
/** A socket's tags — identity key first, then whatever accept added. */
|
|
75
|
+
tags(ws) {
|
|
76
|
+
return this.ctx.getTags(ws);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The attachment, validated. Null when it does not parse — an attachment
|
|
80
|
+
* written by a previous deploy is untrusted input, and a null is honest
|
|
81
|
+
* where a cast would be a lie.
|
|
82
|
+
*/
|
|
83
|
+
read(ws) {
|
|
84
|
+
const parsed = this.schema.safeParse(ws.deserializeAttachment());
|
|
85
|
+
return parsed.success ? parsed.data : null;
|
|
86
|
+
}
|
|
87
|
+
/** Replace the attachment, validated on the way in. */
|
|
88
|
+
write(ws, attachment) {
|
|
89
|
+
this.writeSerialized(ws, this.schema.parse(attachment));
|
|
90
|
+
}
|
|
91
|
+
writeSerialized(ws, validated) {
|
|
92
|
+
try {
|
|
93
|
+
ws.serializeAttachment(validated);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
const approx = jsonLength(validated);
|
|
97
|
+
throw new Error(`fabric: WebSocket attachment refused by the platform — the bound is `
|
|
98
|
+
+ `${WS_ATTACHMENT_LIMIT_BYTES.toLocaleString('en-US')} bytes of SERIALIZED attachment `
|
|
99
|
+
+ `(workerd MAX_ATTACHMENT_SIZE), and this value is ~${approx.toLocaleString('en-US')} bytes `
|
|
100
|
+
+ `as JSON text (an approximation; the serialized form is what counts): ${errorText(e)}`, { cause: e });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function jsonLength(value) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.stringify(value)?.length ?? 0;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function errorText(error) {
|
|
113
|
+
return error instanceof Error ? error.message : String(error);
|
|
114
|
+
}
|