@graphorin/client 0.5.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/CHANGELOG.md +17 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +114 -0
- package/dist/errors.js.map +1 -0
- package/dist/graphorin-client.d.ts +180 -0
- package/dist/graphorin-client.d.ts.map +1 -0
- package/dist/graphorin-client.js +594 -0
- package/dist/graphorin-client.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- package/dist/reconnect.d.ts +54 -0
- package/dist/reconnect.d.ts.map +1 -0
- package/dist/reconnect.js +53 -0
- package/dist/reconnect.js.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.js +4 -0
- package/dist/transport/sse.d.ts +15 -0
- package/dist/transport/sse.d.ts.map +1 -0
- package/dist/transport/sse.js +149 -0
- package/dist/transport/sse.js.map +1 -0
- package/dist/transport/types.d.ts +93 -0
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/transport/ws.d.ts +17 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +149 -0
- package/dist/transport/ws.js.map +1 -0
- package/package.json +87 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { ClientAbortedError, ClientNotConnectedError, GraphorinClientError, ProtocolViolationError, TransportFailedError, kindForRpcCode } from "./errors.js";
|
|
2
|
+
import { computeBackoffMs, sleep } from "./reconnect.js";
|
|
3
|
+
import { openSseTransport } from "./transport/sse.js";
|
|
4
|
+
import { openWebSocketTransport } from "./transport/ws.js";
|
|
5
|
+
import { isErrorFrame, isEventFrame, isLifecycleFrame, isPongFrame, isReplayMarkerFrame, isRpcFailure, isRpcSuccess, isSubscribedFrame, isUnsubscribedFrame } from "@graphorin/protocol";
|
|
6
|
+
|
|
7
|
+
//#region src/graphorin-client.ts
|
|
8
|
+
/**
|
|
9
|
+
* `GraphorinClient` — ergonomic façade over the
|
|
10
|
+
* {@link Transport} contract. Handles:
|
|
11
|
+
*
|
|
12
|
+
* - WS handshake (`openWebSocketTransport`) with optional ticket flow.
|
|
13
|
+
* - Optional SSE fallback (`openSseTransport`) for environments
|
|
14
|
+
* that block WebSocket upgrades.
|
|
15
|
+
* - JSON-RPC request / response correlation for `subscribe` /
|
|
16
|
+
* `unsubscribe` / `cancel` / `resume` / `ping` calls.
|
|
17
|
+
* - Async-iterable subscriptions (`for await (const event of
|
|
18
|
+
* sub.events())`).
|
|
19
|
+
* - Exponential-backoff reconnect with `lastEventId` resume against
|
|
20
|
+
* the server replay buffer.
|
|
21
|
+
*
|
|
22
|
+
* The class is intentionally small (≈ 400 LOC) so the production
|
|
23
|
+
* cross-cuts (telemetry, sticky reconnect on transient errors,
|
|
24
|
+
* load-shedding) live in higher-level wrappers consumers build on
|
|
25
|
+
* top of `GraphorinClient` instead of leaking into the protocol
|
|
26
|
+
* adapter itself.
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* @stable
|
|
32
|
+
*/
|
|
33
|
+
var GraphorinClient = class {
|
|
34
|
+
#options;
|
|
35
|
+
#pending = /* @__PURE__ */ new Map();
|
|
36
|
+
#subscriptions = /* @__PURE__ */ new Map();
|
|
37
|
+
/** IP-3: the synthetic single subscription an SSE connection carries. */
|
|
38
|
+
#sseSubscription;
|
|
39
|
+
#transport;
|
|
40
|
+
#idCounter = 0;
|
|
41
|
+
#closed = false;
|
|
42
|
+
#connectingPromise;
|
|
43
|
+
#abortController = new AbortController();
|
|
44
|
+
constructor(options) {
|
|
45
|
+
if (typeof options.baseUrl !== "string" || options.baseUrl.length === 0) throw new TypeError("GraphorinClient: baseUrl must be a non-empty string.");
|
|
46
|
+
this.#options = options;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Open the underlying transport. Resolves once the server has
|
|
50
|
+
* accepted the handshake (`'open'`); rejects with a typed
|
|
51
|
+
* {@link GraphorinClientError} otherwise.
|
|
52
|
+
*
|
|
53
|
+
* Calling `connect()` while already connected is a no-op; calling
|
|
54
|
+
* it during another `connect()` returns the same promise.
|
|
55
|
+
*/
|
|
56
|
+
async connect() {
|
|
57
|
+
if (this.#closed) throw new ClientAbortedError("GraphorinClient was disconnected; create a new instance.");
|
|
58
|
+
if (this.#transport !== void 0) return;
|
|
59
|
+
if (this.#connectingPromise !== void 0) return this.#connectingPromise;
|
|
60
|
+
this.#connectingPromise = (async () => {
|
|
61
|
+
const preference = this.#options.transport ?? "auto";
|
|
62
|
+
try {
|
|
63
|
+
if (preference === "ws" || preference === "auto") try {
|
|
64
|
+
this.#transport = await this.#openWs();
|
|
65
|
+
await this.#initializeRpc();
|
|
66
|
+
return;
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (preference === "ws") throw err;
|
|
69
|
+
}
|
|
70
|
+
this.#transport = await this.#openSse();
|
|
71
|
+
} finally {
|
|
72
|
+
this.#connectingPromise = void 0;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
return this.#connectingPromise;
|
|
76
|
+
}
|
|
77
|
+
async #initializeRpc() {
|
|
78
|
+
if (this.#transport?.kind !== "ws") return;
|
|
79
|
+
await this.#sendRpc("initialize", { clientInfo: {
|
|
80
|
+
name: "graphorin-client",
|
|
81
|
+
version: "0.5.0"
|
|
82
|
+
} });
|
|
83
|
+
}
|
|
84
|
+
/** Send a `ping` RPC and resolve when the server replies with `pong`. */
|
|
85
|
+
async ping() {
|
|
86
|
+
await this.#sendRpc("ping");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Subscribe to a server-side event stream. Resolves with a
|
|
90
|
+
* {@link Subscription} once the server confirms with the matching
|
|
91
|
+
* `subscribed` frame; rejects when the server returns an
|
|
92
|
+
* `error` instead.
|
|
93
|
+
*/
|
|
94
|
+
async subscribe(target, opts) {
|
|
95
|
+
if (this.#transport === void 0) throw new ClientNotConnectedError();
|
|
96
|
+
if (this.#transport.kind === "sse") {
|
|
97
|
+
const subject$1 = subjectFor(target);
|
|
98
|
+
const boundSubject = `session:${this.#options.sessionId ?? ""}/events`;
|
|
99
|
+
if (subject$1 !== boundSubject) throw new TransportFailedError(`subscribe('${subject$1}') requires the WebSocket transport — the SSE fallback carries only the bound session stream ('${boundSubject}').`);
|
|
100
|
+
if (this.#sseSubscription === void 0) {
|
|
101
|
+
this.#sseSubscription = this.#createSubscription({
|
|
102
|
+
subscriptionId: "__sse__",
|
|
103
|
+
subject: subject$1,
|
|
104
|
+
target,
|
|
105
|
+
snapshotEventId: void 0
|
|
106
|
+
});
|
|
107
|
+
this.#subscriptions.set("__sse__", this.#sseSubscription);
|
|
108
|
+
}
|
|
109
|
+
return this.#sseSubscription;
|
|
110
|
+
}
|
|
111
|
+
const subject = subjectFor(target);
|
|
112
|
+
const lastEventId = opts?.sinceEventId ?? this.#transport.lastEventId;
|
|
113
|
+
const params = { subject };
|
|
114
|
+
if (lastEventId !== void 0) params.sinceEventId = lastEventId;
|
|
115
|
+
const result = await this.#sendRpc("subscription.subscribe", params);
|
|
116
|
+
const subscriptionId = typeof result.subscriptionId === "string" && result.subscriptionId.length > 0 ? result.subscriptionId : void 0;
|
|
117
|
+
if (subscriptionId === void 0) throw new ProtocolViolationError("Server subscribe reply missing subscriptionId.");
|
|
118
|
+
const snapshotEventId = typeof result.snapshotEventId === "string" ? result.snapshotEventId : void 0;
|
|
119
|
+
const sub = this.#createSubscription({
|
|
120
|
+
subscriptionId,
|
|
121
|
+
subject,
|
|
122
|
+
target,
|
|
123
|
+
snapshotEventId
|
|
124
|
+
});
|
|
125
|
+
this.#subscriptions.set(subscriptionId, sub);
|
|
126
|
+
return sub;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Cancel a server-side run. Sends the `run.cancel` RPC and
|
|
130
|
+
* resolves with the server's `result` payload (typically
|
|
131
|
+
* `{ cancelled: true, partialStateAvailable: true }`).
|
|
132
|
+
*/
|
|
133
|
+
async cancel(runId, opts = {}) {
|
|
134
|
+
if (typeof runId !== "string" || runId.length === 0) throw new TypeError("cancel: runId must be a non-empty string.");
|
|
135
|
+
const params = { runId };
|
|
136
|
+
if (opts.drain !== void 0) params.drain = opts.drain;
|
|
137
|
+
if (opts.reason !== void 0) params.reason = opts.reason;
|
|
138
|
+
if (opts.onPendingApprovals !== void 0) params.onPendingApprovals = opts.onPendingApprovals;
|
|
139
|
+
return this.#sendRpc("run.cancel", params);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Resume a paused (HITL) run. The WebSocket protocol intentionally
|
|
143
|
+
* does NOT carry a `resume` control message — resumes are durable
|
|
144
|
+
* + idempotent + body-carrying, which maps onto the REST endpoint
|
|
145
|
+
* `POST /v1/runs/:runId/resume`. NOTE (IP-14): the server endpoint
|
|
146
|
+
* currently answers **501** — server-side durable resume is not
|
|
147
|
+
* implemented yet. Library-mode callers resume directly:
|
|
148
|
+
* `agent.run(result.state, { directive })`.
|
|
149
|
+
*/
|
|
150
|
+
async resume(runId, directive, opts = {}) {
|
|
151
|
+
if (typeof runId !== "string" || runId.length === 0) throw new TypeError("resume: runId must be a non-empty string.");
|
|
152
|
+
const fetchImpl = this.#options.fetch ?? globalThis.fetch;
|
|
153
|
+
if (typeof fetchImpl !== "function") throw new TransportFailedError("No fetch implementation available; pass `fetch` via the client options.");
|
|
154
|
+
const url = joinUrl(this.#options.baseUrl, `/v1/runs/${encodeURIComponent(runId)}/resume`, { ws: false });
|
|
155
|
+
const headers = { "Content-Type": "application/json" };
|
|
156
|
+
if (this.#options.auth.kind === "bearer") headers.Authorization = `Bearer ${this.#options.auth.token}`;
|
|
157
|
+
if (opts.idempotencyKey !== void 0) headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
158
|
+
const res = await fetchImpl(url, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers,
|
|
161
|
+
body: JSON.stringify(directive === void 0 ? {} : { directive })
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) throw new TransportFailedError(`resume(${runId}): server responded ${res.status} ${res.statusText}.`, { code: res.status });
|
|
164
|
+
try {
|
|
165
|
+
return await res.json();
|
|
166
|
+
} catch {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Send an MCP-compatible cancellation notification. Does not wait
|
|
172
|
+
* for a server reply (notifications have no `id`).
|
|
173
|
+
*/
|
|
174
|
+
cancelNotify(requestId) {
|
|
175
|
+
if (this.#transport === void 0) throw new ClientNotConnectedError();
|
|
176
|
+
if (typeof requestId !== "string" || requestId.length === 0) throw new TypeError("cancelNotify: requestId must be a non-empty string.");
|
|
177
|
+
const frame = {
|
|
178
|
+
v: "1",
|
|
179
|
+
jsonrpc: "2.0",
|
|
180
|
+
method: "notifications/cancelled",
|
|
181
|
+
params: { requestId }
|
|
182
|
+
};
|
|
183
|
+
this.#transport.send(frame);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Disconnect the underlying transport and abort every pending RPC
|
|
187
|
+
* + subscription. Idempotent.
|
|
188
|
+
*/
|
|
189
|
+
async disconnect() {
|
|
190
|
+
if (this.#closed) return;
|
|
191
|
+
this.#closed = true;
|
|
192
|
+
const transport = this.#transport;
|
|
193
|
+
this.#transport = void 0;
|
|
194
|
+
this.#abortController.abort(new ClientAbortedError());
|
|
195
|
+
for (const pending of this.#pending.values()) pending.reject(new ClientAbortedError("Client disconnected before reply."));
|
|
196
|
+
this.#pending.clear();
|
|
197
|
+
for (const sub of this.#subscriptions.values()) sub.__close("aborted", new ClientAbortedError("Client disconnected."));
|
|
198
|
+
this.#subscriptions.clear();
|
|
199
|
+
if (transport !== void 0) try {
|
|
200
|
+
transport.close(1e3, "client-disconnect");
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
/** Return the active transport kind (or `undefined` if not connected). */
|
|
204
|
+
get transportKind() {
|
|
205
|
+
return this.#transport?.kind;
|
|
206
|
+
}
|
|
207
|
+
async #openWs() {
|
|
208
|
+
return await openWebSocketTransport({
|
|
209
|
+
url: this.#wsUrl(),
|
|
210
|
+
auth: this.#options.auth,
|
|
211
|
+
...this.#options.WebSocket !== void 0 ? { WebSocket: this.#options.WebSocket } : {},
|
|
212
|
+
...this.#options.clientId !== void 0 ? { clientId: this.#options.clientId } : {}
|
|
213
|
+
}, this.#listeners("ws"));
|
|
214
|
+
}
|
|
215
|
+
async #openSse() {
|
|
216
|
+
if (this.#options.auth.kind !== "bearer") throw new TransportFailedError("SSE fallback requires the 'bearer' auth strategy. The WebSocket ticket flow does not extend to EventSource.");
|
|
217
|
+
return await openSseTransport({
|
|
218
|
+
url: this.#sseUrl(),
|
|
219
|
+
auth: this.#options.auth,
|
|
220
|
+
...this.#options.fetch !== void 0 ? { fetch: this.#options.fetch } : {},
|
|
221
|
+
...this.#options.clientId !== void 0 ? { clientId: this.#options.clientId } : {}
|
|
222
|
+
}, this.#listeners("sse"));
|
|
223
|
+
}
|
|
224
|
+
#wsUrl() {
|
|
225
|
+
const path = this.#options.wsPath ?? "/v1/ws";
|
|
226
|
+
return joinUrl(this.#options.baseUrl, path, { ws: true });
|
|
227
|
+
}
|
|
228
|
+
#sseUrl() {
|
|
229
|
+
const template = this.#options.sseSessionPath ?? "/v1/sessions/:sessionId/events";
|
|
230
|
+
if (template.includes(":sessionId")) {
|
|
231
|
+
const sessionId = this.#options.sessionId;
|
|
232
|
+
if (sessionId === void 0 || sessionId.length === 0) throw new TransportFailedError("The SSE fallback needs a session binding — pass `sessionId` in the client options (it fills ':sessionId' in sseSessionPath).");
|
|
233
|
+
return joinUrl(this.#options.baseUrl, template.replace(":sessionId", encodeURIComponent(sessionId)), { ws: false });
|
|
234
|
+
}
|
|
235
|
+
return joinUrl(this.#options.baseUrl, template, { ws: false });
|
|
236
|
+
}
|
|
237
|
+
#listeners(kind) {
|
|
238
|
+
return {
|
|
239
|
+
onOpen: () => {},
|
|
240
|
+
onFrame: (frame) => this.#handleFrame(frame),
|
|
241
|
+
onError: (err) => this.#handleTransportError(err, kind),
|
|
242
|
+
onClose: (reason) => this.#handleTransportClose(reason, kind)
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
#handleFrame(frame) {
|
|
246
|
+
if (isRpcSuccess(frame)) {
|
|
247
|
+
const pending = this.#pending.get(frame.id);
|
|
248
|
+
if (pending !== void 0) {
|
|
249
|
+
this.#pending.delete(frame.id);
|
|
250
|
+
pending.resolve(frame.result);
|
|
251
|
+
}
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (isRpcFailure(frame)) {
|
|
255
|
+
const pending = this.#pending.get(frame.id);
|
|
256
|
+
if (pending !== void 0) {
|
|
257
|
+
this.#pending.delete(frame.id);
|
|
258
|
+
pending.reject(new GraphorinClientError(kindForRpcCode(frame.error.code), `Server returned an error for RPC '${frame.id}': ${frame.error.message} (code=${frame.error.code}).`));
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (isSubscribedFrame(frame)) return;
|
|
263
|
+
if (isUnsubscribedFrame(frame)) {
|
|
264
|
+
const sub = this.#subscriptions.get(frame.subscriptionId);
|
|
265
|
+
if (sub !== void 0) {
|
|
266
|
+
sub.__close("unsubscribed");
|
|
267
|
+
this.#subscriptions.delete(frame.subscriptionId);
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (isPongFrame(frame)) return;
|
|
272
|
+
if (isLifecycleFrame(frame)) {
|
|
273
|
+
const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
|
|
274
|
+
if (sub !== void 0) sub.__pushLifecycle(frame);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (isReplayMarkerFrame(frame)) {
|
|
278
|
+
const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
|
|
279
|
+
if (sub !== void 0) sub.__pushReplayMarker(frame);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (isEventFrame(frame)) {
|
|
283
|
+
const sub = this.#subscriptions.get(frame.subscriptionId) ?? this.#sseFallback();
|
|
284
|
+
if (sub !== void 0) sub.__push(frame);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (isErrorFrame(frame)) {
|
|
288
|
+
const target = frame.subscriptionId !== void 0 ? this.#subscriptions.get(frame.subscriptionId) : void 0;
|
|
289
|
+
const error = new GraphorinClientError("protocol-violation", `Server error frame: ${frame.message} (code=${frame.code}).`);
|
|
290
|
+
if (target !== void 0) {
|
|
291
|
+
target.__close("aborted", error);
|
|
292
|
+
if (frame.subscriptionId !== void 0) this.#subscriptions.delete(frame.subscriptionId);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* IP-3: on the SSE transport every frame belongs to the single
|
|
298
|
+
* implicit session subscription regardless of the server-generated
|
|
299
|
+
* subscriptionId it carries.
|
|
300
|
+
*/
|
|
301
|
+
#sseFallback() {
|
|
302
|
+
return this.#transport?.kind === "sse" ? this.#sseSubscription : void 0;
|
|
303
|
+
}
|
|
304
|
+
#handleTransportError(err, _kind) {
|
|
305
|
+
for (const pending of this.#pending.values()) pending.reject(err);
|
|
306
|
+
this.#pending.clear();
|
|
307
|
+
}
|
|
308
|
+
async #handleTransportClose(reason, _kind) {
|
|
309
|
+
if (this.#closed) return;
|
|
310
|
+
this.#transport = void 0;
|
|
311
|
+
const closeError = new TransportFailedError(`Transport closed: ${reason.reason || reason.graphorinReason || "unknown"} (code=${reason.code}).`, { code: reason.code });
|
|
312
|
+
for (const pending of this.#pending.values()) pending.reject(closeError);
|
|
313
|
+
this.#pending.clear();
|
|
314
|
+
if (reason.graphorinReason === "auth.required" || reason.graphorinReason === "auth.invalid" || reason.graphorinReason === "auth.revoked" || reason.graphorinReason === "protocol.violation") {
|
|
315
|
+
for (const sub of this.#subscriptions.values()) sub.__close("aborted", closeError);
|
|
316
|
+
this.#subscriptions.clear();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
await this.#reconnect();
|
|
320
|
+
}
|
|
321
|
+
async #reconnect() {
|
|
322
|
+
if (this.#closed) return;
|
|
323
|
+
let attempt = 0;
|
|
324
|
+
while (!this.#closed) {
|
|
325
|
+
attempt += 1;
|
|
326
|
+
const delay = computeBackoffMs(attempt, this.#options.reconnect);
|
|
327
|
+
if (delay === null) {
|
|
328
|
+
const exhausted = new TransportFailedError(`Reconnect attempts exhausted after ${attempt - 1} retries.`);
|
|
329
|
+
for (const sub of this.#subscriptions.values()) sub.__close("aborted", exhausted);
|
|
330
|
+
this.#subscriptions.clear();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
await sleep(delay, this.#abortController.signal);
|
|
335
|
+
await this.connect();
|
|
336
|
+
if (this.#transport === void 0) continue;
|
|
337
|
+
for (const [oldId, sub] of [...this.#subscriptions]) {
|
|
338
|
+
this.#subscriptions.delete(oldId);
|
|
339
|
+
try {
|
|
340
|
+
const cursor = sub.__lastEventId();
|
|
341
|
+
const result = await this.#sendRpc("subscription.subscribe", {
|
|
342
|
+
subject: sub.__subject(),
|
|
343
|
+
...cursor !== void 0 ? { sinceEventId: cursor } : {}
|
|
344
|
+
});
|
|
345
|
+
const newId = typeof result.subscriptionId === "string" && result.subscriptionId.length > 0 ? result.subscriptionId : void 0;
|
|
346
|
+
if (newId === void 0) throw new ProtocolViolationError("Server resubscribe reply missing subscriptionId.");
|
|
347
|
+
sub.__rebind(newId);
|
|
348
|
+
this.#subscriptions.set(newId, sub);
|
|
349
|
+
} catch (err) {
|
|
350
|
+
sub.__close("aborted", err instanceof Error ? err : new Error(String(err)));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
} catch (err) {
|
|
355
|
+
if (this.#closed) return;
|
|
356
|
+
if (err instanceof ClientAbortedError) return;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
#createSubscription(args) {
|
|
361
|
+
const queue = [];
|
|
362
|
+
const lifecycle = [];
|
|
363
|
+
const replayMarkers = [];
|
|
364
|
+
const waiters = [];
|
|
365
|
+
let closed = false;
|
|
366
|
+
let closeError;
|
|
367
|
+
let lastEventId = args.snapshotEventId;
|
|
368
|
+
const push = (frame) => {
|
|
369
|
+
lastEventId = frame.eventId;
|
|
370
|
+
const waiter = waiters.shift();
|
|
371
|
+
if (waiter !== void 0) {
|
|
372
|
+
waiter.resolve({
|
|
373
|
+
value: frame,
|
|
374
|
+
done: false
|
|
375
|
+
});
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
queue.push(frame);
|
|
379
|
+
};
|
|
380
|
+
const close = (reason, err) => {
|
|
381
|
+
if (closed) return;
|
|
382
|
+
closed = true;
|
|
383
|
+
if (err !== void 0) closeError = err;
|
|
384
|
+
else if (reason === "aborted") closeError = new ClientAbortedError();
|
|
385
|
+
while (waiters.length > 0) {
|
|
386
|
+
const waiter = waiters.shift();
|
|
387
|
+
if (waiter === void 0) continue;
|
|
388
|
+
if (closeError !== void 0) waiter.reject(closeError);
|
|
389
|
+
else waiter.resolve({
|
|
390
|
+
value: void 0,
|
|
391
|
+
done: true
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
return {
|
|
396
|
+
get subscriptionId() {
|
|
397
|
+
return args.subscriptionId;
|
|
398
|
+
},
|
|
399
|
+
subject: args.subject,
|
|
400
|
+
events: () => ({ [Symbol.asyncIterator]: () => ({
|
|
401
|
+
next: () => {
|
|
402
|
+
const buffered = queue.shift();
|
|
403
|
+
if (buffered !== void 0) return Promise.resolve({
|
|
404
|
+
value: buffered,
|
|
405
|
+
done: false
|
|
406
|
+
});
|
|
407
|
+
if (closed) {
|
|
408
|
+
if (closeError !== void 0) return Promise.reject(closeError);
|
|
409
|
+
return Promise.resolve({
|
|
410
|
+
value: void 0,
|
|
411
|
+
done: true
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return new Promise((resolve, reject) => {
|
|
415
|
+
waiters.push({
|
|
416
|
+
resolve,
|
|
417
|
+
reject
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
},
|
|
421
|
+
return: () => {
|
|
422
|
+
close("unsubscribed");
|
|
423
|
+
return Promise.resolve({
|
|
424
|
+
value: void 0,
|
|
425
|
+
done: true
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}) }),
|
|
429
|
+
unsubscribe: async () => {
|
|
430
|
+
if (closed) return;
|
|
431
|
+
try {
|
|
432
|
+
await this.#sendRpc("subscription.unsubscribe", { subscriptionId: args.subscriptionId });
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (!(err instanceof ClientAbortedError)) throw err;
|
|
435
|
+
}
|
|
436
|
+
close("unsubscribed");
|
|
437
|
+
this.#subscriptions.delete(args.subscriptionId);
|
|
438
|
+
},
|
|
439
|
+
metadata: () => ({
|
|
440
|
+
id: args.subscriptionId,
|
|
441
|
+
subject: args.subject,
|
|
442
|
+
target: args.target,
|
|
443
|
+
snapshotEventId: args.snapshotEventId,
|
|
444
|
+
lastEventId,
|
|
445
|
+
closed
|
|
446
|
+
}),
|
|
447
|
+
__push: push,
|
|
448
|
+
__pushLifecycle: (frame) => {
|
|
449
|
+
lifecycle.push(frame);
|
|
450
|
+
if (frame.status === "completed" || frame.status === "aborted" || frame.status === "failed") {
|
|
451
|
+
close(frame.status === "completed" ? "unsubscribed" : "aborted");
|
|
452
|
+
this.#subscriptions.delete(args.subscriptionId);
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
__pushReplayMarker: (frame) => {
|
|
456
|
+
replayMarkers.push(frame);
|
|
457
|
+
},
|
|
458
|
+
__close: close,
|
|
459
|
+
__subject: () => args.subject,
|
|
460
|
+
__target: () => args.target,
|
|
461
|
+
__snapshotEventId: () => args.snapshotEventId,
|
|
462
|
+
__lastEventId: () => lastEventId,
|
|
463
|
+
__rebind: (newSubscriptionId) => {
|
|
464
|
+
args.subscriptionId = newSubscriptionId;
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
async #sendRpc(method, params) {
|
|
469
|
+
if (this.#transport === void 0) throw new ClientNotConnectedError();
|
|
470
|
+
const id = this.#nextId();
|
|
471
|
+
const frame = buildRpcFrame(method, id, params);
|
|
472
|
+
const timeoutMs = this.#options.rpcTimeoutMs;
|
|
473
|
+
return new Promise((resolve, reject) => {
|
|
474
|
+
let timer;
|
|
475
|
+
const clear = () => {
|
|
476
|
+
if (timer !== void 0) {
|
|
477
|
+
clearTimeout(timer);
|
|
478
|
+
timer = void 0;
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
const pending = {
|
|
482
|
+
resolve: (value) => {
|
|
483
|
+
clear();
|
|
484
|
+
resolve(value);
|
|
485
|
+
},
|
|
486
|
+
reject: (reason) => {
|
|
487
|
+
clear();
|
|
488
|
+
reject(reason);
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
this.#pending.set(id, pending);
|
|
492
|
+
if (timeoutMs !== void 0 && timeoutMs > 0) timer = setTimeout(() => {
|
|
493
|
+
if (this.#pending.get(id) === pending) {
|
|
494
|
+
this.#pending.delete(id);
|
|
495
|
+
reject(new TransportFailedError(`RPC '${method}' (id=${id}) timed out after ${timeoutMs}ms with no server reply.`));
|
|
496
|
+
}
|
|
497
|
+
}, timeoutMs);
|
|
498
|
+
try {
|
|
499
|
+
this.#transport.send(frame);
|
|
500
|
+
} catch (err) {
|
|
501
|
+
clear();
|
|
502
|
+
this.#pending.delete(id);
|
|
503
|
+
reject(err);
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
#nextId() {
|
|
508
|
+
this.#idCounter += 1;
|
|
509
|
+
return `${this.#options.clientId ?? "graphorin"}-${this.#idCounter.toString(36)}`;
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function subjectFor(target) {
|
|
513
|
+
switch (target.target) {
|
|
514
|
+
case "session":
|
|
515
|
+
assertNonEmpty(target.id, "session.id");
|
|
516
|
+
return `session:${target.id}/events`;
|
|
517
|
+
case "agent":
|
|
518
|
+
assertNonEmpty(target.id, "agent.id");
|
|
519
|
+
assertNonEmpty(target.runId, "agent.runId");
|
|
520
|
+
return `agent:${target.id}/runs/${target.runId}/events`;
|
|
521
|
+
case "run":
|
|
522
|
+
assertNonEmpty(target.runId, "run.runId");
|
|
523
|
+
if (target.sessionId !== void 0) {
|
|
524
|
+
assertNonEmpty(target.sessionId, "run.sessionId");
|
|
525
|
+
return `session:${target.sessionId}/runs/${target.runId}/events`;
|
|
526
|
+
}
|
|
527
|
+
return `run:${target.runId}/events`;
|
|
528
|
+
case "workflow":
|
|
529
|
+
assertNonEmpty(target.id, "workflow.id");
|
|
530
|
+
return `workflow:${target.id}/events`;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function assertNonEmpty(value, fieldName) {
|
|
534
|
+
if (typeof value !== "string" || value.length === 0) throw new TypeError(`${fieldName} must be a non-empty string.`);
|
|
535
|
+
}
|
|
536
|
+
function joinUrl(baseUrl, path, opts) {
|
|
537
|
+
let normalized = baseUrl;
|
|
538
|
+
if (opts.ws) {
|
|
539
|
+
if (normalized.startsWith("http://")) normalized = normalized.replace(/^http:\/\//, "ws://");
|
|
540
|
+
else if (normalized.startsWith("https://")) normalized = normalized.replace(/^https:\/\//, "wss://");
|
|
541
|
+
} else if (normalized.startsWith("ws://")) normalized = normalized.replace(/^ws:\/\//, "http://");
|
|
542
|
+
else if (normalized.startsWith("wss://")) normalized = normalized.replace(/^wss:\/\//, "https://");
|
|
543
|
+
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
|
544
|
+
const tail = path.startsWith("/") ? path : `/${path}`;
|
|
545
|
+
return `${normalized}${tail}`;
|
|
546
|
+
}
|
|
547
|
+
function buildRpcFrame(method, id, params) {
|
|
548
|
+
switch (method) {
|
|
549
|
+
case "initialize": return {
|
|
550
|
+
v: "1",
|
|
551
|
+
jsonrpc: "2.0",
|
|
552
|
+
id,
|
|
553
|
+
method: "initialize",
|
|
554
|
+
params: params ?? { clientInfo: {
|
|
555
|
+
name: "graphorin-client",
|
|
556
|
+
version: "0.5.0"
|
|
557
|
+
} }
|
|
558
|
+
};
|
|
559
|
+
case "subscription.subscribe": return {
|
|
560
|
+
v: "1",
|
|
561
|
+
jsonrpc: "2.0",
|
|
562
|
+
id,
|
|
563
|
+
method: "subscription.subscribe",
|
|
564
|
+
params: params ?? { subject: "" }
|
|
565
|
+
};
|
|
566
|
+
case "subscription.unsubscribe": return {
|
|
567
|
+
v: "1",
|
|
568
|
+
jsonrpc: "2.0",
|
|
569
|
+
id,
|
|
570
|
+
method: "subscription.unsubscribe",
|
|
571
|
+
params: params ?? { subscriptionId: "" }
|
|
572
|
+
};
|
|
573
|
+
case "run.cancel": return {
|
|
574
|
+
v: "1",
|
|
575
|
+
jsonrpc: "2.0",
|
|
576
|
+
id,
|
|
577
|
+
method: "run.cancel",
|
|
578
|
+
params: params ?? { runId: "" }
|
|
579
|
+
};
|
|
580
|
+
case "ping": return {
|
|
581
|
+
v: "1",
|
|
582
|
+
jsonrpc: "2.0",
|
|
583
|
+
id,
|
|
584
|
+
method: "ping",
|
|
585
|
+
...params !== void 0 ? { params } : {}
|
|
586
|
+
};
|
|
587
|
+
case "notifications/cancelled": throw new TypeError("buildRpcFrame: 'notifications/cancelled' is a notification, not an RPC.");
|
|
588
|
+
}
|
|
589
|
+
throw new TypeError(`Unknown RPC method '${method}'.`);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
//#endregion
|
|
593
|
+
export { GraphorinClient };
|
|
594
|
+
//# sourceMappingURL=graphorin-client.js.map
|