@abraca/dabra 1.8.2 → 2.0.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/abracadabra-provider.cjs +12722 -9050
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12683 -9061
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1485 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +51 -2
- package/src/AbracadabraClient.ts +516 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +228 -0
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +1862 -0
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +628 -0
- package/src/DocUtils.ts +89 -0
- package/src/DocumentManager.ts +319 -0
- package/src/E2EAbracadabraProvider.ts +189 -0
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/FileBlobStore.ts +10 -0
- package/src/IdentityDoc.ts +25 -0
- package/src/MetaManager.ts +100 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +473 -0
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +174 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
package/src/RpcClient.ts
ADDED
|
@@ -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
|
+
}
|