@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/dist/actor.js
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
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
|
+
import { Server } from 'partyserver';
|
|
44
|
+
import { adoptGeneration, generation as fabricGeneration, onColdStart as fabricOnColdStart, runColdStart, } from '@nimbus-sh/fabric/generation.js';
|
|
45
|
+
import { timers as fabricTimers, } from '@nimbus-sh/fabric/timers.js';
|
|
46
|
+
import { outbox as fabricOutbox } from '@nimbus-sh/fabric/outbox.js';
|
|
47
|
+
import { journal as fabricJournal } from '@nimbus-sh/fabric/journal.js';
|
|
48
|
+
import { facetPool } from '@nimbus-sh/fabric/facet-pool.js';
|
|
49
|
+
import { derived as fabricDerived, derivedAsync as fabricDerivedAsync, } from '@nimbus-sh/fabric/derived.js';
|
|
50
|
+
import { connections as fabricConnections, } from '@nimbus-sh/fabric/connections.js';
|
|
51
|
+
import { FencedWork, } from '@nimbus-sh/fabric/fenced-work.js';
|
|
52
|
+
import { adoptCtxExports, composeFabric, } from '@nimbus-sh/fabric/composition.js';
|
|
53
|
+
import { configureWsHibernation, } from '@nimbus-sh/fabric/ws-hibernation-config.js';
|
|
54
|
+
import { ProcessFabric } from '@nimbus-sh/fabric/process-fabric.js';
|
|
55
|
+
import { ScheduleStore, } from './schedules.js';
|
|
56
|
+
import { dispatchRpc } from './rpc.js';
|
|
57
|
+
import { isRpcRequestFrame, isStateFrame, STATE_ERROR_FRAME_TYPE, STATE_FRAME_TYPE } from './protocol.js';
|
|
58
|
+
/** The timer reason the schedule store dispatches under. */
|
|
59
|
+
export const SCHEDULE_TIMER_REASON = 'loom:schedule';
|
|
60
|
+
function resolveOption(cls, key) {
|
|
61
|
+
for (let current = cls; current; current = Object.getPrototypeOf(current)) {
|
|
62
|
+
const value = current.options?.[key];
|
|
63
|
+
if (value !== undefined)
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
export class Actor extends Server {
|
|
69
|
+
/** fabric `TimerHost`: the chain serializing this instance's timer map. */
|
|
70
|
+
_timerChain;
|
|
71
|
+
/**
|
|
72
|
+
* Fabric's ws-hibernation configuration result, when
|
|
73
|
+
* `options.hibernate` asked for it; null otherwise. Reports honestly
|
|
74
|
+
* which half the runtime supported.
|
|
75
|
+
*/
|
|
76
|
+
hibernation = null;
|
|
77
|
+
#schedules;
|
|
78
|
+
#timerHandlers = {};
|
|
79
|
+
#outboxes = new Map();
|
|
80
|
+
#journals = new Map();
|
|
81
|
+
#processes = null;
|
|
82
|
+
#facets = null;
|
|
83
|
+
#hibernate;
|
|
84
|
+
#state;
|
|
85
|
+
#stateLoaded = false;
|
|
86
|
+
#stateSchemaReady = false;
|
|
87
|
+
constructor(ctx, env) {
|
|
88
|
+
super(ctx, env);
|
|
89
|
+
const cls = Object.getPrototypeOf(this).constructor;
|
|
90
|
+
const fabric = resolveOption(cls, 'fabric');
|
|
91
|
+
if (fabric)
|
|
92
|
+
composeFabric(fabric);
|
|
93
|
+
const ctxExports = ctx.exports;
|
|
94
|
+
if (ctxExports)
|
|
95
|
+
adoptCtxExports(ctxExports);
|
|
96
|
+
this.#hibernate = resolveOption(cls, 'hibernate') === true;
|
|
97
|
+
if (this.#hibernate) {
|
|
98
|
+
this.hibernation = configureWsHibernation(ctx);
|
|
99
|
+
}
|
|
100
|
+
this.#schedules = new ScheduleStore(ctx);
|
|
101
|
+
this.registerTimerReason(SCHEDULE_TIMER_REASON, (now, info) => this.#dispatchSchedules(now, info));
|
|
102
|
+
this.#wrapHooks();
|
|
103
|
+
}
|
|
104
|
+
// ── The floor, per turn ────────────────────────────────────────────────
|
|
105
|
+
/**
|
|
106
|
+
* The deferred floor work of one incarnation, paid by the first turn that
|
|
107
|
+
* owns it: adopt the persisted generation counter (once per instance),
|
|
108
|
+
* then drain the cold-start queue — fenced-work recovery and whatever the
|
|
109
|
+
* embedder deferred. Never the init gate: every call site here is a turn
|
|
110
|
+
* that already passed initialization. A failed cold-start task is
|
|
111
|
+
* reported and does not fail the turn that happened to drain it.
|
|
112
|
+
*/
|
|
113
|
+
async #enterTurn() {
|
|
114
|
+
await adoptGeneration(this.ctx);
|
|
115
|
+
try {
|
|
116
|
+
await runColdStart(this.ctx);
|
|
117
|
+
}
|
|
118
|
+
catch (e) {
|
|
119
|
+
console.error(`[loom] ${this.#className()} cold-start task failed:`, e);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Instance-level wraps around the embedder's hooks, so every entry point
|
|
124
|
+
* pays {@link #enterTurn} and protocol frames never reach `onMessage`.
|
|
125
|
+
* Captured at construction: hooks defined as instance FIELDS would
|
|
126
|
+
* assign over these wrappers — define hooks as methods.
|
|
127
|
+
*/
|
|
128
|
+
#wrapHooks() {
|
|
129
|
+
const onConnect = this.onConnect.bind(this);
|
|
130
|
+
const onMessage = this.onMessage.bind(this);
|
|
131
|
+
const onRequest = this.onRequest.bind(this);
|
|
132
|
+
const onClose = this.onClose.bind(this);
|
|
133
|
+
const onError = this.onError.bind(this);
|
|
134
|
+
this.onConnect = async (connection, ctx) => {
|
|
135
|
+
await this.#enterTurn();
|
|
136
|
+
this.#sendStateOnConnect(connection);
|
|
137
|
+
await onConnect(connection, ctx);
|
|
138
|
+
};
|
|
139
|
+
this.onMessage = async (connection, message) => {
|
|
140
|
+
await this.#enterTurn();
|
|
141
|
+
if (await this.#consumeProtocolFrame(connection, message))
|
|
142
|
+
return;
|
|
143
|
+
await onMessage(connection, message);
|
|
144
|
+
};
|
|
145
|
+
this.onRequest = async (request) => {
|
|
146
|
+
await this.#enterTurn();
|
|
147
|
+
return onRequest(request);
|
|
148
|
+
};
|
|
149
|
+
this.onClose = async (connection, code, reason, wasClean) => {
|
|
150
|
+
await this.#enterTurn();
|
|
151
|
+
await onClose(connection, code, reason, wasClean);
|
|
152
|
+
};
|
|
153
|
+
this.onError = async (connection, error) => {
|
|
154
|
+
await this.#enterTurn();
|
|
155
|
+
await onError(connection, error);
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* The native-RPC entry point (`getActorByName` calls it before any
|
|
160
|
+
* embedder RPC method) pays the turn entry too, after partyserver has
|
|
161
|
+
* initialized.
|
|
162
|
+
*/
|
|
163
|
+
async setName(name, props) {
|
|
164
|
+
await super.setName(name, props);
|
|
165
|
+
await this.#enterTurn();
|
|
166
|
+
}
|
|
167
|
+
// ── One alarm, many reasons ────────────────────────────────────────────
|
|
168
|
+
/**
|
|
169
|
+
* partyserver initialization, then the floor, then `onAlarm`, then
|
|
170
|
+
* fabric's dispatcher runs every due reason with the platform's
|
|
171
|
+
* `alarmInfo`. Handlers re-arm through their return value; the map's
|
|
172
|
+
* earliest remaining deadline re-arms the platform alarm.
|
|
173
|
+
* `__unsafe_ensureInitialized` is partyserver's documented escape hatch
|
|
174
|
+
* for frameworks; calling it here (instead of `super.alarm()`) is what
|
|
175
|
+
* lets `onAlarm` run AFTER the floor, like every other embedder hook.
|
|
176
|
+
*/
|
|
177
|
+
async alarm(alarmInfo) {
|
|
178
|
+
await this.__unsafe_ensureInitialized();
|
|
179
|
+
await this.#enterTurn();
|
|
180
|
+
await this.onAlarm();
|
|
181
|
+
await this.timers.dispatch(this.#timerHandlers, undefined, alarmInfo);
|
|
182
|
+
}
|
|
183
|
+
/** partyserver logs "implement onAlarm" per fire; an empty hook is the default here. */
|
|
184
|
+
onAlarm() { }
|
|
185
|
+
/**
|
|
186
|
+
* Register the handler for one timer reason. Reasons are the alarm's
|
|
187
|
+
* multiplexing key: register in the constructor, arm with
|
|
188
|
+
* `this.timers.schedule(reason, whenMs)`, re-arm by returning
|
|
189
|
+
* `{ rearmAt }` from the handler. One handler per reason, for the
|
|
190
|
+
* instance's lifetime.
|
|
191
|
+
*/
|
|
192
|
+
registerTimerReason(reason, handler) {
|
|
193
|
+
if (reason in this.#timerHandlers) {
|
|
194
|
+
throw new Error(`loom: timer reason '${reason}' is already registered on ${this.#className()}`);
|
|
195
|
+
}
|
|
196
|
+
this.#timerHandlers[reason] = handler;
|
|
197
|
+
}
|
|
198
|
+
// ── Scheduling ─────────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* Schedule a method call: `when` is a delay in seconds, an absolute
|
|
201
|
+
* `Date`, or a cron expression. The callback fires as
|
|
202
|
+
* `this[callback](payload, invocation)`; the invocation carries the
|
|
203
|
+
* schedule, the attempt number, and the platform's `alarmInfo`. Retries
|
|
204
|
+
* are durable rows (see schedules.ts), governed by `options.retry`.
|
|
205
|
+
*/
|
|
206
|
+
async schedule(when, callback, payload, options) {
|
|
207
|
+
this.#assertScheduleCallback(callback);
|
|
208
|
+
const schedule = this.#schedules.create(when, callback, payload, options);
|
|
209
|
+
await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
|
|
210
|
+
return schedule;
|
|
211
|
+
}
|
|
212
|
+
/** Schedule a method call every `intervalSeconds`, first fire one interval from now. */
|
|
213
|
+
async scheduleEvery(intervalSeconds, callback, payload, options) {
|
|
214
|
+
this.#assertScheduleCallback(callback);
|
|
215
|
+
const schedule = this.#schedules.every(intervalSeconds, callback, payload, options);
|
|
216
|
+
await this.timers.schedule(SCHEDULE_TIMER_REASON, schedule.time);
|
|
217
|
+
return schedule;
|
|
218
|
+
}
|
|
219
|
+
async getScheduleById(id) {
|
|
220
|
+
return this.#schedules.byId(id);
|
|
221
|
+
}
|
|
222
|
+
async listSchedules(criteria) {
|
|
223
|
+
return this.#schedules.list(criteria);
|
|
224
|
+
}
|
|
225
|
+
/** True when the id existed and is now cancelled. */
|
|
226
|
+
async cancelSchedule(id) {
|
|
227
|
+
return this.#schedules.cancel(id);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* A schedule's retry budget is spent (or its callback is not a method).
|
|
231
|
+
* The default names the failure; override to route it.
|
|
232
|
+
*/
|
|
233
|
+
onScheduleError(schedule, error) {
|
|
234
|
+
console.error(`[loom] ${this.#className()} schedule '${schedule.id}' (${schedule.callback}) failed for good:`, error);
|
|
235
|
+
}
|
|
236
|
+
#assertScheduleCallback(callback) {
|
|
237
|
+
if (typeof this[callback] !== 'function') {
|
|
238
|
+
throw new Error(`loom: this.${callback} is not a function`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async #dispatchSchedules(now, info) {
|
|
242
|
+
const result = await this.#schedules.dispatchDue(this, now, info, (schedule, error) => {
|
|
243
|
+
try {
|
|
244
|
+
this.onScheduleError(schedule, error);
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
console.error(`[loom] ${this.#className()} onScheduleError itself failed:`, e);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return result.rearmAt === null ? undefined : { rearmAt: result.rearmAt };
|
|
251
|
+
}
|
|
252
|
+
// ── State sync ─────────────────────────────────────────────────────────
|
|
253
|
+
/**
|
|
254
|
+
* The synced state. Loaded from SQLite on first read; `initialState`
|
|
255
|
+
* before anything was ever set; undefined for a stateless actor.
|
|
256
|
+
*/
|
|
257
|
+
get state() {
|
|
258
|
+
if (!this.#stateLoaded) {
|
|
259
|
+
this.#ensureStateSchema();
|
|
260
|
+
const rows = [...this.#sql.exec(`SELECT state FROM loom_state WHERE id = 1`)];
|
|
261
|
+
this.#state = rows.length > 0 ? JSON.parse(rows[0].state) : this.initialState;
|
|
262
|
+
this.#stateLoaded = true;
|
|
263
|
+
}
|
|
264
|
+
return this.#state;
|
|
265
|
+
}
|
|
266
|
+
/** Replace the state: validate, persist, broadcast, notify. */
|
|
267
|
+
setState(state) {
|
|
268
|
+
this.#setStateInternal(state, 'server');
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Synchronous veto over every state change, the embedder's own and a
|
|
272
|
+
* connection's alike. Runs BEFORE anything persists; throw to refuse.
|
|
273
|
+
* A refused connection update earns the client a
|
|
274
|
+
* `cf_agent_state_error` frame.
|
|
275
|
+
*/
|
|
276
|
+
validateStateChange(_next, _source) { }
|
|
277
|
+
/** The state changed and is already persisted and broadcast. */
|
|
278
|
+
onStateChanged(_state, _source) { }
|
|
279
|
+
#setStateInternal(state, source) {
|
|
280
|
+
this.validateStateChange(state, source);
|
|
281
|
+
const json = JSON.stringify(state);
|
|
282
|
+
if (json === undefined) {
|
|
283
|
+
throw new Error('loom: state must be JSON-serializable, and undefined is not a state');
|
|
284
|
+
}
|
|
285
|
+
this.#ensureStateSchema();
|
|
286
|
+
this.#sql.exec(`INSERT OR REPLACE INTO loom_state (id, state, updated_at) VALUES (1, ?, ?)`, json, Date.now());
|
|
287
|
+
this.#state = state;
|
|
288
|
+
this.#stateLoaded = true;
|
|
289
|
+
this.broadcast(JSON.stringify({ type: STATE_FRAME_TYPE, state }), source === 'server' ? [] : [source.id]);
|
|
290
|
+
// The change already persisted and broadcast, so an onStateChanged
|
|
291
|
+
// failure — sync or async — is the hook's problem, never a refusal.
|
|
292
|
+
try {
|
|
293
|
+
const hook = this.onStateChanged(state, source);
|
|
294
|
+
if (hook && typeof hook.then === 'function') {
|
|
295
|
+
this.ctx.waitUntil(hook.catch((e) => {
|
|
296
|
+
console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
console.error(`[loom] ${this.#className()} onStateChanged failed:`, e);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
#sendStateOnConnect(connection) {
|
|
305
|
+
const state = this.state;
|
|
306
|
+
if (state === undefined)
|
|
307
|
+
return;
|
|
308
|
+
try {
|
|
309
|
+
connection.send(JSON.stringify({ type: STATE_FRAME_TYPE, state }));
|
|
310
|
+
}
|
|
311
|
+
catch (e) {
|
|
312
|
+
console.warn(`[loom] ${this.#className()} could not send state on connect:`, e);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
#ensureStateSchema() {
|
|
316
|
+
if (this.#stateSchemaReady)
|
|
317
|
+
return;
|
|
318
|
+
this.#sql.exec(`CREATE TABLE IF NOT EXISTS loom_state (
|
|
319
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
320
|
+
state TEXT NOT NULL,
|
|
321
|
+
updated_at INTEGER NOT NULL
|
|
322
|
+
)`);
|
|
323
|
+
this.#stateSchemaReady = true;
|
|
324
|
+
}
|
|
325
|
+
get #sql() {
|
|
326
|
+
return this.ctx.storage.sql;
|
|
327
|
+
}
|
|
328
|
+
// ── Protocol frames ────────────────────────────────────────────────────
|
|
329
|
+
/** True when the message was a protocol frame and is now handled. */
|
|
330
|
+
async #consumeProtocolFrame(connection, message) {
|
|
331
|
+
if (typeof message !== 'string' || message[0] !== '{')
|
|
332
|
+
return false;
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(message);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (isStateFrame(parsed)) {
|
|
341
|
+
try {
|
|
342
|
+
this.#setStateInternal(parsed.state, connection);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
console.warn(`[loom] ${this.#className()} refused a state update from ${connection.id}:`, e);
|
|
346
|
+
try {
|
|
347
|
+
connection.send(JSON.stringify({ type: STATE_ERROR_FRAME_TYPE, error: 'State update rejected' }));
|
|
348
|
+
}
|
|
349
|
+
catch { /* peer gone; the refusal has no one to reach */ }
|
|
350
|
+
}
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
if (isRpcRequestFrame(parsed)) {
|
|
354
|
+
await dispatchRpc(this, connection, parsed);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
// ── The fabric floor, as accessors ─────────────────────────────────────
|
|
360
|
+
/** This actor's reason map over its ONE platform alarm. */
|
|
361
|
+
get timers() {
|
|
362
|
+
return fabricTimers(this, this.ctx);
|
|
363
|
+
}
|
|
364
|
+
/** This incarnation's generation. Zero until the first turn adopted it. */
|
|
365
|
+
get generation() {
|
|
366
|
+
return fabricGeneration(this.ctx);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The named durable retry outbox, its drain registered as a timer reason
|
|
370
|
+
* on first call. One instance per name; later calls return the first and
|
|
371
|
+
* ignore their policy argument.
|
|
372
|
+
*
|
|
373
|
+
* Create outboxes in the CONSTRUCTOR. A queued row survives an instance
|
|
374
|
+
* reset, but the dispatcher drops a fired reason no handler answers
|
|
375
|
+
* (rollback forward-compat) — an outbox first created inside a request
|
|
376
|
+
* path is not registered when the next incarnation's alarm fires, and
|
|
377
|
+
* its queued rows sit until some later `queue()` happens to re-arm.
|
|
378
|
+
*/
|
|
379
|
+
outbox(name, policy) {
|
|
380
|
+
const existing = this.#outboxes.get(name);
|
|
381
|
+
if (existing)
|
|
382
|
+
return existing;
|
|
383
|
+
const box = fabricOutbox(this, this.ctx, name, policy);
|
|
384
|
+
this.registerTimerReason(box.reason, box.handler());
|
|
385
|
+
this.#outboxes.set(name, box);
|
|
386
|
+
return box;
|
|
387
|
+
}
|
|
388
|
+
/** The named append-only event journal. One instance per name. */
|
|
389
|
+
journal(name) {
|
|
390
|
+
const existing = this.#journals.get(name);
|
|
391
|
+
if (existing)
|
|
392
|
+
return existing;
|
|
393
|
+
const created = fabricJournal(this.ctx, name);
|
|
394
|
+
this.#journals.set(name, created);
|
|
395
|
+
return created;
|
|
396
|
+
}
|
|
397
|
+
/** Leased facets: disposal retires (storage wiped), `detach()` keeps it. */
|
|
398
|
+
get facets() {
|
|
399
|
+
return (this.#facets ??= facetPool(this.ctx));
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The process fabric over this actor's substrate. Declare the substrate
|
|
403
|
+
* by overriding {@link processHost}; the fabric is built once, on first
|
|
404
|
+
* use.
|
|
405
|
+
*/
|
|
406
|
+
get processes() {
|
|
407
|
+
return (this.#processes ??= new ProcessFabric(this.processHost()));
|
|
408
|
+
}
|
|
409
|
+
/** The substrate {@link processes} runs on. Override to declare one. */
|
|
410
|
+
processHost() {
|
|
411
|
+
throw new Error(`loom: ${this.#className()} used this.processes without a substrate — override processHost()`);
|
|
412
|
+
}
|
|
413
|
+
/** A watermark memo: derive a cheap key, compare, rebuild only on change. */
|
|
414
|
+
derived(watermark, build, hooks) {
|
|
415
|
+
return fabricDerived(watermark, build, hooks);
|
|
416
|
+
}
|
|
417
|
+
/** The async memo; a watermark or build failure serves the last good value. */
|
|
418
|
+
derivedAsync(watermark, build, hooks) {
|
|
419
|
+
return fabricDerivedAsync(watermark, build, hooks);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Typed, validated per-connection state over the WebSocket attachment,
|
|
423
|
+
* hibernation-durable. partyserver owns the accept (tag connections via
|
|
424
|
+
* `getConnectionTags`); this reads, writes, and addresses by tag. To
|
|
425
|
+
* replace-on-reconnect, close the other holders of the identity tag in
|
|
426
|
+
* `onConnect`.
|
|
427
|
+
*
|
|
428
|
+
* Hibernation-only: the state rides the hibernatable socket attachment,
|
|
429
|
+
* and partyserver's non-hibernating connections neither wrap nor persist
|
|
430
|
+
* it — so a non-hibernating actor is refused here, not corrupted later.
|
|
431
|
+
*/
|
|
432
|
+
connections(schema) {
|
|
433
|
+
if (!this.#hibernate) {
|
|
434
|
+
throw new Error(`loom: ${this.#className()}.connections() needs hibernation — the typed state rides the `
|
|
435
|
+
+ `hibernatable socket attachment; set static options = { hibernate: true }`);
|
|
436
|
+
}
|
|
437
|
+
const adapter = {
|
|
438
|
+
acceptWebSocket: () => {
|
|
439
|
+
throw new Error('loom: partyserver owns the accept — tag connections via getConnectionTags() and write state with write()');
|
|
440
|
+
},
|
|
441
|
+
getWebSockets: (tag) => [...this.getConnections(tag)],
|
|
442
|
+
getTags: (ws) => [...ws.tags],
|
|
443
|
+
};
|
|
444
|
+
const inner = fabricConnections(adapter, schema);
|
|
445
|
+
return {
|
|
446
|
+
get: (tag) => inner.get(tag),
|
|
447
|
+
list: (tag) => inner.list(tag),
|
|
448
|
+
tags: (connection) => inner.tags(connection),
|
|
449
|
+
read: (connection) => inner.read(connection),
|
|
450
|
+
write: (connection, attachment) => inner.write(connection, attachment),
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* A fenced-work journal whose recovery is pumped on the first turn of
|
|
455
|
+
* every incarnation — reconnects after a reset included. The host defines
|
|
456
|
+
* what a launch is and how to re-drive it; call once, in the constructor.
|
|
457
|
+
*/
|
|
458
|
+
fenceWork(host) {
|
|
459
|
+
const work = new FencedWork(this.ctx.storage, host);
|
|
460
|
+
fabricOnColdStart(this.ctx, () => work.recoverInterrupted());
|
|
461
|
+
return work;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Defer async reconciliation to the first turn of this incarnation —
|
|
465
|
+
* never the init gate. Safe to call from the constructor; that is the
|
|
466
|
+
* point.
|
|
467
|
+
*/
|
|
468
|
+
deferToColdStart(task) {
|
|
469
|
+
fabricOnColdStart(this.ctx, task);
|
|
470
|
+
}
|
|
471
|
+
#className() {
|
|
472
|
+
return Object.getPrototypeOf(this).constructor.name;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* callable.ts — the opt-in that makes an actor method reachable over the
|
|
3
|
+
* connection.
|
|
4
|
+
*
|
|
5
|
+
* RPC exposure is allowlist-only: a method a client can invoke by name is a
|
|
6
|
+
* public surface, and an accidental one is a vulnerability. `callable()`
|
|
7
|
+
* is the allowlist mark. The mechanism mirrors the Agents SDK exactly
|
|
8
|
+
* (verified in `agents` 0.20.1 dist, `index.js:54,112-134`): a TC39
|
|
9
|
+
* standard method decorator whose registry is a module-level
|
|
10
|
+
* `WeakMap<Function, CallableMetadata>` keyed by the method's function
|
|
11
|
+
* object. Keying by function makes the mark travel with the method through
|
|
12
|
+
* inheritance and `this[name]` lookup, and costs nothing at class-definition
|
|
13
|
+
* time.
|
|
14
|
+
*
|
|
15
|
+
* The decorator ignores its context argument, so plain-JS callers (tests,
|
|
16
|
+
* codebases without decorator syntax) can mark a method directly:
|
|
17
|
+
* `callable()(MyActor.prototype.greet)`.
|
|
18
|
+
*/
|
|
19
|
+
export interface CallableMetadata {
|
|
20
|
+
/** What the method does, for surface listings. */
|
|
21
|
+
description?: string;
|
|
22
|
+
/**
|
|
23
|
+
* A streaming method receives a {@link import('./rpc.js').StreamingResponse}
|
|
24
|
+
* as its FIRST argument, ahead of the caller's own, and replies through it.
|
|
25
|
+
* Its return value is discarded.
|
|
26
|
+
*/
|
|
27
|
+
streaming?: boolean;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Mark a method as callable over the connection. First mark wins; marking
|
|
31
|
+
* the same function twice keeps the first metadata.
|
|
32
|
+
*/
|
|
33
|
+
export declare function callable(metadata?: CallableMetadata): <This, Args extends unknown[], Return>(target: (this: This, ...args: Args) => Return, _context?: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>) => (this: This, ...args: Args) => Return;
|
|
34
|
+
/** True when the value is a function carrying the callable mark. */
|
|
35
|
+
export declare function isCallable(method: unknown): boolean;
|
|
36
|
+
/** The mark's metadata, or undefined for an unmarked value. */
|
|
37
|
+
export declare function callableMetadata(method: unknown): CallableMetadata | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* Every callable method reachable from an instance, by name, walking the
|
|
40
|
+
* prototype chain. A subclass override without its own mark hides the
|
|
41
|
+
* marked parent method — the override is what `this[name]` resolves to,
|
|
42
|
+
* and it is unmarked.
|
|
43
|
+
*/
|
|
44
|
+
export declare function callableMethods(target: object): Map<string, CallableMetadata>;
|
|
45
|
+
//# sourceMappingURL=callable.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"callable.d.ts","sourceRoot":"","sources":["../src/callable.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAID;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,QAAQ,GAAE,gBAAqB,IACzB,IAAI,EAAE,IAAI,SAAS,OAAO,EAAE,EAAE,MAAM,EAC/D,QAAQ,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,IAAI,KAAK,MAAM,EAC7C,WAAW,2BAA2B,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,IAAI,KAAK,MAAM,CAAC,KAClF,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,IAAI,KAAK,MAAM,CAIzC;AAED,oEAAoE;AACpE,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,OAAO,CAEnD;AAED,+DAA+D;AAC/D,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAE9E;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAa7E"}
|
package/dist/callable.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* callable.ts — the opt-in that makes an actor method reachable over the
|
|
3
|
+
* connection.
|
|
4
|
+
*
|
|
5
|
+
* RPC exposure is allowlist-only: a method a client can invoke by name is a
|
|
6
|
+
* public surface, and an accidental one is a vulnerability. `callable()`
|
|
7
|
+
* is the allowlist mark. The mechanism mirrors the Agents SDK exactly
|
|
8
|
+
* (verified in `agents` 0.20.1 dist, `index.js:54,112-134`): a TC39
|
|
9
|
+
* standard method decorator whose registry is a module-level
|
|
10
|
+
* `WeakMap<Function, CallableMetadata>` keyed by the method's function
|
|
11
|
+
* object. Keying by function makes the mark travel with the method through
|
|
12
|
+
* inheritance and `this[name]` lookup, and costs nothing at class-definition
|
|
13
|
+
* time.
|
|
14
|
+
*
|
|
15
|
+
* The decorator ignores its context argument, so plain-JS callers (tests,
|
|
16
|
+
* codebases without decorator syntax) can mark a method directly:
|
|
17
|
+
* `callable()(MyActor.prototype.greet)`.
|
|
18
|
+
*/
|
|
19
|
+
const registry = new WeakMap();
|
|
20
|
+
/**
|
|
21
|
+
* Mark a method as callable over the connection. First mark wins; marking
|
|
22
|
+
* the same function twice keeps the first metadata.
|
|
23
|
+
*/
|
|
24
|
+
export function callable(metadata = {}) {
|
|
25
|
+
return function markCallable(target, _context) {
|
|
26
|
+
if (!registry.has(target))
|
|
27
|
+
registry.set(target, metadata);
|
|
28
|
+
return target;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** True when the value is a function carrying the callable mark. */
|
|
32
|
+
export function isCallable(method) {
|
|
33
|
+
return typeof method === 'function' && registry.has(method);
|
|
34
|
+
}
|
|
35
|
+
/** The mark's metadata, or undefined for an unmarked value. */
|
|
36
|
+
export function callableMetadata(method) {
|
|
37
|
+
return typeof method === 'function' ? registry.get(method) : undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every callable method reachable from an instance, by name, walking the
|
|
41
|
+
* prototype chain. A subclass override without its own mark hides the
|
|
42
|
+
* marked parent method — the override is what `this[name]` resolves to,
|
|
43
|
+
* and it is unmarked.
|
|
44
|
+
*/
|
|
45
|
+
export function callableMethods(target) {
|
|
46
|
+
const found = new Map();
|
|
47
|
+
const shadowed = new Set();
|
|
48
|
+
for (let proto = Object.getPrototypeOf(target); proto && proto !== Object.prototype; proto = Object.getPrototypeOf(proto)) {
|
|
49
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
50
|
+
if (name === 'constructor' || shadowed.has(name))
|
|
51
|
+
continue;
|
|
52
|
+
// Own descriptors only — reading `instance[name]` would run getters.
|
|
53
|
+
shadowed.add(name);
|
|
54
|
+
const metadata = callableMetadata(Object.getOwnPropertyDescriptor(proto, name)?.value);
|
|
55
|
+
if (metadata !== undefined)
|
|
56
|
+
found.set(name, metadata);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return found;
|
|
60
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.ts — the caller's half of callable RPC: a promise per call, and a
|
|
3
|
+
* typed stub proxy that makes an actor's methods look local.
|
|
4
|
+
*
|
|
5
|
+
* Dependency-free and workerd-free on purpose: the socket is anything with
|
|
6
|
+
* `send` and message listeners — a browser WebSocket, a PartySocket, a
|
|
7
|
+
* server-side WebSocket from `fetch()` — so this module runs wherever the
|
|
8
|
+
* connection was made. Frames and defaults follow the Agents SDK client
|
|
9
|
+
* (verified in `agents` 0.20.1 dist, `client.js:129-151,217-248`): ids are
|
|
10
|
+
* `crypto.randomUUID()`, plain calls time out at 30 s by default, streamed
|
|
11
|
+
* calls (an `onChunk` listener) get no timeout unless one is passed.
|
|
12
|
+
*/
|
|
13
|
+
import type { StreamingResponse } from './rpc.js';
|
|
14
|
+
/** The connection as the client drives it. A browser WebSocket satisfies it. */
|
|
15
|
+
export interface ActorSocket {
|
|
16
|
+
send(data: string): void;
|
|
17
|
+
addEventListener(type: 'message', listener: (event: {
|
|
18
|
+
data: unknown;
|
|
19
|
+
}) => void): void;
|
|
20
|
+
removeEventListener(type: 'message', listener: (event: {
|
|
21
|
+
data: unknown;
|
|
22
|
+
}) => void): void;
|
|
23
|
+
}
|
|
24
|
+
export interface ActorCallOptions {
|
|
25
|
+
/**
|
|
26
|
+
* Reject the call after this long. Default: 30,000 ms for plain calls;
|
|
27
|
+
* none for streamed calls.
|
|
28
|
+
*/
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
/** Receives each `done: false` chunk of a streamed reply. */
|
|
31
|
+
onChunk?: (chunk: unknown) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The target's async methods, callable as promises. Which of them the actor
|
|
35
|
+
* actually answers is decided server-side by the `callable()` mark; an
|
|
36
|
+
* unmarked method rejects with "is not callable".
|
|
37
|
+
*
|
|
38
|
+
* A streaming callable's `StreamingResponse` parameter is server-side —
|
|
39
|
+
* the caller passes the remaining arguments and the promise resolves with
|
|
40
|
+
* the final chunk, so the stub type strips that first parameter. The
|
|
41
|
+
* detection is structural: a method whose first parameter merely ACCEPTS a
|
|
42
|
+
* StreamingResponse (`unknown`, a broad object) is typed as streaming too.
|
|
43
|
+
*/
|
|
44
|
+
export type ActorStub<T> = {
|
|
45
|
+
[K in keyof T as T[K] extends (...args: never[]) => unknown ? K : never]: T[K] extends (stream: StreamingResponse, ...args: infer StreamArgs) => unknown ? (...args: StreamArgs) => Promise<unknown> : T[K] extends (...args: infer Args) => infer Return ? (...args: Args) => Promise<Awaited<Return>> : never;
|
|
46
|
+
};
|
|
47
|
+
export interface ActorClient {
|
|
48
|
+
/** Call one callable method by name. */
|
|
49
|
+
call<T = unknown>(method: string, args?: unknown[], options?: ActorCallOptions): Promise<T>;
|
|
50
|
+
/** A proxy whose method calls become `call(name, args)`. */
|
|
51
|
+
stub<T>(options?: ActorCallOptions): ActorStub<T>;
|
|
52
|
+
/** Detach from the socket and reject every call still pending. */
|
|
53
|
+
close(): void;
|
|
54
|
+
}
|
|
55
|
+
export declare const DEFAULT_CALL_TIMEOUT_MS = 30000;
|
|
56
|
+
/** Attach an RPC client to a socket. One message listener for all calls. */
|
|
57
|
+
export declare function actorClient(socket: ActorSocket, defaults?: ActorCallOptions): ActorClient;
|
|
58
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAElD,gFAAgF;AAChF,MAAM,WAAW,WAAW;IAC1B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gBAAgB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;IACtF,mBAAmB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,GAAG,IAAI,CAAC;CAC1F;AAED,MAAM,WAAW,gBAAgB;IAC/B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6DAA6D;IAC7D,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;KACxB,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,KAAK,EAAE,KAAK,OAAO,GAAG,CAAC,GAAG,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,SAAS,CACrF,MAAM,EAAE,iBAAiB,EACzB,GAAG,IAAI,EAAE,MAAM,UAAU,KACtB,OAAO,GACR,CAAC,GAAG,IAAI,EAAE,UAAU,KAAK,OAAO,CAAC,OAAO,CAAC,GACzC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG,IAAI,EAAE,MAAM,IAAI,KAAK,MAAM,MAAM,GAChD,CAAC,GAAG,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,GAC3C,KAAK;CACZ,CAAC;AAEF,MAAM,WAAW,WAAW;IAC1B,wCAAwC;IACxC,IAAI,CAAC,CAAC,GAAG,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC,CAAC,CAAC;IAC5F,4DAA4D;IAC5D,IAAI,CAAC,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC,CAAC,CAAC,CAAC;IAClD,kEAAkE;IAClE,KAAK,IAAI,IAAI,CAAC;CACf;AAED,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAS9C,4EAA4E;AAC5E,wBAAgB,WAAW,CAAC,MAAM,EAAE,WAAW,EAAE,QAAQ,GAAE,gBAAqB,GAAG,WAAW,CA8E7F"}
|