@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/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"}
@@ -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
+ }
@@ -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"}