@nimbus-sh/fabric 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +487 -0
- package/dist/alarms.d.ts +134 -0
- package/dist/alarms.d.ts.map +1 -0
- package/dist/alarms.js +214 -0
- package/dist/bindings.d.ts +316 -0
- package/dist/bindings.d.ts.map +1 -0
- package/dist/bindings.js +678 -0
- package/dist/ctx-exports.d.ts +47 -0
- package/dist/ctx-exports.d.ts.map +1 -0
- package/dist/ctx-exports.js +54 -0
- package/dist/facet-image-store.d.ts +112 -0
- package/dist/facet-image-store.d.ts.map +1 -0
- package/dist/facet-image-store.js +181 -0
- package/dist/fanout-pool.d.ts +223 -0
- package/dist/fanout-pool.d.ts.map +1 -0
- package/dist/fanout-pool.js +368 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/inner-do-registry.d.ts +41 -0
- package/dist/inner-do-registry.d.ts.map +1 -0
- package/dist/inner-do-registry.js +51 -0
- package/dist/launch-journal.d.ts +170 -0
- package/dist/launch-journal.d.ts.map +1 -0
- package/dist/launch-journal.js +154 -0
- package/dist/launch-pacer.d.ts +173 -0
- package/dist/launch-pacer.d.ts.map +1 -0
- package/dist/launch-pacer.js +193 -0
- package/dist/loader-ledger.d.ts +57 -0
- package/dist/loader-ledger.d.ts.map +1 -0
- package/dist/loader-ledger.js +91 -0
- package/dist/loader-pool.d.ts +315 -0
- package/dist/loader-pool.d.ts.map +1 -0
- package/dist/loader-pool.js +666 -0
- package/dist/process-fabric.d.ts +524 -0
- package/dist/process-fabric.d.ts.map +1 -0
- package/dist/process-fabric.js +388 -0
- package/dist/process-host.d.ts +132 -0
- package/dist/process-host.d.ts.map +1 -0
- package/dist/process-host.js +444 -0
- package/dist/vendor/errors.d.ts +24 -0
- package/dist/vendor/errors.d.ts.map +1 -0
- package/dist/vendor/errors.js +46 -0
- package/dist/vendor/serialize.d.ts +3 -0
- package/dist/vendor/serialize.d.ts.map +1 -0
- package/dist/vendor/serialize.js +25 -0
- package/dist/vendor/types.d.ts +69 -0
- package/dist/vendor/types.d.ts.map +1 -0
- package/dist/vendor/types.js +4 -0
- package/dist/workerd-facet-host.d.ts +207 -0
- package/dist/workerd-facet-host.d.ts.map +1 -0
- package/dist/workerd-facet-host.js +508 -0
- package/dist/ws-hibernation-config.d.ts +73 -0
- package/dist/ws-hibernation-config.d.ts.map +1 -0
- package/dist/ws-hibernation-config.js +93 -0
- package/package.json +62 -0
- package/src/alarms.ts +275 -0
- package/src/bindings.ts +871 -0
- package/src/ctx-exports.ts +77 -0
- package/src/facet-image-store.ts +196 -0
- package/src/fanout-pool.ts +503 -0
- package/src/index.ts +26 -0
- package/src/inner-do-registry.ts +58 -0
- package/src/launch-journal.ts +229 -0
- package/src/launch-pacer.ts +231 -0
- package/src/loader-ledger.ts +112 -0
- package/src/loader-pool.ts +984 -0
- package/src/process-fabric.ts +729 -0
- package/src/process-host.ts +566 -0
- package/src/vendor/errors.ts +56 -0
- package/src/vendor/serialize.ts +37 -0
- package/src/vendor/types.ts +75 -0
- package/src/workerd-facet-host.ts +694 -0
- package/src/ws-hibernation-config.ts +123 -0
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nimbus-sh/fabric",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"cloudflare",
|
|
7
|
+
"workers",
|
|
8
|
+
"durable-objects",
|
|
9
|
+
"facets",
|
|
10
|
+
"worker-loader",
|
|
11
|
+
"sandbox"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/AshishKumar4/Nimbus",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/AshishKumar4/Nimbus.git",
|
|
17
|
+
"directory": "packages/fabric"
|
|
18
|
+
},
|
|
19
|
+
"bugs": "https://github.com/AshishKumar4/Nimbus/issues",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"sideEffects": false,
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"workspace": "./src/index.ts",
|
|
28
|
+
"bun": "./src/index.ts",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"import": "./dist/index.js"
|
|
31
|
+
},
|
|
32
|
+
"./*.js": {
|
|
33
|
+
"workspace": "./src/*.ts",
|
|
34
|
+
"bun": "./src/*.ts",
|
|
35
|
+
"types": "./dist/*.d.ts",
|
|
36
|
+
"import": "./dist/*.js"
|
|
37
|
+
},
|
|
38
|
+
"./package.json": "./package.json"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"dist",
|
|
42
|
+
"src",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsc -p tsconfig.json --noCheck --noEmit false --declaration true --declarationMap true --outDir dist --rootDir src",
|
|
48
|
+
"prepack": "npm run build",
|
|
49
|
+
"typecheck": "tsc --noEmit"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@nimbus-sh/core": "^0.5.0",
|
|
53
|
+
"zod": "^4.4.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@cloudflare/workers-types": "^4.20250327.0",
|
|
57
|
+
"typescript": "^5.7.0"
|
|
58
|
+
},
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/src/alarms.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* alarms.ts — Durable Object alarm multiplexing + isolate-generation
|
|
3
|
+
* machinery, persisted across hibernation.
|
|
4
|
+
*
|
|
5
|
+
* Workerd hibernates Durable Objects between requests to free memory. On
|
|
6
|
+
* wake, the new isolate must rebuild its in-memory state from SQL — but it
|
|
7
|
+
* also needs to know "is this the same lifecycle as before, or did workerd
|
|
8
|
+
* recycle me?" That distinction matters for recovery (warmJoin vs cold init)
|
|
9
|
+
* and is captured by the isolate generation, a counter persisted across
|
|
10
|
+
* hibernations.
|
|
11
|
+
*
|
|
12
|
+
* A Durable Object has ONE alarm, and a second `setAlarm()` silently
|
|
13
|
+
* overwrites the first — so every alarm-driven subsystem coordinates through
|
|
14
|
+
* a single reason→deadline map and one dispatcher. Reasons are plain strings
|
|
15
|
+
* registered by the embedder: `scheduleAlarm` arms one, and `dispatchAlarm`
|
|
16
|
+
* runs the embedder-supplied handler for every reason whose deadline has
|
|
17
|
+
* passed.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { errorText } from '@nimbus-sh/core/_shared/error-text.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The storage the alarm map lives in. `setAlarm` is optional because
|
|
24
|
+
* `wrangler dev` serves a storage without it, which is the whole reason
|
|
25
|
+
* scheduling degrades to a no-op instead of throwing.
|
|
26
|
+
*/
|
|
27
|
+
export interface AlarmStorage {
|
|
28
|
+
get(key: string): Promise<unknown>;
|
|
29
|
+
put(key: string, value: unknown): Promise<void>;
|
|
30
|
+
delete(key: string): Promise<boolean>;
|
|
31
|
+
setAlarm?(scheduledTime: number): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The hosting actor's context, as the alarm coordination reads it. */
|
|
35
|
+
export interface AlarmContext {
|
|
36
|
+
storage: AlarmStorage;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Multi-reason alarm coordination map.
|
|
41
|
+
*
|
|
42
|
+
* JSON-serialised `Record<reason, deadlineMsEpoch>` where keys are the
|
|
43
|
+
* embedder's canonical reason strings (e.g. 'w9-flush', 'log-janitor'). The
|
|
44
|
+
* alarm() dispatcher reads this on fire, dispatches every reason whose
|
|
45
|
+
* deadline has passed, and re-arms `ctx.storage.setAlarm` at the earliest
|
|
46
|
+
* remaining deadline.
|
|
47
|
+
*
|
|
48
|
+
* Why a map (not a single nextAlarmAt + reason): two subsystems can have
|
|
49
|
+
* distinct deadlines. Without the map, the later setAlarm() call would
|
|
50
|
+
* overwrite the earlier reason silently, breaking whichever subsystem
|
|
51
|
+
* expected its deadline.
|
|
52
|
+
*
|
|
53
|
+
* Forward-compat: the dispatcher silently drops unknown reasons so a
|
|
54
|
+
* rollback from a future deploy that added new reasons doesn't leave the
|
|
55
|
+
* alarm stuck.
|
|
56
|
+
*
|
|
57
|
+
* The VALUE is live production DO storage ('w1_next_alarm_reasons', from the
|
|
58
|
+
* workstream that introduced it) and must never change — renaming a storage
|
|
59
|
+
* key is a migration, and orphaned rows are the least of what it breaks.
|
|
60
|
+
*/
|
|
61
|
+
export const ALARM_REASONS_KEY = 'w1_next_alarm_reasons';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Storage key for the isolate-generation counter (cold-start +
|
|
65
|
+
* post-hibernation wake; one increment per fresh isolate).
|
|
66
|
+
*
|
|
67
|
+
* The VALUE is live production DO storage ('w9_isolate_gen') and must never
|
|
68
|
+
* change, same contract as {@link ALARM_REASONS_KEY}.
|
|
69
|
+
*/
|
|
70
|
+
export const ISOLATE_GEN_KEY = 'w9_isolate_gen';
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The host instance carrying the per-instance alarm chain. The field lives on
|
|
74
|
+
* the embedder's DO instance so one chain serializes every alarm-map
|
|
75
|
+
* read-modify-write for that instance (see {@link scheduleAlarm}).
|
|
76
|
+
*/
|
|
77
|
+
export interface AlarmHost {
|
|
78
|
+
_alarmChain?: Promise<unknown>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The host instance carrying the isolate-generation state. */
|
|
82
|
+
export interface IsolateGenHost {
|
|
83
|
+
_isolateGen: number;
|
|
84
|
+
_isolateGenPersisted: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Schedule (or re-schedule) an alarm reason. Coordinated via a single map in
|
|
89
|
+
* DO storage so multiple subsystems don't clobber each other's `setAlarm()`
|
|
90
|
+
* calls.
|
|
91
|
+
*
|
|
92
|
+
* Semantics:
|
|
93
|
+
* - Reads the existing reasons map.
|
|
94
|
+
* - Sets `map[reason] = whenMs` IF `whenMs` is sooner than the
|
|
95
|
+
* currently-pending deadline for that reason (or no entry exists).
|
|
96
|
+
* Later-than-pending requests are silently ignored — the existing
|
|
97
|
+
* alarm will fire and re-arm anyway.
|
|
98
|
+
* - Writes the map back and calls `ctx.storage.setAlarm(min(deadlines))`.
|
|
99
|
+
*
|
|
100
|
+
* Cost: 1 storage read + 1 storage write + 1 setAlarm per call. setAlarm
|
|
101
|
+
* itself is billed as 1 row written per DO pricing. At a 60s janitor
|
|
102
|
+
* cadence, this is ~$0.05/mo/session at scale — dwarfed by the
|
|
103
|
+
* hibernation duration savings.
|
|
104
|
+
*
|
|
105
|
+
* Fail-soft: any throw is swallowed with a warn. On older runtimes /
|
|
106
|
+
* wrangler-dev where setAlarm is unavailable, this is a no-op (the
|
|
107
|
+
* subsystem's in-isolate setTimeout fallback continues to work).
|
|
108
|
+
*/
|
|
109
|
+
export function scheduleAlarm(
|
|
110
|
+
host: AlarmHost,
|
|
111
|
+
ctx: AlarmContext,
|
|
112
|
+
reason: string,
|
|
113
|
+
whenMs: number,
|
|
114
|
+
): Promise<boolean> {
|
|
115
|
+
// Serialize every read-modify-write of the reasons map through one
|
|
116
|
+
// per-instance chain: two schedulers firing back-to-back from one activity
|
|
117
|
+
// hook would otherwise interleave their get→put cycles and silently drop
|
|
118
|
+
// whichever reason wrote first.
|
|
119
|
+
const run = async (): Promise<boolean> => {
|
|
120
|
+
try {
|
|
121
|
+
const setAlarmFn = ctx?.storage?.setAlarm;
|
|
122
|
+
if (typeof setAlarmFn !== 'function') return false;
|
|
123
|
+
const existing = (await ctx.storage.get(ALARM_REASONS_KEY)) as
|
|
124
|
+
| Record<string, number>
|
|
125
|
+
| undefined;
|
|
126
|
+
const map: Record<string, number> = { ...(existing || {}) };
|
|
127
|
+
// Earliest-deadline-first: only update if new request is sooner or
|
|
128
|
+
// this reason has no pending entry.
|
|
129
|
+
if (!(reason in map) || whenMs < map[reason]) {
|
|
130
|
+
map[reason] = whenMs;
|
|
131
|
+
await ctx.storage.put(ALARM_REASONS_KEY, map);
|
|
132
|
+
}
|
|
133
|
+
const earliest = Math.min(...Object.values(map));
|
|
134
|
+
setAlarmFn.call(ctx.storage, earliest);
|
|
135
|
+
return true;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
console.warn('[nimbus/W1] scheduleAlarm threw:', errorText(e));
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const chained = (host._alarmChain ?? Promise.resolve()).then(run, run);
|
|
142
|
+
host._alarmChain = chained;
|
|
143
|
+
return chained;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What one alarm handler may return: nothing, or a deadline this reason
|
|
148
|
+
* re-arms itself at. Re-arming through the return value keeps the map's
|
|
149
|
+
* read-modify-write inside the dispatcher, where it is serialized.
|
|
150
|
+
*/
|
|
151
|
+
export type AlarmHandlerResult = void | { rearmAt: number };
|
|
152
|
+
|
|
153
|
+
/** The embedder's reasons, each with the handler that answers it. */
|
|
154
|
+
export type AlarmHandlers = Record<
|
|
155
|
+
string,
|
|
156
|
+
(now: number) => AlarmHandlerResult | Promise<AlarmHandlerResult>
|
|
157
|
+
>;
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Multi-reason alarm dispatcher. Called from the DO's `alarm()` handler with
|
|
161
|
+
* the embedder's handler map.
|
|
162
|
+
*
|
|
163
|
+
* For each pending reason whose deadline has passed, run its handler.
|
|
164
|
+
* Handlers are awaited in place: the alarm invocation is the fresh turn a
|
|
165
|
+
* re-entering subsystem asked for, and it has to stay the one paying for the
|
|
166
|
+
* work it just released.
|
|
167
|
+
*
|
|
168
|
+
* After running fireable reasons, re-arms `ctx.storage.setAlarm` at the
|
|
169
|
+
* earliest remaining deadline. If no reasons remain, deletes the map key and
|
|
170
|
+
* does NOT call setAlarm — the DO becomes hibernation-eligible after the 10s
|
|
171
|
+
* idle window.
|
|
172
|
+
*
|
|
173
|
+
* Forward/back-compat: unknown reasons silently dropped. `onLegacyAlarm`
|
|
174
|
+
* covers an alarm that fires with no map at all — a deploy from before the
|
|
175
|
+
* map existed left a bare `setAlarm` behind, and the embedder decides what
|
|
176
|
+
* that one-time fire means (one dispatch later the map is populated by the
|
|
177
|
+
* next scheduleAlarm call).
|
|
178
|
+
*/
|
|
179
|
+
export function dispatchAlarm(
|
|
180
|
+
host: AlarmHost,
|
|
181
|
+
ctx: AlarmContext,
|
|
182
|
+
handlers: AlarmHandlers,
|
|
183
|
+
onLegacyAlarm?: () => void,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
// Same serialization as scheduleAlarm: the dispatcher's read→handlers→write
|
|
186
|
+
// cycle must not interleave with an activity-hook scheduleAlarm.
|
|
187
|
+
const chained = (host._alarmChain ?? Promise.resolve()).then(
|
|
188
|
+
() => dispatchAlarmBody(ctx, handlers, onLegacyAlarm),
|
|
189
|
+
() => dispatchAlarmBody(ctx, handlers, onLegacyAlarm),
|
|
190
|
+
);
|
|
191
|
+
host._alarmChain = chained;
|
|
192
|
+
return chained;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function dispatchAlarmBody(
|
|
196
|
+
ctx: AlarmContext,
|
|
197
|
+
handlers: AlarmHandlers,
|
|
198
|
+
onLegacyAlarm?: () => void,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
try {
|
|
201
|
+
const now = Date.now();
|
|
202
|
+
const existing = (await ctx?.storage?.get?.(ALARM_REASONS_KEY)) as
|
|
203
|
+
| Record<string, number>
|
|
204
|
+
| undefined;
|
|
205
|
+
if (!existing || Object.keys(existing).length === 0) {
|
|
206
|
+
onLegacyAlarm?.();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const map: Record<string, number> = { ...existing };
|
|
210
|
+
// Snapshot fireable reasons BEFORE running any of them, so a
|
|
211
|
+
// handler that schedules itself for the next cycle doesn't get
|
|
212
|
+
// immediately re-fired in the same dispatch.
|
|
213
|
+
const fired: string[] = [];
|
|
214
|
+
for (const [reason, when] of Object.entries(map)) {
|
|
215
|
+
if (when <= now) fired.push(reason);
|
|
216
|
+
}
|
|
217
|
+
for (const reason of fired) {
|
|
218
|
+
delete map[reason];
|
|
219
|
+
const handler = handlers[reason];
|
|
220
|
+
// Unknown reasons silently dropped (forward-compat).
|
|
221
|
+
if (!handler) continue;
|
|
222
|
+
try {
|
|
223
|
+
const result = await handler(now);
|
|
224
|
+
if (result && typeof result.rearmAt === 'number') {
|
|
225
|
+
map[reason] = result.rearmAt;
|
|
226
|
+
}
|
|
227
|
+
} catch (e) {
|
|
228
|
+
console.warn(`[nimbus/W1] dispatch ${reason} threw:`, errorText(e));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Re-arm or clear.
|
|
232
|
+
const setAlarmFn = ctx?.storage?.setAlarm;
|
|
233
|
+
if (Object.keys(map).length > 0) {
|
|
234
|
+
await ctx.storage.put(ALARM_REASONS_KEY, map);
|
|
235
|
+
const earliest = Math.min(...Object.values(map));
|
|
236
|
+
if (typeof setAlarmFn === 'function') {
|
|
237
|
+
setAlarmFn.call(ctx.storage, earliest);
|
|
238
|
+
}
|
|
239
|
+
} else {
|
|
240
|
+
try { await ctx.storage.delete(ALARM_REASONS_KEY); } catch {}
|
|
241
|
+
// No remaining reasons → no setAlarm call → DO becomes
|
|
242
|
+
// hibernation-eligible after the 10s idle window.
|
|
243
|
+
}
|
|
244
|
+
} catch (e) {
|
|
245
|
+
console.warn('[nimbus/W1] dispatchAlarm threw:', errorText(e));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Increment + persist the isolate-gen counter once per fresh isolate. */
|
|
250
|
+
export async function maybeBumpIsolateGen(host: IsolateGenHost, ctx: AlarmContext): Promise<void> {
|
|
251
|
+
if (host._isolateGenPersisted) return;
|
|
252
|
+
host._isolateGenPersisted = true;
|
|
253
|
+
try {
|
|
254
|
+
const prev = (await ctx.storage.get(ISOLATE_GEN_KEY)) as number | undefined;
|
|
255
|
+
// Adopt the persisted truth first, and adopt the bump only after the
|
|
256
|
+
// put resolves. An unpersisted `next` would be re-read as `prev` by the
|
|
257
|
+
// NEXT boot and re-issued — two instances sharing one generation is
|
|
258
|
+
// exactly the pid-aliasing this counter exists to prevent. Running on
|
|
259
|
+
// the previous persisted generation is the lesser lapse, and the
|
|
260
|
+
// put-failure case is replica-only in practice (replicas never spawn).
|
|
261
|
+
//
|
|
262
|
+
// What holds the guarantee is the output gate, not this await: measured,
|
|
263
|
+
// the block body resolves in 0 ms even with a confirmed put, because
|
|
264
|
+
// `await storage.put()` returns before durability. The gate is what
|
|
265
|
+
// keeps a pid from generation N from escaping before N is durable, which
|
|
266
|
+
// is why marking this put `allowUnconfirmed` is not a free speedup — see
|
|
267
|
+
// scratchpad/coldstart-s1.md.
|
|
268
|
+
host._isolateGen = typeof prev === 'number' ? prev : 0;
|
|
269
|
+
const next = host._isolateGen + 1;
|
|
270
|
+
await ctx.storage.put(ISOLATE_GEN_KEY, next);
|
|
271
|
+
host._isolateGen = next;
|
|
272
|
+
} catch (e) {
|
|
273
|
+
console.warn('[nimbus/W9] isolate-gen bump failed:', errorText(e));
|
|
274
|
+
}
|
|
275
|
+
}
|