@nimbus-sh/loom 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 +163 -0
- package/dist/actor.d.ts +222 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +474 -0
- package/dist/callable.d.ts +45 -0
- package/dist/callable.d.ts.map +1 -0
- package/dist/callable.js +60 -0
- package/dist/client.d.ts +58 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +98 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/protocol.d.ts +63 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +45 -0
- package/dist/routing.d.ts +13 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +12 -0
- package/dist/rpc.d.ts +47 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +102 -0
- package/dist/schedules.d.ts +150 -0
- package/dist/schedules.d.ts.map +1 -0
- package/dist/schedules.js +276 -0
- package/package.json +66 -0
- package/src/actor.ts +638 -0
- package/src/callable.ts +76 -0
- package/src/client.ts +153 -0
- package/src/index.ts +19 -0
- package/src/protocol.ts +84 -0
- package/src/routing.ts +19 -0
- package/src/rpc.ts +110 -0
- package/src/schedules.ts +356 -0
package/src/actor.ts
ADDED
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* actor.ts — `Actor`, a partyserver `Server` standing on the fabric floor.
|
|
3
|
+
*
|
|
4
|
+
* partyserver contributes the surface: routing, connections, tags,
|
|
5
|
+
* broadcast, hibernation opt-in, `onStart`/`onConnect`/`onMessage`/
|
|
6
|
+
* `onRequest`/`onClose`/`onError`. Fabric contributes the machinery a
|
|
7
|
+
* Durable Object needs under that surface, and this class wires it so an
|
|
8
|
+
* embedder never does:
|
|
9
|
+
*
|
|
10
|
+
* - ONE ALARM, MANY REASONS. `alarm()` dispatches fabric's reason map;
|
|
11
|
+
* the embedder registers reasons ({@link Actor.registerTimerReason})
|
|
12
|
+
* and arms them (`this.timers`). The schedule API and every outbox are
|
|
13
|
+
* reasons in the same map. The platform's `alarmInfo` rides through to
|
|
14
|
+
* every handler.
|
|
15
|
+
* - NOTHING ASYNC ON THE INIT GATE. The constructor is synchronous.
|
|
16
|
+
* partyserver's own gate runs exactly `onStart` (its `#ensureInitialized`,
|
|
17
|
+
* a `blockConcurrencyWhile`); a gate callback still pending at ~30 s is
|
|
18
|
+
* cancelled and RESETS the object, so `onStart` must stay short.
|
|
19
|
+
* Everything the floor defers — generation adoption, cold-start
|
|
20
|
+
* reconciliation, fenced-work recovery — runs on the first turn the
|
|
21
|
+
* actor already owns: every entry point passes {@link Actor.#enterTurn}
|
|
22
|
+
* after initialization and before embedder code. One platform
|
|
23
|
+
* exception: partyserver's fetch asks `getConnectionTags` during the
|
|
24
|
+
* accept, before the connect turn's floor entry — keep that hook pure.
|
|
25
|
+
* - HIBERNATION CONFIGURED, NOT JUST ENABLED. With
|
|
26
|
+
* `static options = { hibernate: true }`, the constructor also applies
|
|
27
|
+
* fabric's ws-hibernation config: ping/pong auto-response (a matched
|
|
28
|
+
* frame no longer wakes the actor) and the 5 s hibernatable-event
|
|
29
|
+
* timeout. The result is on `this.hibernation` for diagnostics.
|
|
30
|
+
* - COMPOSITION STATED ONCE. `static options = { fabric: {...} }` feeds
|
|
31
|
+
* `composeFabric`, and the constructor captures `ctx.exports` where the
|
|
32
|
+
* platform hands it over. Both are first-write-wins.
|
|
33
|
+
*
|
|
34
|
+
* Protocol frames (state sync, callable RPC — see protocol.ts) are consumed
|
|
35
|
+
* before `onMessage`; everything else reaches the embedder untouched. For
|
|
36
|
+
* that interception to hold, hooks must be prototype METHODS — an instance
|
|
37
|
+
* field (`onMessage = () => {}`) assigns over the wiring.
|
|
38
|
+
*
|
|
39
|
+
* The state and schedule tables live in the actor's own SQLite, so an Actor
|
|
40
|
+
* class must be SQLite-backed (`new_sqlite_classes` — the default for new
|
|
41
|
+
* classes).
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { Server, type Connection, type ConnectionContext, type WSMessage } from 'partyserver';
|
|
45
|
+
import {
|
|
46
|
+
adoptGeneration,
|
|
47
|
+
generation as fabricGeneration,
|
|
48
|
+
onColdStart as fabricOnColdStart,
|
|
49
|
+
runColdStart,
|
|
50
|
+
type GenerationContext,
|
|
51
|
+
} from '@nimbus-sh/fabric/generation.js';
|
|
52
|
+
import {
|
|
53
|
+
timers as fabricTimers,
|
|
54
|
+
type TimerAlarmInfo,
|
|
55
|
+
type TimerContext,
|
|
56
|
+
type TimerHandlerResult,
|
|
57
|
+
type TimerHandlers,
|
|
58
|
+
type Timers,
|
|
59
|
+
} from '@nimbus-sh/fabric/timers.js';
|
|
60
|
+
import { outbox as fabricOutbox, type Outbox, type OutboxContext, type OutboxPolicy } from '@nimbus-sh/fabric/outbox.js';
|
|
61
|
+
import { journal as fabricJournal, type Journal, type JournalContext } from '@nimbus-sh/fabric/journal.js';
|
|
62
|
+
import { facetPool, type FacetPool, type FacetPoolContext } from '@nimbus-sh/fabric/facet-pool.js';
|
|
63
|
+
import {
|
|
64
|
+
derived as fabricDerived,
|
|
65
|
+
derivedAsync as fabricDerivedAsync,
|
|
66
|
+
type Derived,
|
|
67
|
+
type DerivedAsync,
|
|
68
|
+
type DerivedAsyncHooks,
|
|
69
|
+
type DerivedHooks,
|
|
70
|
+
} from '@nimbus-sh/fabric/derived.js';
|
|
71
|
+
import {
|
|
72
|
+
connections as fabricConnections,
|
|
73
|
+
type ConnectionSocket,
|
|
74
|
+
type ConnectionsContext,
|
|
75
|
+
} from '@nimbus-sh/fabric/connections.js';
|
|
76
|
+
import {
|
|
77
|
+
FencedWork,
|
|
78
|
+
type FencedWorkHost,
|
|
79
|
+
type FencedWorkRecord,
|
|
80
|
+
type FencedWorkStorage,
|
|
81
|
+
} from '@nimbus-sh/fabric/fenced-work.js';
|
|
82
|
+
import {
|
|
83
|
+
adoptCtxExports,
|
|
84
|
+
composeFabric,
|
|
85
|
+
type CtxExports,
|
|
86
|
+
type FabricComposition,
|
|
87
|
+
} from '@nimbus-sh/fabric/composition.js';
|
|
88
|
+
import {
|
|
89
|
+
configureWsHibernation,
|
|
90
|
+
type WsHibernationConfigResult,
|
|
91
|
+
} from '@nimbus-sh/fabric/ws-hibernation-config.js';
|
|
92
|
+
import { ProcessFabric, type ProcessHost } from '@nimbus-sh/fabric/process-fabric.js';
|
|
93
|
+
import type { z } from 'zod/v4';
|
|
94
|
+
import {
|
|
95
|
+
ScheduleStore,
|
|
96
|
+
type Schedule,
|
|
97
|
+
type ScheduleContext,
|
|
98
|
+
type ScheduleCriteria,
|
|
99
|
+
type ScheduleOptions,
|
|
100
|
+
} from './schedules.js';
|
|
101
|
+
import { dispatchRpc } from './rpc.js';
|
|
102
|
+
import { isRpcRequestFrame, isStateFrame, STATE_ERROR_FRAME_TYPE, STATE_FRAME_TYPE } from './protocol.js';
|
|
103
|
+
|
|
104
|
+
/** The timer reason the schedule store dispatches under. */
|
|
105
|
+
export const SCHEDULE_TIMER_REASON = 'loom:schedule';
|
|
106
|
+
|
|
107
|
+
/** Static configuration, inherited through the class chain like partyserver's. */
|
|
108
|
+
export interface ActorOptions {
|
|
109
|
+
/** partyserver's hibernation opt-in; loom also applies fabric's ws config. */
|
|
110
|
+
hibernate?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The embedder's fabric composition, stated once on the class. Fed to
|
|
113
|
+
* `composeFabric` (first-write-wins) before anything can need it.
|
|
114
|
+
*/
|
|
115
|
+
fabric?: FabricComposition;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Typed, validated per-connection state over partyserver's `Connection`
|
|
120
|
+
* surface — fabric's `connections` machinery with the accept half left to
|
|
121
|
+
* partyserver, which owns the accept.
|
|
122
|
+
*/
|
|
123
|
+
export interface TypedConnections<T> {
|
|
124
|
+
/** The open connection holding a tag, or null. */
|
|
125
|
+
get(tag: string): Connection | null;
|
|
126
|
+
/** Every open connection (optionally: holding a tag). */
|
|
127
|
+
list(tag?: string): Connection[];
|
|
128
|
+
/** A connection's tags — its id first, then `getConnectionTags`' additions. */
|
|
129
|
+
tags(connection: Connection): string[];
|
|
130
|
+
/**
|
|
131
|
+
* The attachment, validated. Null when it does not parse — an attachment
|
|
132
|
+
* written by a previous deploy is untrusted input.
|
|
133
|
+
*/
|
|
134
|
+
read(connection: Connection): T | null;
|
|
135
|
+
/** Replace the attachment, validated on the way in. */
|
|
136
|
+
write(connection: Connection, attachment: T): void;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveOption<K extends keyof ActorOptions>(cls: unknown, key: K): ActorOptions[K] {
|
|
140
|
+
for (
|
|
141
|
+
let current = cls as { options?: ActorOptions } | null;
|
|
142
|
+
current;
|
|
143
|
+
current = Object.getPrototypeOf(current) as { options?: ActorOptions } | null
|
|
144
|
+
) {
|
|
145
|
+
const value = current.options?.[key];
|
|
146
|
+
if (value !== undefined) return value;
|
|
147
|
+
}
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export class Actor<
|
|
152
|
+
Env extends Cloudflare.Env = Cloudflare.Env,
|
|
153
|
+
State = unknown,
|
|
154
|
+
Props extends Record<string, unknown> = Record<string, unknown>,
|
|
155
|
+
> extends Server<Env, Props> {
|
|
156
|
+
declare static options: ActorOptions;
|
|
157
|
+
|
|
158
|
+
/** fabric `TimerHost`: the chain serializing this instance's timer map. */
|
|
159
|
+
_timerChain?: Promise<unknown>;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Fabric's ws-hibernation configuration result, when
|
|
163
|
+
* `options.hibernate` asked for it; null otherwise. Reports honestly
|
|
164
|
+
* which half the runtime supported.
|
|
165
|
+
*/
|
|
166
|
+
readonly hibernation: WsHibernationConfigResult | null = null;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The state broadcast to (and settable by) connections. Assign it in the
|
|
170
|
+
* subclass; leave it unassigned for a stateless actor.
|
|
171
|
+
*/
|
|
172
|
+
declare initialState: State;
|
|
173
|
+
|
|
174
|
+
readonly #schedules: ScheduleStore;
|
|
175
|
+
readonly #timerHandlers: TimerHandlers = {};
|
|
176
|
+
readonly #outboxes = new Map<string, Outbox<never>>();
|
|
177
|
+
readonly #journals = new Map<string, Journal<never>>();
|
|
178
|
+
#processes: ProcessFabric | null = null;
|
|
179
|
+
#facets: FacetPool | null = null;
|
|
180
|
+
readonly #hibernate: boolean;
|
|
181
|
+
#state: State | undefined;
|
|
182
|
+
#stateLoaded = false;
|
|
183
|
+
#stateSchemaReady = false;
|
|
184
|
+
|
|
185
|
+
constructor(ctx: DurableObjectState, env: Env) {
|
|
186
|
+
super(ctx, env);
|
|
187
|
+
const cls = (Object.getPrototypeOf(this) as { constructor: unknown }).constructor;
|
|
188
|
+
const fabric = resolveOption(cls, 'fabric');
|
|
189
|
+
if (fabric) composeFabric(fabric);
|
|
190
|
+
const ctxExports = (ctx as { exports?: CtxExports }).exports;
|
|
191
|
+
if (ctxExports) adoptCtxExports(ctxExports);
|
|
192
|
+
this.#hibernate = resolveOption(cls, 'hibernate') === true;
|
|
193
|
+
if (this.#hibernate) {
|
|
194
|
+
this.hibernation = configureWsHibernation(ctx);
|
|
195
|
+
}
|
|
196
|
+
this.#schedules = new ScheduleStore(ctx as unknown as ScheduleContext);
|
|
197
|
+
this.registerTimerReason(SCHEDULE_TIMER_REASON, (now, info) => this.#dispatchSchedules(now, info));
|
|
198
|
+
this.#wrapHooks();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── The floor, per turn ────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The deferred floor work of one incarnation, paid by the first turn that
|
|
205
|
+
* owns it: adopt the persisted generation counter (once per instance),
|
|
206
|
+
* then drain the cold-start queue — fenced-work recovery and whatever the
|
|
207
|
+
* embedder deferred. Never the init gate: every call site here is a turn
|
|
208
|
+
* that already passed initialization. A failed cold-start task is
|
|
209
|
+
* reported and does not fail the turn that happened to drain it.
|
|
210
|
+
*/
|
|
211
|
+
async #enterTurn(): Promise<void> {
|
|
212
|
+
await adoptGeneration(this.ctx as unknown as GenerationContext);
|
|
213
|
+
try {
|
|
214
|
+
await runColdStart(this.ctx);
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.error(`[loom] ${this.#className()} cold-start task failed:`, e);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Instance-level wraps around the embedder's hooks, so every entry point
|
|
222
|
+
* pays {@link #enterTurn} and protocol frames never reach `onMessage`.
|
|
223
|
+
* Captured at construction: hooks defined as instance FIELDS would
|
|
224
|
+
* assign over these wrappers — define hooks as methods.
|
|
225
|
+
*/
|
|
226
|
+
#wrapHooks(): void {
|
|
227
|
+
const onConnect = this.onConnect.bind(this);
|
|
228
|
+
const onMessage = this.onMessage.bind(this);
|
|
229
|
+
const onRequest = this.onRequest.bind(this);
|
|
230
|
+
const onClose = this.onClose.bind(this);
|
|
231
|
+
const onError = this.onError.bind(this);
|
|
232
|
+
this.onConnect = async (connection: Connection, ctx: ConnectionContext): Promise<void> => {
|
|
233
|
+
await this.#enterTurn();
|
|
234
|
+
this.#sendStateOnConnect(connection);
|
|
235
|
+
await onConnect(connection, ctx);
|
|
236
|
+
};
|
|
237
|
+
this.onMessage = async (connection: Connection, message: WSMessage): Promise<void> => {
|
|
238
|
+
await this.#enterTurn();
|
|
239
|
+
if (await this.#consumeProtocolFrame(connection, message)) return;
|
|
240
|
+
await onMessage(connection, message);
|
|
241
|
+
};
|
|
242
|
+
this.onRequest = async (request: Request): Promise<Response> => {
|
|
243
|
+
await this.#enterTurn();
|
|
244
|
+
return onRequest(request);
|
|
245
|
+
};
|
|
246
|
+
this.onClose = async (connection: Connection, code: number, reason: string, wasClean: boolean): Promise<void> => {
|
|
247
|
+
await this.#enterTurn();
|
|
248
|
+
await onClose(connection, code, reason, wasClean);
|
|
249
|
+
};
|
|
250
|
+
this.onError = async (connection: Connection, error: unknown): Promise<void> => {
|
|
251
|
+
await this.#enterTurn();
|
|
252
|
+
await onError(connection, error);
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* The native-RPC entry point (`getActorByName` calls it before any
|
|
258
|
+
* embedder RPC method) pays the turn entry too, after partyserver has
|
|
259
|
+
* initialized.
|
|
260
|
+
*/
|
|
261
|
+
override async setName(name: string, props?: Props): Promise<void> {
|
|
262
|
+
await super.setName(name, props);
|
|
263
|
+
await this.#enterTurn();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── One alarm, many reasons ────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* partyserver initialization, then the floor, then `onAlarm`, then
|
|
270
|
+
* fabric's dispatcher runs every due reason with the platform's
|
|
271
|
+
* `alarmInfo`. Handlers re-arm through their return value; the map's
|
|
272
|
+
* earliest remaining deadline re-arms the platform alarm.
|
|
273
|
+
* `__unsafe_ensureInitialized` is partyserver's documented escape hatch
|
|
274
|
+
* for frameworks; calling it here (instead of `super.alarm()`) is what
|
|
275
|
+
* lets `onAlarm` run AFTER the floor, like every other embedder hook.
|
|
276
|
+
*/
|
|
277
|
+
override async alarm(alarmInfo?: TimerAlarmInfo): Promise<void> {
|
|
278
|
+
await this.__unsafe_ensureInitialized();
|
|
279
|
+
await this.#enterTurn();
|
|
280
|
+
await this.onAlarm();
|
|
281
|
+
await this.timers.dispatch(this.#timerHandlers, undefined, alarmInfo);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** partyserver logs "implement onAlarm" per fire; an empty hook is the default here. */
|
|
285
|
+
override onAlarm(): void | Promise<void> {}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Register the handler for one timer reason. Reasons are the alarm's
|
|
289
|
+
* multiplexing key: register in the constructor, arm with
|
|
290
|
+
* `this.timers.schedule(reason, whenMs)`, re-arm by returning
|
|
291
|
+
* `{ rearmAt }` from the handler. One handler per reason, for the
|
|
292
|
+
* instance's lifetime.
|
|
293
|
+
*/
|
|
294
|
+
protected registerTimerReason(
|
|
295
|
+
reason: string,
|
|
296
|
+
handler: (now: number, info?: TimerAlarmInfo) => TimerHandlerResult | Promise<TimerHandlerResult>,
|
|
297
|
+
): void {
|
|
298
|
+
if (reason in this.#timerHandlers) {
|
|
299
|
+
throw new Error(`loom: timer reason '${reason}' is already registered on ${this.#className()}`);
|
|
300
|
+
}
|
|
301
|
+
this.#timerHandlers[reason] = handler;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Scheduling ─────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Schedule a method call: `when` is a delay in seconds, an absolute
|
|
308
|
+
* `Date`, or a cron expression. The callback fires as
|
|
309
|
+
* `this[callback](payload, invocation)`; the invocation carries the
|
|
310
|
+
* schedule, the attempt number, and the platform's `alarmInfo`. Retries
|
|
311
|
+
* are durable rows (see schedules.ts), governed by `options.retry`.
|
|
312
|
+
*/
|
|
313
|
+
async schedule<T = unknown>(
|
|
314
|
+
when: number | Date | string,
|
|
315
|
+
callback: keyof this & string,
|
|
316
|
+
payload?: T,
|
|
317
|
+
options?: ScheduleOptions,
|
|
318
|
+
): Promise<Schedule<T>> {
|
|
319
|
+
this.#assertScheduleCallback(callback);
|
|
320
|
+
const schedule = this.#schedules.create(when, callback, payload, options);
|
|
321
|
+
await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
|
|
322
|
+
return schedule;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Schedule a method call every `intervalSeconds`, first fire one interval from now. */
|
|
326
|
+
async scheduleEvery<T = unknown>(
|
|
327
|
+
intervalSeconds: number,
|
|
328
|
+
callback: keyof this & string,
|
|
329
|
+
payload?: T,
|
|
330
|
+
options?: ScheduleOptions,
|
|
331
|
+
): Promise<Schedule<T>> {
|
|
332
|
+
this.#assertScheduleCallback(callback);
|
|
333
|
+
const schedule = this.#schedules.every(intervalSeconds, callback, payload, options);
|
|
334
|
+
await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
|
|
335
|
+
return schedule;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async getScheduleById<T = unknown>(id: string): Promise<Schedule<T> | undefined> {
|
|
339
|
+
return this.#schedules.byId<T>(id);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async listSchedules<T = unknown>(criteria?: ScheduleCriteria): Promise<Array<Schedule<T>>> {
|
|
343
|
+
return this.#schedules.list<T>(criteria);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** True when the id existed and is now cancelled. */
|
|
347
|
+
async cancelSchedule(id: string): Promise<boolean> {
|
|
348
|
+
return this.#schedules.cancel(id);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* A schedule's retry budget is spent (or its callback is not a method).
|
|
353
|
+
* The default names the failure; override to route it.
|
|
354
|
+
*/
|
|
355
|
+
onScheduleError(schedule: Schedule, error: unknown): void {
|
|
356
|
+
console.error(
|
|
357
|
+
`[loom] ${this.#className()} schedule '${schedule.id}' (${schedule.callback}) failed for good:`,
|
|
358
|
+
error,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#assertScheduleCallback(callback: string): void {
|
|
363
|
+
if (typeof (this as unknown as Record<string, unknown>)[callback] !== 'function') {
|
|
364
|
+
throw new Error(`loom: this.${callback} is not a function`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async #dispatchSchedules(now: number, info?: TimerAlarmInfo): Promise<TimerHandlerResult> {
|
|
369
|
+
const result = await this.#schedules.dispatchDue(this, now, info, (schedule, error) => {
|
|
370
|
+
try {
|
|
371
|
+
this.onScheduleError(schedule, error);
|
|
372
|
+
} catch (e) {
|
|
373
|
+
console.error(`[loom] ${this.#className()} onScheduleError itself failed:`, e);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
return result.rearmAt === null ? undefined : { rearmAt: result.rearmAt };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ── State sync ─────────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The synced state. Loaded from SQLite on first read; `initialState`
|
|
383
|
+
* before anything was ever set; undefined for a stateless actor.
|
|
384
|
+
*/
|
|
385
|
+
get state(): State {
|
|
386
|
+
if (!this.#stateLoaded) {
|
|
387
|
+
this.#ensureStateSchema();
|
|
388
|
+
const rows = [...this.#sql.exec(`SELECT state FROM loom_state WHERE id = 1`)] as Array<{ state: string }>;
|
|
389
|
+
this.#state = rows.length > 0 ? (JSON.parse(rows[0].state) as State) : this.initialState;
|
|
390
|
+
this.#stateLoaded = true;
|
|
391
|
+
}
|
|
392
|
+
return this.#state as State;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Replace the state: validate, persist, broadcast, notify. */
|
|
396
|
+
setState(state: State): void {
|
|
397
|
+
this.#setStateInternal(state, 'server');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Synchronous veto over every state change, the embedder's own and a
|
|
402
|
+
* connection's alike. Runs BEFORE anything persists; throw to refuse.
|
|
403
|
+
* A refused connection update earns the client a
|
|
404
|
+
* `cf_agent_state_error` frame.
|
|
405
|
+
*/
|
|
406
|
+
validateStateChange(_next: State, _source: Connection | 'server'): void {}
|
|
407
|
+
|
|
408
|
+
/** The state changed and is already persisted and broadcast. */
|
|
409
|
+
onStateChanged(_state: State, _source: Connection | 'server'): void | Promise<void> {}
|
|
410
|
+
|
|
411
|
+
#setStateInternal(state: State, source: Connection | 'server'): void {
|
|
412
|
+
this.validateStateChange(state, source);
|
|
413
|
+
const json = JSON.stringify(state);
|
|
414
|
+
if (json === undefined) {
|
|
415
|
+
throw new Error('loom: state must be JSON-serializable, and undefined is not a state');
|
|
416
|
+
}
|
|
417
|
+
this.#ensureStateSchema();
|
|
418
|
+
this.#sql.exec(
|
|
419
|
+
`INSERT OR REPLACE INTO loom_state (id, state, updated_at) VALUES (1, ?, ?)`,
|
|
420
|
+
json,
|
|
421
|
+
Date.now(),
|
|
422
|
+
);
|
|
423
|
+
this.#state = state;
|
|
424
|
+
this.#stateLoaded = true;
|
|
425
|
+
this.broadcast(
|
|
426
|
+
JSON.stringify({ type: STATE_FRAME_TYPE, state }),
|
|
427
|
+
source === 'server' ? [] : [source.id],
|
|
428
|
+
);
|
|
429
|
+
// The change already persisted and broadcast, so an onStateChanged
|
|
430
|
+
// failure — sync or async — is the hook's problem, never a refusal.
|
|
431
|
+
try {
|
|
432
|
+
const hook = this.onStateChanged(state, source);
|
|
433
|
+
if (hook && typeof (hook as Promise<void>).then === 'function') {
|
|
434
|
+
this.ctx.waitUntil(
|
|
435
|
+
(hook as Promise<void>).catch((e) => {
|
|
436
|
+
console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
} catch (e) {
|
|
441
|
+
console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
#sendStateOnConnect(connection: Connection): void {
|
|
446
|
+
const state = this.state;
|
|
447
|
+
if (state === undefined) return;
|
|
448
|
+
try {
|
|
449
|
+
connection.send(JSON.stringify({ type: STATE_FRAME_TYPE, state }));
|
|
450
|
+
} catch (e) {
|
|
451
|
+
console.warn(`[loom] ${this.#className()} could not send state on connect:`, e);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#ensureStateSchema(): void {
|
|
456
|
+
if (this.#stateSchemaReady) return;
|
|
457
|
+
this.#sql.exec(`CREATE TABLE IF NOT EXISTS loom_state (
|
|
458
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
459
|
+
state TEXT NOT NULL,
|
|
460
|
+
updated_at INTEGER NOT NULL
|
|
461
|
+
)`);
|
|
462
|
+
this.#stateSchemaReady = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
get #sql(): { exec(query: string, ...bindings: Array<string | number | null>): Iterable<unknown> } {
|
|
466
|
+
return (this.ctx as unknown as ScheduleContext).storage.sql;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ── Protocol frames ────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/** True when the message was a protocol frame and is now handled. */
|
|
472
|
+
async #consumeProtocolFrame(connection: Connection, message: WSMessage): Promise<boolean> {
|
|
473
|
+
if (typeof message !== 'string' || message[0] !== '{') return false;
|
|
474
|
+
let parsed: unknown;
|
|
475
|
+
try {
|
|
476
|
+
parsed = JSON.parse(message);
|
|
477
|
+
} catch {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
if (isStateFrame(parsed)) {
|
|
481
|
+
try {
|
|
482
|
+
this.#setStateInternal(parsed.state as State, connection);
|
|
483
|
+
} catch (e) {
|
|
484
|
+
console.warn(`[loom] ${this.#className()} refused a state update from ${connection.id}:`, e);
|
|
485
|
+
try {
|
|
486
|
+
connection.send(JSON.stringify({ type: STATE_ERROR_FRAME_TYPE, error: 'State update rejected' }));
|
|
487
|
+
} catch { /* peer gone; the refusal has no one to reach */ }
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
if (isRpcRequestFrame(parsed)) {
|
|
492
|
+
await dispatchRpc(this, connection, parsed);
|
|
493
|
+
return true;
|
|
494
|
+
}
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── The fabric floor, as accessors ─────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/** This actor's reason map over its ONE platform alarm. */
|
|
501
|
+
get timers(): Timers {
|
|
502
|
+
return fabricTimers(this, this.ctx as unknown as TimerContext);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** This incarnation's generation. Zero until the first turn adopted it. */
|
|
506
|
+
get generation(): number {
|
|
507
|
+
return fabricGeneration(this.ctx);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The named durable retry outbox, its drain registered as a timer reason
|
|
512
|
+
* on first call. One instance per name; later calls return the first and
|
|
513
|
+
* ignore their policy argument.
|
|
514
|
+
*
|
|
515
|
+
* Create outboxes in the CONSTRUCTOR. A queued row survives an instance
|
|
516
|
+
* reset, but the dispatcher drops a fired reason no handler answers
|
|
517
|
+
* (rollback forward-compat) — an outbox first created inside a request
|
|
518
|
+
* path is not registered when the next incarnation's alarm fires, and
|
|
519
|
+
* its queued rows sit until some later `queue()` happens to re-arm.
|
|
520
|
+
*/
|
|
521
|
+
outbox<M>(name: string, policy: OutboxPolicy<M>): Outbox<M> {
|
|
522
|
+
const existing = this.#outboxes.get(name);
|
|
523
|
+
if (existing) return existing as Outbox<M>;
|
|
524
|
+
const box = fabricOutbox<M>(this, this.ctx as unknown as OutboxContext, name, policy);
|
|
525
|
+
this.registerTimerReason(box.reason, box.handler());
|
|
526
|
+
this.#outboxes.set(name, box as Outbox<never>);
|
|
527
|
+
return box;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The named append-only event journal. One instance per name. */
|
|
531
|
+
journal<P>(name: string): Journal<P> {
|
|
532
|
+
const existing = this.#journals.get(name);
|
|
533
|
+
if (existing) return existing as Journal<P>;
|
|
534
|
+
const created = fabricJournal<P>(this.ctx as unknown as JournalContext, name);
|
|
535
|
+
this.#journals.set(name, created as Journal<never>);
|
|
536
|
+
return created;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Leased facets: disposal retires (storage wiped), `detach()` keeps it. */
|
|
540
|
+
get facets(): FacetPool {
|
|
541
|
+
return (this.#facets ??= facetPool(this.ctx as unknown as FacetPoolContext));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* The process fabric over this actor's substrate. Declare the substrate
|
|
546
|
+
* by overriding {@link processHost}; the fabric is built once, on first
|
|
547
|
+
* use.
|
|
548
|
+
*/
|
|
549
|
+
get processes(): ProcessFabric {
|
|
550
|
+
return (this.#processes ??= new ProcessFabric(this.processHost()));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The substrate {@link processes} runs on. Override to declare one. */
|
|
554
|
+
protected processHost(): ProcessHost {
|
|
555
|
+
throw new Error(
|
|
556
|
+
`loom: ${this.#className()} used this.processes without a substrate — override processHost()`,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A watermark memo: derive a cheap key, compare, rebuild only on change. */
|
|
561
|
+
derived<T, C = void>(
|
|
562
|
+
watermark: (context: C) => string | number,
|
|
563
|
+
build: (context: C, key: string | number) => T,
|
|
564
|
+
hooks?: DerivedHooks,
|
|
565
|
+
): Derived<T, C> {
|
|
566
|
+
return fabricDerived(watermark, build, hooks);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** The async memo; a watermark or build failure serves the last good value. */
|
|
570
|
+
derivedAsync<T, C = void>(
|
|
571
|
+
watermark: (context: C) => Promise<string | number>,
|
|
572
|
+
build: (context: C, key: string | number) => Promise<T>,
|
|
573
|
+
hooks?: DerivedAsyncHooks,
|
|
574
|
+
): DerivedAsync<T, C> {
|
|
575
|
+
return fabricDerivedAsync(watermark, build, hooks);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Typed, validated per-connection state over the WebSocket attachment,
|
|
580
|
+
* hibernation-durable. partyserver owns the accept (tag connections via
|
|
581
|
+
* `getConnectionTags`); this reads, writes, and addresses by tag. To
|
|
582
|
+
* replace-on-reconnect, close the other holders of the identity tag in
|
|
583
|
+
* `onConnect`.
|
|
584
|
+
*
|
|
585
|
+
* Hibernation-only: the state rides the hibernatable socket attachment,
|
|
586
|
+
* and partyserver's non-hibernating connections neither wrap nor persist
|
|
587
|
+
* it — so a non-hibernating actor is refused here, not corrupted later.
|
|
588
|
+
*/
|
|
589
|
+
connections<T>(schema: z.ZodType<T>): TypedConnections<T> {
|
|
590
|
+
if (!this.#hibernate) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`loom: ${this.#className()}.connections() needs hibernation — the typed state rides the `
|
|
593
|
+
+ `hibernatable socket attachment; set static options = { hibernate: true }`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
const adapter: ConnectionsContext = {
|
|
597
|
+
acceptWebSocket: () => {
|
|
598
|
+
throw new Error(
|
|
599
|
+
'loom: partyserver owns the accept — tag connections via getConnectionTags() and write state with write()',
|
|
600
|
+
);
|
|
601
|
+
},
|
|
602
|
+
getWebSockets: (tag?: string) => [...this.getConnections(tag)] as unknown as ConnectionSocket[],
|
|
603
|
+
getTags: (ws: ConnectionSocket) => [...(ws as unknown as Connection).tags],
|
|
604
|
+
};
|
|
605
|
+
const inner = fabricConnections(adapter, schema);
|
|
606
|
+
return {
|
|
607
|
+
get: (tag) => inner.get(tag) as Connection | null,
|
|
608
|
+
list: (tag) => inner.list(tag) as unknown as Connection[],
|
|
609
|
+
tags: (connection) => inner.tags(connection as unknown as ConnectionSocket),
|
|
610
|
+
read: (connection) => inner.read(connection as unknown as ConnectionSocket),
|
|
611
|
+
write: (connection, attachment) => inner.write(connection as unknown as ConnectionSocket, attachment),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* A fenced-work journal whose recovery is pumped on the first turn of
|
|
617
|
+
* every incarnation — reconnects after a reset included. The host defines
|
|
618
|
+
* what a launch is and how to re-drive it; call once, in the constructor.
|
|
619
|
+
*/
|
|
620
|
+
protected fenceWork<R extends FencedWorkRecord>(host: FencedWorkHost<R>): FencedWork<R> {
|
|
621
|
+
const work = new FencedWork<R>(this.ctx.storage as unknown as FencedWorkStorage, host);
|
|
622
|
+
fabricOnColdStart(this.ctx, () => work.recoverInterrupted());
|
|
623
|
+
return work;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Defer async reconciliation to the first turn of this incarnation —
|
|
628
|
+
* never the init gate. Safe to call from the constructor; that is the
|
|
629
|
+
* point.
|
|
630
|
+
*/
|
|
631
|
+
protected deferToColdStart(task: () => Promise<unknown>): void {
|
|
632
|
+
fabricOnColdStart(this.ctx, task);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
#className(): string {
|
|
636
|
+
return (Object.getPrototypeOf(this) as { constructor: { name: string } }).constructor.name;
|
|
637
|
+
}
|
|
638
|
+
}
|