@abraca/dabra 1.9.1 → 2.0.1

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.
@@ -0,0 +1,659 @@
1
+ import EventEmitter from "./EventEmitter.ts";
2
+
3
+ /**
4
+ * RPC v1 — request/response over `MSG_STATELESS`.
5
+ *
6
+ * Mirrors `docs/rpc-v1.md` in the abracadabra-rs server. Frames travel as
7
+ * `MSG_STATELESS` payloads with the literal prefix `rpc:v1:` followed by a
8
+ * JSON envelope. The provider's existing `sendStateless` / `stateless` event
9
+ * stream is the only transport surface this layer touches; everything else
10
+ * (state machine, deadlines, handler registry, dedupe of self-replies) lives
11
+ * here.
12
+ */
13
+
14
+ export const RPC_PREFIX = "rpc:v1:";
15
+
16
+ export type RpcKind =
17
+ | "req"
18
+ | "ack"
19
+ | "nack"
20
+ | "progress"
21
+ | "result"
22
+ | "cancel"
23
+ | "hb"
24
+ | "register"
25
+ | "unregister";
26
+
27
+ export interface RpcFrame {
28
+ kind: RpcKind;
29
+ id: string;
30
+ ts?: number;
31
+ from?: string;
32
+ to?: string;
33
+ method?: string;
34
+ args?: unknown;
35
+ deadline_ms?: number;
36
+ data?: unknown;
37
+ error?: RpcErrorPayload;
38
+ by?: "server" | "runner";
39
+ runner_id?: string;
40
+ seq?: number;
41
+ methods?: string[];
42
+ }
43
+
44
+ export interface RpcErrorPayload {
45
+ code: string;
46
+ message: string;
47
+ details?: unknown;
48
+ }
49
+
50
+ export type RpcErrorCode =
51
+ | "NO_HANDLER"
52
+ | "HANDLER_GONE"
53
+ | "TIMEOUT"
54
+ | "CANCELLED"
55
+ | "RATE_LIMITED"
56
+ | "UNAUTHORIZED"
57
+ | "SCHEMA"
58
+ | "INTERNAL"
59
+ | "APP"
60
+ | string; // forward-compat for unknown codes from runners
61
+
62
+ /** Thrown by `rpc.call(...)` on terminal nack / error / timeout. */
63
+ export class RpcError extends Error {
64
+ code: RpcErrorCode;
65
+ details?: unknown;
66
+ constructor(code: RpcErrorCode, message: string, details?: unknown) {
67
+ super(message);
68
+ this.name = "RpcError";
69
+ this.code = code;
70
+ this.details = details;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Minimal provider surface RpcClient needs. The base provider already
76
+ * satisfies it; the indirection is here so handlers can be tested against
77
+ * a stub without booting a Y.Doc.
78
+ */
79
+ export interface RpcTransport {
80
+ sendStateless(payload: string): void;
81
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
82
+ on(event: string, fn: Function): unknown;
83
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
84
+ off(event: string, fn?: Function): unknown;
85
+ }
86
+
87
+ export type RpcTarget =
88
+ | { kind: "role"; role: "service" }
89
+ | { kind: "user"; userId: string }
90
+ | { kind: "runner"; sessionId: string };
91
+
92
+ export interface RpcCallOptions {
93
+ /** Defaults to `{ kind: "role", role: "service" }`. */
94
+ target?: RpcTarget;
95
+ /** Wall-clock deadline in ms. Server clamps to its own ceiling. */
96
+ deadline?: number;
97
+ /** Abort the call (sends `rpc:cancel` to the runner). */
98
+ signal?: AbortSignal;
99
+ /** Fired when a runner claims the call (after the server ack). */
100
+ onClaim?: (runnerId: string) => void;
101
+ /** Fired for every `rpc:progress` frame the runner emits. */
102
+ onProgress?: (data: unknown, seq: number) => void;
103
+ }
104
+
105
+ export interface RpcCallHandle<T = unknown> extends Promise<T> {
106
+ /** RPC id (UUID v7) — useful for log correlation. */
107
+ readonly id: string;
108
+ /** Runner session id, populated once a runner has claimed the call. */
109
+ readonly claimedBy: string | undefined;
110
+ /** Cancel the call. Equivalent to `signal.abort()` if a signal was passed. */
111
+ cancel(reason?: string): void;
112
+ }
113
+
114
+ interface PendingRpc {
115
+ id: string;
116
+ resolve: (value: unknown) => void;
117
+ reject: (err: Error) => void;
118
+ options: RpcCallOptions;
119
+ state: "pending" | "accepted" | "claimed" | "settled";
120
+ claimedBy: string | undefined;
121
+ deadlineTimer: ReturnType<typeof setTimeout> | undefined;
122
+ }
123
+
124
+ /**
125
+ * Handler signature for a runner-side RPC handler.
126
+ *
127
+ * Returning a value emits `rpc:result(ok)`. Throwing emits
128
+ * `rpc:result(err)` — `RpcError` instances pass through their `code` and
129
+ * `details`; any other thrown value becomes `INTERNAL`.
130
+ *
131
+ * Returning an async generator turns each `yield` into a `rpc:progress`
132
+ * frame (with monotonic `seq`); the generator's return value is the final
133
+ * `result.data`.
134
+ */
135
+ export type RpcHandler = (
136
+ args: unknown,
137
+ ctx: RpcHandlerContext,
138
+ ) =>
139
+ | unknown
140
+ | Promise<unknown>
141
+ | AsyncGenerator<unknown, unknown, void>;
142
+
143
+ export interface RpcHandlerContext {
144
+ id: string;
145
+ method: string;
146
+ /** Server-stamped caller user id. Trusted. */
147
+ from: string;
148
+ /** Aborted when the caller cancels or the deadline is reached. */
149
+ signal: AbortSignal;
150
+ }
151
+
152
+ // ── UUID v7 (small inline impl) ─────────────────────────────────────────────
153
+
154
+ function uuidv7(): string {
155
+ // Minimal UUID v7 — time-ordered, 122 random bits.
156
+ const ts = Date.now();
157
+ const tsHex = ts.toString(16).padStart(12, "0");
158
+ const rand = new Uint8Array(10);
159
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
160
+ crypto.getRandomValues(rand);
161
+ } else {
162
+ for (let i = 0; i < rand.length; i++) rand[i] = Math.floor(Math.random() * 256);
163
+ }
164
+ // version=7, variant=10
165
+ rand[0] = (rand[0] & 0x0f) | 0x70;
166
+ rand[2] = (rand[2] & 0x3f) | 0x80;
167
+ const hex = Array.from(rand, (b) => b.toString(16).padStart(2, "0")).join("");
168
+ return (
169
+ `${tsHex.slice(0, 8)}-${tsHex.slice(8, 12)}-` +
170
+ `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 20)}`
171
+ );
172
+ }
173
+
174
+ // ── Client ──────────────────────────────────────────────────────────────────
175
+
176
+ /**
177
+ * Per-provider RPC layer. Construct one and attach by passing the provider
178
+ * (or any `RpcTransport`). Disposes on `destroy()`.
179
+ *
180
+ * Caller side: `rpc.call('ai.summarize@1', { docId }, { onProgress })`.
181
+ *
182
+ * Runner side: `rpc.handle('ai.summarize@1', async (args, ctx) => …)`.
183
+ */
184
+ export class RpcClient extends EventEmitter {
185
+ private readonly transport: RpcTransport;
186
+ private readonly pending = new Map<string, PendingRpc>();
187
+ private readonly handlers = new Map<string, RpcHandler>();
188
+ /** Tracks live runner-side invocations so we can route `rpc:cancel`. */
189
+ private readonly runningHandlers = new Map<string, AbortController>();
190
+ /** Pending register frames awaiting server ack — resolved by id. */
191
+ private readonly pendingRegistrations = new Map<string, () => void>();
192
+ /** Grace timer armed on `disconnect`; cancelled on `connect`. Fires
193
+ * `HANDLER_GONE` on every pending RPC if the WS is still down after
194
+ * the grace window. v1 used to wait `deadline + 5s` which could be
195
+ * 35s on default deadlines — way too long for a definitively-dead
196
+ * connection. */
197
+ private disconnectGrace: ReturnType<typeof setTimeout> | undefined;
198
+ private static readonly DISCONNECT_GRACE_MS = 10_000;
199
+ private readonly onStatelessBound: (data: { payload: string }) => void;
200
+ /**
201
+ * Replays all live `register` frames whenever the underlying provider
202
+ * (re)syncs — covers WebSocket reconnects where the server allocated a
203
+ * new session id and the old capability entries were freed.
204
+ */
205
+ private readonly onSyncedBound: (data: { state: boolean }) => void;
206
+ private readonly onDisconnectBound: () => void;
207
+ private readonly onConnectBound: () => void;
208
+ private destroyed = false;
209
+
210
+ constructor(transport: RpcTransport) {
211
+ super();
212
+ this.transport = transport;
213
+ this.onStatelessBound = (data) => this.receive(data.payload);
214
+ this.onSyncedBound = (data) => {
215
+ if (data.state) this.replayRegistrations();
216
+ };
217
+ this.onDisconnectBound = () => this.armDisconnectGrace();
218
+ this.onConnectBound = () => this.cancelDisconnectGrace();
219
+ transport.on("stateless", this.onStatelessBound);
220
+ transport.on("synced", this.onSyncedBound);
221
+ transport.on("disconnect", this.onDisconnectBound);
222
+ transport.on("connect", this.onConnectBound);
223
+ }
224
+
225
+ private armDisconnectGrace(): void {
226
+ if (this.destroyed) return;
227
+ if (this.disconnectGrace) return; // already armed
228
+ this.disconnectGrace = setTimeout(
229
+ () => this.failPendingOnDisconnect(),
230
+ RpcClient.DISCONNECT_GRACE_MS,
231
+ );
232
+ }
233
+
234
+ private cancelDisconnectGrace(): void {
235
+ if (this.disconnectGrace) {
236
+ clearTimeout(this.disconnectGrace);
237
+ this.disconnectGrace = undefined;
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Called when the WS has been down for the full grace window. Every
243
+ * in-flight call is dead from the server's perspective (its `close_session`
244
+ * already fired `HANDLER_GONE` to a now-disconnected caller; on reconnect
245
+ * the server-side session is fresh, so any cached cancel/result frames
246
+ * the server tried to deliver were lost). Fail pending immediately.
247
+ */
248
+ private failPendingOnDisconnect(): void {
249
+ this.disconnectGrace = undefined;
250
+ if (this.destroyed) return;
251
+ for (const [, p] of this.pending) {
252
+ if (p.deadlineTimer) clearTimeout(p.deadlineTimer);
253
+ this.settle(p, () =>
254
+ p.reject(new RpcError("HANDLER_GONE", "websocket disconnected")),
255
+ );
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Re-emit a `register` frame for every locally-known handler. Called on
261
+ * every `synced` event from the provider, since a reconnected WS gets a
262
+ * fresh server-side session with empty capability state.
263
+ */
264
+ private replayRegistrations(): void {
265
+ if (this.destroyed) return;
266
+ // A fresh server-side session will never ack any register frame issued
267
+ // before this `synced` event, so prior pending entries are dead. Resolve
268
+ // them with the current map clear so any awaiting `ready()` doesn't
269
+ // hang forever — the new register below is the source of truth now.
270
+ if (this.pendingRegistrations.size > 0) {
271
+ const stale = [...this.pendingRegistrations.values()];
272
+ this.pendingRegistrations.clear();
273
+ for (const r of stale) r();
274
+ }
275
+ if (this.handlers.size === 0) return;
276
+ const methods = [...this.handlers.keys()];
277
+ const id = uuidv7();
278
+ // Track this re-register so `ready()` resolves once the server ack lands.
279
+ this.pendingRegistrations.set(id, () => {});
280
+ this.send({ kind: "register", id, methods });
281
+ }
282
+
283
+ destroy(): void {
284
+ if (this.destroyed) return;
285
+ this.destroyed = true;
286
+ this.transport.off("stateless", this.onStatelessBound);
287
+ this.transport.off("synced", this.onSyncedBound);
288
+ this.transport.off("disconnect", this.onDisconnectBound);
289
+ this.transport.off("connect", this.onConnectBound);
290
+ this.cancelDisconnectGrace();
291
+ // Abort every in-flight handler.
292
+ for (const [, ac] of this.runningHandlers) {
293
+ try { ac.abort(); } catch { /* noop */ }
294
+ }
295
+ this.runningHandlers.clear();
296
+ // Reject every pending caller.
297
+ for (const [, p] of this.pending) {
298
+ if (p.deadlineTimer) clearTimeout(p.deadlineTimer);
299
+ p.reject(new RpcError("CANCELLED", "RpcClient destroyed"));
300
+ }
301
+ this.pending.clear();
302
+ // Unblock any awaiters of `ready()` — the WS is gone and no register
303
+ // ack will ever land. Resolving (vs rejecting) keeps `ready(): Promise<void>`
304
+ // semantically clean; the call sites that follow `ready()` are typically
305
+ // diagnostic/orchestration, not request hot paths.
306
+ for (const [, resolve] of this.pendingRegistrations) {
307
+ try { resolve(); } catch { /* noop */ }
308
+ }
309
+ this.pendingRegistrations.clear();
310
+ this.handlers.clear();
311
+ this.removeAllListeners();
312
+ }
313
+
314
+ // ── Caller side ───────────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Send an RPC and return a handle that resolves with the runner's result.
318
+ * Throws `RpcError` on nack / handler error / timeout / cancellation.
319
+ */
320
+ call<T = unknown>(method: string, args?: unknown, options?: RpcCallOptions): RpcCallHandle<T> {
321
+ const opts = options ?? {};
322
+ const id = uuidv7();
323
+ const target = opts.target ?? { kind: "role", role: "service" };
324
+
325
+ let resolve!: (value: unknown) => void;
326
+ let reject!: (err: Error) => void;
327
+ const promise = new Promise<unknown>((res, rej) => {
328
+ resolve = res;
329
+ reject = rej;
330
+ });
331
+
332
+ // Calling on a destroyed client used to silently hang because `send()`
333
+ // short-circuits but the pending entry was already created. Reject now
334
+ // and skip the rest so consumers always get a terminal outcome.
335
+ if (this.destroyed) {
336
+ const handle = promise as RpcCallHandle<T>;
337
+ Object.defineProperty(handle, "id", { value: id, enumerable: true });
338
+ Object.defineProperty(handle, "claimedBy", { enumerable: true, get: () => undefined });
339
+ handle.cancel = () => {};
340
+ reject(new RpcError("CANCELLED", "rpc client destroyed"));
341
+ return handle;
342
+ }
343
+
344
+ // Pre-aborted signal: no point sending the `req` (the runner would do
345
+ // wasted work for a caller that's already given up) and no point sending
346
+ // a `cancel` for an id the server has never seen. Short-circuit cleanly.
347
+ if (opts.signal?.aborted) {
348
+ const reason = opts.signal.reason;
349
+ const handle = promise as RpcCallHandle<T>;
350
+ Object.defineProperty(handle, "id", { value: id, enumerable: true });
351
+ Object.defineProperty(handle, "claimedBy", { enumerable: true, get: () => undefined });
352
+ handle.cancel = () => {};
353
+ reject(new RpcError("CANCELLED", typeof reason === "string" ? reason : "aborted"));
354
+ return handle;
355
+ }
356
+
357
+ const pending: PendingRpc = {
358
+ id,
359
+ resolve,
360
+ reject,
361
+ options: opts,
362
+ state: "pending",
363
+ claimedBy: undefined,
364
+ deadlineTimer: undefined,
365
+ };
366
+ this.pending.set(id, pending);
367
+
368
+ // Local fallback timeout so we don't wait past `deadline` if the server
369
+ // somehow goes silent (it shouldn't — server enforces its own).
370
+ const deadline = opts.deadline ?? 30_000;
371
+ pending.deadlineTimer = setTimeout(() => {
372
+ if (pending.state !== "settled") {
373
+ this.settle(pending, () =>
374
+ reject(new RpcError("TIMEOUT", "rpc deadline exceeded (client-side)")),
375
+ );
376
+ }
377
+ }, deadline + 5_000); // grace window so server-side TIMEOUT lands first
378
+
379
+ // Pre-aborted signals were handled above (no req frame sent). For a live
380
+ // signal, listen for abort and cancel mid-flight when it trips.
381
+ if (opts.signal) {
382
+ opts.signal.addEventListener(
383
+ "abort",
384
+ () => this.abortInFlight(pending, opts.signal?.reason),
385
+ { once: true },
386
+ );
387
+ }
388
+
389
+ const frame: RpcFrame = {
390
+ kind: "req",
391
+ id,
392
+ ts: Date.now(),
393
+ method,
394
+ args,
395
+ to: serializeTarget(target),
396
+ deadline_ms: deadline,
397
+ };
398
+ this.send(frame);
399
+
400
+ const handle = promise as RpcCallHandle<T>;
401
+ Object.defineProperty(handle, "id", { value: id, enumerable: true });
402
+ Object.defineProperty(handle, "claimedBy", {
403
+ enumerable: true,
404
+ get: () => pending.claimedBy,
405
+ });
406
+ handle.cancel = (reason?: string) => this.abortInFlight(pending, reason);
407
+ return handle;
408
+ }
409
+
410
+ private abortInFlight(pending: PendingRpc, reason?: unknown): void {
411
+ if (pending.state === "settled") return;
412
+ // Tell the runner we're cancelling.
413
+ this.send({ kind: "cancel", id: pending.id, ts: Date.now(), data: reason ? { reason: String(reason) } : undefined });
414
+ // Reject locally; the server may still emit a CANCELLED result later
415
+ // (we'll see it but the pending is gone, so it's a no-op).
416
+ this.settle(pending, () =>
417
+ pending.reject(new RpcError("CANCELLED", typeof reason === "string" ? reason : "cancelled")),
418
+ );
419
+ }
420
+
421
+ // ── Runner side ───────────────────────────────────────────────────────────
422
+
423
+ /**
424
+ * Register a handler for `method` and advertise it to the server so
425
+ * incoming `to: role:service` calls resolve here. Returns an unsubscribe
426
+ * function. Calling `register` twice for the same method replaces the
427
+ * handler and re-advertises.
428
+ *
429
+ * The advertise is fire-and-forget; if you need to wait until the server
430
+ * has acknowledged registration (typical in tests with a separate caller
431
+ * connection), `await rpc.ready()` after registering all handlers.
432
+ */
433
+ handle(method: string, handler: RpcHandler): () => void {
434
+ this.handlers.set(method, handler);
435
+ const id = uuidv7();
436
+ this.pendingRegistrations.set(id, () => {
437
+ // Default no-op; overwritten when ready() is awaited.
438
+ });
439
+ this.send({ kind: "register", id, methods: [method] });
440
+ return () => {
441
+ if (this.handlers.get(method) === handler) {
442
+ this.handlers.delete(method);
443
+ this.send({ kind: "unregister", id: uuidv7(), methods: [method] });
444
+ }
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Resolve once every outstanding `register` frame has been acknowledged
450
+ * by the server. Useful when a separate connection is about to issue
451
+ * `rpc.call(...)` and you need the capability registry to be live first.
452
+ * Resolves immediately if there's nothing pending.
453
+ */
454
+ ready(): Promise<void> {
455
+ if (this.pendingRegistrations.size === 0) return Promise.resolve();
456
+ const ids = [...this.pendingRegistrations.keys()];
457
+ return Promise.all(
458
+ ids.map(
459
+ (id) =>
460
+ new Promise<void>((resolve) => {
461
+ this.pendingRegistrations.set(id, resolve);
462
+ }),
463
+ ),
464
+ ).then(() => undefined);
465
+ }
466
+
467
+ // ── Wire ───────────────────────────────────────────────────────────────────
468
+
469
+ private send(frame: Partial<RpcFrame> & Pick<RpcFrame, "kind" | "id">): void {
470
+ if (this.destroyed) return;
471
+ const payload = `${RPC_PREFIX}${JSON.stringify(frame)}`;
472
+ this.transport.sendStateless(payload);
473
+ }
474
+
475
+ private receive(payload: string): void {
476
+ if (!payload.startsWith(RPC_PREFIX)) return;
477
+ const json = payload.slice(RPC_PREFIX.length);
478
+ let frame: RpcFrame;
479
+ try {
480
+ frame = JSON.parse(json) as RpcFrame;
481
+ } catch {
482
+ return;
483
+ }
484
+ switch (frame.kind) {
485
+ case "ack": return this.onAck(frame);
486
+ case "progress": return this.onProgress(frame);
487
+ case "result": return this.onResult(frame);
488
+ case "nack": return this.onNack(frame);
489
+ case "req": return void this.onReq(frame);
490
+ case "cancel": return this.onCancel(frame);
491
+ // hb / register / unregister never travel client-bound.
492
+ default: return;
493
+ }
494
+ }
495
+
496
+ private onAck(frame: RpcFrame): void {
497
+ // Register-ack: server confirmed a `register` frame.
498
+ const pendingReg = this.pendingRegistrations.get(frame.id);
499
+ if (pendingReg) {
500
+ this.pendingRegistrations.delete(frame.id);
501
+ pendingReg();
502
+ return;
503
+ }
504
+ const pending = this.pending.get(frame.id);
505
+ if (!pending) return;
506
+ if (frame.by === "server" && pending.state === "pending") {
507
+ pending.state = "accepted";
508
+ } else if (frame.by === "runner") {
509
+ pending.state = "claimed";
510
+ pending.claimedBy = frame.runner_id;
511
+ pending.options.onClaim?.(frame.runner_id ?? "");
512
+ }
513
+ }
514
+
515
+ private onProgress(frame: RpcFrame): void {
516
+ const pending = this.pending.get(frame.id);
517
+ if (!pending) return;
518
+ pending.options.onProgress?.(frame.data, frame.seq ?? 0);
519
+ }
520
+
521
+ private onResult(frame: RpcFrame): void {
522
+ const pending = this.pending.get(frame.id);
523
+ if (!pending) return;
524
+ if (frame.error) {
525
+ const err = new RpcError(frame.error.code, frame.error.message, frame.error.details);
526
+ this.settle(pending, () => pending.reject(err));
527
+ } else {
528
+ this.settle(pending, () => pending.resolve(frame.data));
529
+ }
530
+ }
531
+
532
+ private onNack(frame: RpcFrame): void {
533
+ const pending = this.pending.get(frame.id);
534
+ if (!pending) return;
535
+ const err = frame.error
536
+ ? new RpcError(frame.error.code, frame.error.message, frame.error.details)
537
+ : new RpcError("INTERNAL", "rpc nack");
538
+ this.settle(pending, () => pending.reject(err));
539
+ }
540
+
541
+ private settle(pending: PendingRpc, finalize: () => void): void {
542
+ if (pending.state === "settled") return;
543
+ pending.state = "settled";
544
+ if (pending.deadlineTimer) clearTimeout(pending.deadlineTimer);
545
+ this.pending.delete(pending.id);
546
+ finalize();
547
+ }
548
+
549
+ // ── Inbound request (runner side) ─────────────────────────────────────────
550
+
551
+ private async onReq(frame: RpcFrame): Promise<void> {
552
+ const method = frame.method ?? "";
553
+ const handler = this.handlers.get(method);
554
+ if (!handler) {
555
+ // Server should have nacked NO_HANDLER before we got here; ignore.
556
+ return;
557
+ }
558
+ if (this.runningHandlers.has(frame.id)) {
559
+ // Duplicate req — server retry path. Server will re-emit the cached
560
+ // terminal frame on its own; we just don't double-invoke.
561
+ return;
562
+ }
563
+ const ac = new AbortController();
564
+ this.runningHandlers.set(frame.id, ac);
565
+
566
+ // Send the runner-claim ack immediately.
567
+ this.send({
568
+ kind: "ack",
569
+ id: frame.id,
570
+ ts: Date.now(),
571
+ by: "runner",
572
+ });
573
+
574
+ const ctx: RpcHandlerContext = {
575
+ id: frame.id,
576
+ method,
577
+ from: frame.from ?? "",
578
+ signal: ac.signal,
579
+ };
580
+
581
+ // Heartbeat while the handler runs. The server doesn't enforce
582
+ // inactivity in v1 but we send these so tooling can observe liveness
583
+ // and a v2 server can fail stalled calls.
584
+ const hbInterval = setInterval(() => {
585
+ if (!ac.signal.aborted) {
586
+ this.send({ kind: "hb", id: frame.id, ts: Date.now() });
587
+ }
588
+ }, 5_000);
589
+
590
+ try {
591
+ const ret = handler(frame.args, ctx);
592
+ if (ret && typeof (ret as AsyncGenerator).next === "function" && typeof (ret as AsyncGenerator)[Symbol.asyncIterator] === "function") {
593
+ // Generator: stream progress, return final.
594
+ const gen = ret as AsyncGenerator<unknown, unknown, void>;
595
+ let seq = 0;
596
+ let final: unknown = undefined;
597
+ while (true) {
598
+ const { value, done } = await gen.next();
599
+ if (done) {
600
+ final = value;
601
+ break;
602
+ }
603
+ if (ac.signal.aborted) break;
604
+ this.send({ kind: "progress", id: frame.id, ts: Date.now(), seq: seq++, data: value });
605
+ }
606
+ if (ac.signal.aborted) {
607
+ this.send({
608
+ kind: "result",
609
+ id: frame.id,
610
+ ts: Date.now(),
611
+ error: { code: "CANCELLED", message: "handler cancelled" },
612
+ });
613
+ } else {
614
+ this.send({ kind: "result", id: frame.id, ts: Date.now(), data: final ?? null });
615
+ }
616
+ } else {
617
+ const value = await (ret as Promise<unknown>);
618
+ if (ac.signal.aborted) {
619
+ this.send({
620
+ kind: "result",
621
+ id: frame.id,
622
+ ts: Date.now(),
623
+ error: { code: "CANCELLED", message: "handler cancelled" },
624
+ });
625
+ } else {
626
+ this.send({ kind: "result", id: frame.id, ts: Date.now(), data: value ?? null });
627
+ }
628
+ }
629
+ } catch (err) {
630
+ const error =
631
+ err instanceof RpcError
632
+ ? { code: err.code, message: err.message, details: err.details }
633
+ : { code: "INTERNAL", message: err instanceof Error ? err.message : String(err) };
634
+ this.send({ kind: "result", id: frame.id, ts: Date.now(), error });
635
+ } finally {
636
+ clearInterval(hbInterval);
637
+ this.runningHandlers.delete(frame.id);
638
+ }
639
+ }
640
+
641
+ private onCancel(frame: RpcFrame): void {
642
+ // The runner only ever observes `cancel` for an id whose `req` it has
643
+ // already seen — frame ordering on a single WS preserves the
644
+ // (req, cancel) sequence the server emits. If `runningHandlers` has
645
+ // no entry for this id, the handler has already completed (cancel
646
+ // arrived after result), so the cancel is informational and can be
647
+ // dropped silently.
648
+ const ac = this.runningHandlers.get(frame.id);
649
+ if (ac) ac.abort(frame.error?.message ?? "cancelled by caller");
650
+ }
651
+ }
652
+
653
+ function serializeTarget(t: RpcTarget): string {
654
+ switch (t.kind) {
655
+ case "role": return `role:${t.role}`;
656
+ case "user": return `user:${t.userId}`;
657
+ case "runner": return `runner:${t.sessionId}`;
658
+ }
659
+ }