@langchain/langgraph-api 1.2.2 → 1.2.4

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.
Files changed (47) hide show
  1. package/dist/experimental/embed/protocol.mjs +86 -15
  2. package/dist/graph/load.d.mts +1 -1
  3. package/dist/graph/load.utils.d.mts +1 -1
  4. package/dist/protocol/service.d.mts +9 -1
  5. package/dist/protocol/service.mjs +65 -25
  6. package/package.json +7 -7
  7. package/dist/src/api/assistants.d.mts +0 -3
  8. package/dist/src/api/assistants.mjs +0 -194
  9. package/dist/src/api/meta.d.mts +0 -3
  10. package/dist/src/api/meta.mjs +0 -65
  11. package/dist/src/api/protocol.d.mts +0 -7
  12. package/dist/src/api/protocol.mjs +0 -157
  13. package/dist/src/api/runs.d.mts +0 -3
  14. package/dist/src/api/runs.mjs +0 -335
  15. package/dist/src/api/store.d.mts +0 -3
  16. package/dist/src/api/store.mjs +0 -111
  17. package/dist/src/api/threads.d.mts +0 -3
  18. package/dist/src/api/threads.mjs +0 -143
  19. package/dist/src/graph/load.utils.d.mts +0 -22
  20. package/dist/src/graph/load.utils.mjs +0 -59
  21. package/dist/src/protocol/service.d.mts +0 -101
  22. package/dist/src/protocol/service.mjs +0 -568
  23. package/dist/src/protocol/session/event-normalizers.d.mts +0 -52
  24. package/dist/src/protocol/session/index.d.mts +0 -261
  25. package/dist/src/protocol/session/index.mjs +0 -826
  26. package/dist/src/protocol/session/namespace.d.mts +0 -47
  27. package/dist/src/protocol/session/namespace.mjs +0 -62
  28. package/dist/src/protocol/types.d.mts +0 -121
  29. package/dist/src/protocol/types.mjs +0 -1
  30. package/dist/src/schemas.d.mts +0 -1552
  31. package/dist/src/semver/index.d.mts +0 -15
  32. package/dist/src/semver/index.mjs +0 -46
  33. package/dist/src/semver/satisfiesPeerRange.d.mts +0 -1
  34. package/dist/src/semver/satisfiesPeerRange.mjs +0 -19
  35. package/dist/src/state.d.mts +0 -3
  36. package/dist/src/state.mjs +0 -30
  37. package/dist/src/storage/context.d.mts +0 -3
  38. package/dist/src/storage/context.mjs +0 -11
  39. package/dist/src/storage/ops.mjs +0 -1281
  40. package/dist/src/stream.d.mts +0 -64
  41. package/dist/src/stream.mjs +0 -427
  42. package/dist/src/utils/hono.d.mts +0 -5
  43. package/dist/src/utils/hono.mjs +0 -24
  44. package/dist/src/utils/runnableConfig.d.mts +0 -3
  45. package/dist/src/utils/runnableConfig.mjs +0 -45
  46. package/dist/src/webhook.d.mts +0 -11
  47. package/dist/src/webhook.mjs +0 -30
@@ -1,826 +0,0 @@
1
- import { v7 as uuid7 } from "uuid";
2
- import { serialiseAsDict, serializeError } from "../../utils/serde.mjs";
3
- import { normalizeInputRequestedData, normalizeToolData, stripInterruptsFromValues, toLifecycleStatus, } from "./event-normalizers.mjs";
4
- import { SUPPORTED_CHANNELS, isRecord, isSupportedChannel, } from "./internal-types.mjs";
5
- import { guessGraphName, isPrefixMatch, normalizeNamespace, parseEventName, toNamespaceKey, } from "./namespace.mjs";
6
- import { normalizeProtocolStatePayload } from "./state-normalizers.mjs";
7
- /**
8
- * Normalizes one LangGraph run into protocol events and manages per-run
9
- * subscriptions, buffering, and replay.
10
- *
11
- * This class is transport-agnostic: callers provide a `send()` function and an
12
- * optional source stream, and the session handles command processing plus event
13
- * fan-out for both direct run-scoped sockets and the shared `v2` session
14
- * service.
15
- */
16
- export class RunProtocolSession {
17
- initialRun;
18
- getRun;
19
- getThreadState;
20
- send;
21
- source;
22
- subscriptions = new Map();
23
- namespaces = new Map();
24
- abortController = new AbortController();
25
- buffer = [];
26
- sendQueue = Promise.resolve();
27
- sourceTask;
28
- nextSeq = 0;
29
- rootGraphName = "root";
30
- terminalLifecycleEmitted = false;
31
- pendingInterruptIds = new Set();
32
- /**
33
- * When true, every event is sent unconditionally via {@link sendJson}
34
- * regardless of subscription state. Used for SSE transports where
35
- * per-connection filtering is handled by the outer service layer.
36
- */
37
- passthrough;
38
- /**
39
- * Creates a run-scoped protocol session.
40
- *
41
- * @param options - Session construction options and transport bindings.
42
- */
43
- constructor(options) {
44
- this.initialRun = options.initialRun;
45
- this.getRun = options.getRun;
46
- this.getThreadState = options.getThreadState;
47
- this.send = options.send;
48
- this.source = options.source;
49
- this.passthrough = options.passthrough ?? false;
50
- if (options.startSeq != null) {
51
- this.nextSeq = options.startSeq;
52
- }
53
- }
54
- /**
55
- * Starts consuming the bound run source and seeds the root lifecycle state.
56
- */
57
- async start() {
58
- this.rootGraphName =
59
- typeof this.initialRun.kwargs.config?.configurable?.graph_id === "string"
60
- ? this.initialRun.kwargs.config.configurable.graph_id
61
- : this.initialRun.assistant_id;
62
- this.setNamespaceInfo([], toLifecycleStatus(this.initialRun.status), {
63
- graphName: this.rootGraphName,
64
- });
65
- await this.pushEvent(this.createEvent("lifecycle", [], {
66
- event: toLifecycleStatus(this.initialRun.status),
67
- graph_name: this.rootGraphName,
68
- }));
69
- if (this.source != null) {
70
- this.sourceTask = this.consumeSource();
71
- }
72
- }
73
- /**
74
- * Stops consuming the run source and waits for queued writes to settle.
75
- */
76
- async close() {
77
- this.abortController.abort();
78
- await this.sourceTask?.catch(() => undefined);
79
- await this.sendQueue.catch(() => undefined);
80
- }
81
- /**
82
- * Parses and handles a raw JSON command coming from the transport.
83
- *
84
- * @param rawPayload - Raw JSON protocol command payload.
85
- */
86
- async handleCommand(rawPayload) {
87
- let payload;
88
- try {
89
- payload = JSON.parse(rawPayload);
90
- }
91
- catch {
92
- await this.sendError(null, "invalid_argument", "Protocol commands must be valid JSON.");
93
- return;
94
- }
95
- if (!isRecord(payload) ||
96
- typeof payload.id !== "number" ||
97
- !Number.isInteger(payload.id) ||
98
- payload.id < 0 ||
99
- typeof payload.method !== "string") {
100
- await this.sendError(null, "invalid_argument", "Protocol commands must include an integer id and string method.");
101
- return;
102
- }
103
- const command = payload;
104
- try {
105
- switch (command.method) {
106
- case "subscription.subscribe":
107
- await this.handleSubscribe(command);
108
- return;
109
- case "subscription.unsubscribe":
110
- await this.handleUnsubscribe(command);
111
- return;
112
- case "agent.getTree":
113
- await this.sendSuccess(command.id, {
114
- tree: this.buildTree([]),
115
- });
116
- return;
117
- case "subscription.reconnect":
118
- await this.sendError(command.id, "not_supported", "subscription.reconnect is not supported by this server yet.");
119
- return;
120
- default:
121
- await this.sendError(command.id, "unknown_command", `Unknown protocol command: ${command.method}`);
122
- }
123
- }
124
- catch (error) {
125
- await this.sendError(command.id, "unknown_error", error instanceof Error ? error.message : "Unknown protocol error", error instanceof Error ? error.stack : undefined);
126
- }
127
- }
128
- /**
129
- * Handles a structured protocol command and returns a typed response.
130
- *
131
- * When `options.deliverResponseInline` is `true`, a
132
- * `subscription.subscribe` resolves to `null` — the success response
133
- * has already been written through the shared transport queue ahead
134
- * of the replay events (see `handleSubscribeForResponse` for
135
- * rationale). Callers that forward the response onto a separate wire
136
- * (the WS `onMessage` handler) must treat `null` as
137
- * "nothing more to send".
138
- *
139
- * @param command - Parsed protocol command.
140
- * @param meta - Optional response metadata from the outer transport.
141
- * @param options - Server-internal delivery flags.
142
- * @returns A typed success/error response, or `null` when the
143
- * response was already sent inline.
144
- */
145
- async handleProtocolCommand(command, meta, options) {
146
- try {
147
- switch (command.method) {
148
- case "subscription.subscribe":
149
- return await this.handleSubscribeForResponse(command, meta, options);
150
- case "subscription.unsubscribe":
151
- return await this.handleUnsubscribeForResponse(command, meta);
152
- case "agent.getTree":
153
- return this.success(command.id, { tree: this.buildTree([]) }, meta);
154
- case "subscription.reconnect":
155
- return this.error(command.id, "not_supported", "subscription.reconnect is not supported by this server yet.", meta);
156
- default:
157
- return this.error(command.id, "unknown_command", `Unknown protocol command: ${command.method}`, meta);
158
- }
159
- }
160
- catch (error) {
161
- return this.error(command.id, "unknown_error", error instanceof Error ? error.message : "Unknown protocol error", meta, error instanceof Error ? error.stack : undefined);
162
- }
163
- }
164
- /**
165
- * Injects a raw run stream event into the protocol session.
166
- *
167
- * @param event - Raw source stream event to normalize.
168
- */
169
- async ingestSourceEvent(event) {
170
- await this.handleSourceEvent(event);
171
- }
172
- /**
173
- * Consumes the bound source stream until completion or cancellation.
174
- */
175
- async consumeSource() {
176
- try {
177
- for await (const event of this.source ?? []) {
178
- if (this.abortController.signal.aborted)
179
- break;
180
- await this.handleSourceEvent(event);
181
- }
182
- }
183
- catch (error) {
184
- await this.sendError(null, "unknown_error", error instanceof Error
185
- ? error.message
186
- : "Failed to consume run stream.", error instanceof Error ? error.stack : undefined);
187
- }
188
- finally {
189
- await this.emitTerminalLifecycle();
190
- }
191
- }
192
- /**
193
- * Emits the terminal root lifecycle event once the underlying run
194
- * finishes. Child namespaces are cascaded upstream by core's
195
- * `LifecycleTransformer` — the session only owns the root because
196
- * terminal status depends on API-only signals (persisted run
197
- * status, thread-level pending interrupts).
198
- */
199
- async emitTerminalLifecycle() {
200
- if (this.terminalLifecycleEmitted)
201
- return;
202
- const currentRun = await this.getRun();
203
- if (currentRun == null) {
204
- this.terminalLifecycleEmitted = true;
205
- return;
206
- }
207
- let status = toLifecycleStatus(currentRun.status);
208
- if (status === "completed") {
209
- if (this.pendingInterruptIds.size > 0) {
210
- status = "interrupted";
211
- }
212
- else {
213
- let threadState;
214
- try {
215
- threadState = (await this.getThreadState?.()) ?? null;
216
- }
217
- catch {
218
- threadState = null;
219
- }
220
- const hasPendingInterrupts = (threadState?.tasks ?? []).some((task) => Array.isArray(task.interrupts) && task.interrupts.length > 0);
221
- if (hasPendingInterrupts) {
222
- status = "interrupted";
223
- }
224
- }
225
- }
226
- if (status === "running")
227
- return;
228
- this.terminalLifecycleEmitted = true;
229
- this.setNamespaceInfo([], status, { graphName: this.rootGraphName });
230
- await this.pushEvent(this.createEvent("lifecycle", [], {
231
- event: status,
232
- graph_name: this.rootGraphName,
233
- }));
234
- }
235
- /**
236
- * Normalizes a single raw source event into protocol events.
237
- *
238
- * @param event - Raw source stream event.
239
- */
240
- async handleSourceEvent(event) {
241
- if (event.event === "metadata")
242
- return;
243
- if (event.event === "error") {
244
- // Core's `LifecycleTransformer.fail()` has already cascaded
245
- // `lifecycle.failed` to every sub-namespace. The session owns
246
- // the root lifecycle, so emit only the root here.
247
- this.terminalLifecycleEmitted = true;
248
- this.setNamespaceInfo([], "failed", { graphName: this.rootGraphName });
249
- await this.pushEvent(this.createEvent("lifecycle", [], {
250
- event: "failed",
251
- graph_name: this.rootGraphName,
252
- error: serializeError(event.data).message,
253
- }));
254
- return;
255
- }
256
- const { method, namespace: rawNamespace } = parseEventName(event.event);
257
- const namespace = normalizeNamespace(rawNamespace);
258
- if (event.normalized &&
259
- (await this.forwardNormalizedSourceEvent(method, namespace, event.data))) {
260
- return;
261
- }
262
- // Authoritative subgraph `lifecycle` events come from core's
263
- // `LifecycleTransformer`. The session observes them, updates its
264
- // in-memory namespace tracking for agent-tree queries, and forwards
265
- // them to subscribers. Root lifecycle events are owned by the
266
- // session (see `start()` and `emitTerminalLifecycle()`), so root
267
- // events from core are dropped here.
268
- if (method === "lifecycle") {
269
- if (namespace.length === 0)
270
- return;
271
- const data = event.data;
272
- this.setNamespaceInfo(namespace, data.event, {
273
- graphName: data.graph_name,
274
- });
275
- await this.pushEvent(this.createEvent("lifecycle", namespace, data));
276
- return;
277
- }
278
- if (method !== "messages") {
279
- await this.ensureNamespaces(namespace);
280
- }
281
- switch (method) {
282
- case "values": {
283
- const normalizedValues = stripInterruptsFromValues(event.data);
284
- await this.emitInputRequestedEvents(namespace, normalizedValues.inputRequests);
285
- if (!this.hasStatePayload(normalizedValues.values)) {
286
- return;
287
- }
288
- await this.pushEvent(this.createEvent("values", namespace, normalizedValues.values));
289
- return;
290
- }
291
- case "checkpoints": {
292
- await this.pushEvent(this.createEvent("checkpoints", namespace, event.data));
293
- return;
294
- }
295
- case "messages":
296
- return;
297
- case "custom":
298
- await this.pushEvent(this.createEvent("custom", namespace, {
299
- payload: event.data,
300
- }));
301
- return;
302
- case "tasks":
303
- await this.pushEvent(this.createEvent("tasks", namespace, event.data));
304
- return;
305
- case "tools":
306
- await this.pushEvent(this.createEvent("tools", namespace, normalizeToolData(event.data, event.id ?? uuid7())));
307
- return;
308
- default:
309
- // Route unknown methods as named custom events. This allows
310
- // reducers to emit("a2a", data) and have it appear on the
311
- // custom channel with name set for client-side filtering.
312
- await this.pushEvent(this.createEvent("custom", namespace, {
313
- name: method,
314
- payload: event.data,
315
- }));
316
- return;
317
- }
318
- }
319
- /**
320
- * Forwards events already converted by core's
321
- * `streamEvents(..., { version: "v3" })` pipeline.
322
- *
323
- * Only events marked by `streamStateV2` after `convertToProtocolEvent` and
324
- * the built-in stream transformers have run take this path.
325
- */
326
- async forwardNormalizedSourceEvent(method, namespace, data) {
327
- switch (method) {
328
- case "lifecycle": {
329
- if (namespace.length === 0)
330
- return true;
331
- const lifecycleData = data;
332
- this.setNamespaceInfo(namespace, lifecycleData.event, {
333
- graphName: lifecycleData.graph_name,
334
- });
335
- await this.pushEvent(this.createEvent("lifecycle", namespace, lifecycleData));
336
- return true;
337
- }
338
- case "messages": {
339
- if (namespace.length > 0) {
340
- await this.ensureNamespaces(namespace);
341
- }
342
- await this.pushEvent(this.createEvent("messages", namespace, data));
343
- return true;
344
- }
345
- case "updates": {
346
- await this.ensureNamespaces(namespace);
347
- const updatesData = data;
348
- if (updatesData.node === "__interrupt__") {
349
- await this.emitInputRequestedEvents(namespace, normalizeInputRequestedData(updatesData.values));
350
- return true;
351
- }
352
- await this.pushEvent(this.createEvent("updates", namespace, updatesData.values, updatesData.node));
353
- return true;
354
- }
355
- case "tools": {
356
- await this.ensureNamespaces(namespace);
357
- await this.pushEvent(this.createEvent("tools", namespace, data));
358
- return true;
359
- }
360
- case "custom": {
361
- await this.ensureNamespaces(namespace);
362
- await this.pushEvent(this.createEvent("custom", namespace, data));
363
- return true;
364
- }
365
- case "checkpoints": {
366
- await this.ensureNamespaces(namespace);
367
- await this.pushEvent(this.createEvent("checkpoints", namespace, data));
368
- return true;
369
- }
370
- default:
371
- return false;
372
- }
373
- }
374
- /**
375
- * Ensures the session is tracking each prefix of `namespace` so
376
- * agent-tree queries and message routing can resolve it.
377
- *
378
- * Does **not** emit any wire events: authoritative
379
- * `lifecycle.started` events are produced upstream by core's
380
- * `LifecycleTransformer` and observed by the session's lifecycle
381
- * handler in {@link RunProtocolSession.handleSourceEvent}. This
382
- * method is a defensive fallback for event paths that reference a namespace
383
- * before the corresponding `lifecycle` event has been ingested.
384
- *
385
- * @param namespace - Namespace whose prefixes should be tracked.
386
- */
387
- async ensureNamespaces(namespace) {
388
- for (let length = 1; length <= namespace.length; length += 1) {
389
- const partial = namespace.slice(0, length);
390
- const key = toNamespaceKey(partial);
391
- if (this.namespaces.has(key))
392
- continue;
393
- this.setNamespaceInfo(partial, "started", {
394
- graphName: guessGraphName(partial),
395
- });
396
- }
397
- }
398
- /**
399
- * Updates cached namespace metadata used for lifecycle and tree responses.
400
- *
401
- * @param namespace - Namespace to update.
402
- * @param status - New lifecycle status for the namespace.
403
- * @param options - Optional graph name override.
404
- */
405
- setNamespaceInfo(namespace, status, options) {
406
- const key = toNamespaceKey(namespace);
407
- const existing = this.namespaces.get(key);
408
- this.namespaces.set(key, {
409
- namespace,
410
- status,
411
- graphName: options?.graphName ??
412
- existing?.graphName ??
413
- (namespace.length === 0
414
- ? this.rootGraphName
415
- : guessGraphName(namespace)),
416
- });
417
- }
418
- /**
419
- * Creates a protocol event with sequencing and payload normalization applied.
420
- *
421
- * @param method - Event channel to emit.
422
- * @param namespace - Namespace associated with the event.
423
- * @param data - Event payload.
424
- * @param node - Optional node name for updates and messages.
425
- * @returns A fully-formed protocol event.
426
- */
427
- createEvent(method, namespace, data, node) {
428
- this.nextSeq += 1;
429
- const eventMethod = method === "input" ? "input.requested" : method;
430
- const normalizedData = method === "values" || method === "updates"
431
- ? normalizeProtocolStatePayload(data)
432
- : data;
433
- return {
434
- type: "event",
435
- event_id: String(this.nextSeq),
436
- seq: this.nextSeq,
437
- method: eventMethod,
438
- params: {
439
- namespace,
440
- timestamp: Date.now(),
441
- ...(node != null ? { node } : {}),
442
- data: normalizedData,
443
- },
444
- };
445
- }
446
- /**
447
- * Buffers an event and delivers it to all matching subscriptions.
448
- * Applies the active flow-control strategy when the buffer is at capacity.
449
- *
450
- * @param event - Protocol event to buffer and fan out.
451
- */
452
- async pushEvent(event) {
453
- this.buffer.push(event);
454
- if (this.passthrough) {
455
- await this.sendJson(event);
456
- }
457
- else {
458
- for (const subscription of this.subscriptions.values()) {
459
- if (!subscription.active ||
460
- !this.matchesSubscription(subscription, event)) {
461
- continue;
462
- }
463
- await this.sendJson(event);
464
- }
465
- }
466
- }
467
- /**
468
- * Checks whether an event should be delivered to a subscription.
469
- *
470
- * @param subscription - Subscription to test.
471
- * @param event - Candidate protocol event.
472
- * @returns Whether the event matches channel and namespace filters.
473
- */
474
- matchesSubscription(subscription, event) {
475
- const channel = event.method === "input.requested"
476
- ? "input"
477
- : isSupportedChannel(event.method)
478
- ? event.method
479
- : undefined;
480
- if (channel == null)
481
- return false;
482
- // Support "custom:name" subscriptions: "custom:a2a" matches custom
483
- // events with data.name === "a2a", while "custom" matches all.
484
- let channelMatched = subscription.channels.has(channel);
485
- if (!channelMatched && channel === "custom") {
486
- const params = event.params;
487
- const eventName = isRecord(params.data) && typeof params.data.name === "string"
488
- ? params.data.name
489
- : undefined;
490
- if (eventName != null) {
491
- channelMatched = subscription.channels.has(`custom:${eventName}`);
492
- }
493
- }
494
- if (!channelMatched)
495
- return false;
496
- if (subscription.namespaces == null ||
497
- subscription.namespaces.length === 0) {
498
- return true;
499
- }
500
- return subscription.namespaces.some((prefix) => {
501
- if (!isPrefixMatch(event.params.namespace, prefix))
502
- return false;
503
- if (subscription.depth == null)
504
- return true;
505
- return (event.params.namespace.length - prefix.length <= subscription.depth);
506
- });
507
- }
508
- hasStatePayload(value) {
509
- return !isRecord(value) || Object.keys(value).length > 0;
510
- }
511
- async emitInputRequestedEvents(namespace, requests) {
512
- for (const request of requests) {
513
- if (this.pendingInterruptIds.has(request.interrupt_id)) {
514
- continue;
515
- }
516
- this.pendingInterruptIds.add(request.interrupt_id);
517
- await this.pushEvent(this.createEvent("input", namespace, request));
518
- }
519
- }
520
- /**
521
- * Builds the agent tree view rooted at a namespace.
522
- *
523
- * @param namespace - Namespace to build from.
524
- * @returns A recursively assembled agent tree node.
525
- */
526
- buildTree(namespace) {
527
- const key = toNamespaceKey(namespace);
528
- const current = this.namespaces.get(key) ??
529
- {
530
- namespace,
531
- status: "started",
532
- graphName: namespace.length === 0
533
- ? this.rootGraphName
534
- : guessGraphName(namespace),
535
- };
536
- const children = [...this.namespaces.values()]
537
- .filter((candidate) => {
538
- if (candidate.namespace.length !== namespace.length + 1)
539
- return false;
540
- return isPrefixMatch(candidate.namespace, namespace);
541
- })
542
- .sort((left, right) => JSON.stringify(left.namespace).localeCompare(JSON.stringify(right.namespace)))
543
- .map((child) => this.buildTree(child.namespace));
544
- return {
545
- namespace: current.namespace,
546
- status: current.status,
547
- graph_name: current.graphName,
548
- ...(children.length > 0 ? { children } : {}),
549
- };
550
- }
551
- /**
552
- * Handles a subscribe command and writes the response to the transport.
553
- *
554
- * @param command - Subscribe command to process.
555
- */
556
- async handleSubscribe(command) {
557
- const params = isRecord(command.params)
558
- ? command.params
559
- : undefined;
560
- const rawChannels = params?.channels;
561
- if (!Array.isArray(rawChannels) || rawChannels.length === 0) {
562
- await this.sendError(command.id, "invalid_argument", "subscription.subscribe requires a non-empty channels array.");
563
- return;
564
- }
565
- const channels = rawChannels.filter((value) => typeof value === "string" &&
566
- (SUPPORTED_CHANNELS.has(value) ||
567
- value.startsWith("custom:")));
568
- if (channels.length !== rawChannels.length) {
569
- await this.sendError(command.id, "invalid_argument", "subscription.subscribe received an unsupported channel.");
570
- return;
571
- }
572
- const namespaces = Array.isArray(params?.namespaces) &&
573
- params.namespaces.every((value) => Array.isArray(value) &&
574
- value.every((segment) => typeof segment === "string"))
575
- ? params.namespaces
576
- : undefined;
577
- const depth = typeof params?.depth === "number" &&
578
- Number.isInteger(params.depth) &&
579
- params.depth >= 0
580
- ? params.depth
581
- : undefined;
582
- const subscription = {
583
- id: uuid7(),
584
- channels: new Set(channels),
585
- namespaces,
586
- depth,
587
- active: false,
588
- };
589
- this.subscriptions.set(subscription.id, subscription);
590
- const snapshotSeq = this.nextSeq;
591
- const snapshot = this.buffer.filter((event) => (event.seq ?? 0) <= snapshotSeq &&
592
- this.matchesSubscription(subscription, event));
593
- await this.sendSuccess(command.id, {
594
- subscription_id: subscription.id,
595
- replayed_events: snapshot.length,
596
- });
597
- for (const event of snapshot) {
598
- await this.sendJson(event);
599
- }
600
- let cursor = snapshotSeq;
601
- while (true) {
602
- const drain = this.buffer.filter((event) => (event.seq ?? 0) > cursor &&
603
- this.matchesSubscription(subscription, event));
604
- if (drain.length === 0)
605
- break;
606
- for (const event of drain) {
607
- await this.sendJson(event);
608
- }
609
- cursor = drain.at(-1)?.seq ?? cursor;
610
- }
611
- subscription.active = true;
612
- }
613
- /**
614
- * Handles a subscribe command and (optionally) writes the
615
- * success/error response inline through the shared transport
616
- * queue, signalling to callers (via a `null` return) that no
617
- * further send is required.
618
- *
619
- * When `options.deliverResponseInline` is `true`, the success
620
- * response is emitted via `sendJson` BEFORE the replay events and
621
- * the method returns `null`. This ordering is critical on
622
- * single-channel transports like WebSocket where the command
623
- * response and event frames share one ordered wire: if events
624
- * preceded the response, the client would receive events whose
625
- * `subscription_id` it hasn't yet registered (the awaiter in
626
- * `#subscribeViaCommand` only adds the subscription to its local
627
- * map after the response resolves) and drop them in the per-sub
628
- * fan-out.
629
- *
630
- * When `deliverResponseInline` is absent/`false` (the default, used
631
- * by HTTP `/commands`), the response is returned so the caller can
632
- * place it in the HTTP response body, matching the prior behaviour.
633
- * Input-validation errors always return normally regardless of the
634
- * flag so they surface consistently in both modes.
635
- *
636
- * @param command - Subscribe command to process.
637
- * @param meta - Optional response metadata from the outer transport.
638
- * @param options - Server-internal delivery flags.
639
- * @returns `null` when the response was sent inline; a typed
640
- * success/error response otherwise.
641
- */
642
- async handleSubscribeForResponse(command, meta, options) {
643
- const deliverResponseInline = options?.deliverResponseInline === true;
644
- const params = isRecord(command.params)
645
- ? command.params
646
- : undefined;
647
- const rawChannels = params?.channels;
648
- if (!Array.isArray(rawChannels) || rawChannels.length === 0) {
649
- return this.error(command.id, "invalid_argument", "subscription.subscribe requires a non-empty channels array.", meta);
650
- }
651
- const channels = rawChannels.filter((value) => typeof value === "string" &&
652
- (SUPPORTED_CHANNELS.has(value) ||
653
- value.startsWith("custom:")));
654
- if (channels.length !== rawChannels.length) {
655
- return this.error(command.id, "invalid_argument", "subscription.subscribe received an unsupported channel.", meta);
656
- }
657
- const namespaces = Array.isArray(params?.namespaces) &&
658
- params.namespaces.every((value) => Array.isArray(value) &&
659
- value.every((segment) => typeof segment === "string"))
660
- ? params.namespaces
661
- : undefined;
662
- const depth = typeof params?.depth === "number" &&
663
- Number.isInteger(params.depth) &&
664
- params.depth >= 0
665
- ? params.depth
666
- : undefined;
667
- const subscription = {
668
- id: uuid7(),
669
- channels: new Set(channels),
670
- namespaces,
671
- depth,
672
- active: false,
673
- };
674
- this.subscriptions.set(subscription.id, subscription);
675
- const snapshotSeq = this.nextSeq;
676
- const snapshot = this.buffer.filter((event) => (event.seq ?? 0) <= snapshotSeq &&
677
- this.matchesSubscription(subscription, event));
678
- const responsePayload = {
679
- type: "success",
680
- id: command.id,
681
- result: {
682
- subscription_id: subscription.id,
683
- replayed_events: snapshot.length,
684
- },
685
- ...(meta != null ? { meta } : {}),
686
- };
687
- if (deliverResponseInline) {
688
- // Response first, replay events second — see method-level
689
- // comment on why the ordering matters for ordered transports.
690
- await this.sendJson(responsePayload);
691
- }
692
- for (const event of snapshot) {
693
- await this.sendJson(event);
694
- }
695
- let cursor = snapshotSeq;
696
- while (true) {
697
- const drain = this.buffer.filter((event) => (event.seq ?? 0) > cursor &&
698
- this.matchesSubscription(subscription, event));
699
- if (drain.length === 0)
700
- break;
701
- for (const event of drain) {
702
- await this.sendJson(event);
703
- }
704
- cursor = drain.at(-1)?.seq ?? cursor;
705
- }
706
- subscription.active = true;
707
- return deliverResponseInline ? null : responsePayload;
708
- }
709
- /**
710
- * Handles an unsubscribe command and writes the response to the transport.
711
- *
712
- * @param command - Unsubscribe command to process.
713
- */
714
- async handleUnsubscribe(command) {
715
- const params = isRecord(command.params)
716
- ? command.params
717
- : undefined;
718
- const subscriptionId = params?.subscription_id;
719
- if (typeof subscriptionId !== "string") {
720
- await this.sendError(command.id, "invalid_argument", "subscription.unsubscribe requires a subscription_id.");
721
- return;
722
- }
723
- if (!this.subscriptions.delete(subscriptionId)) {
724
- await this.sendError(command.id, "no_such_subscription", `Unknown subscription: ${subscriptionId}`);
725
- return;
726
- }
727
- await this.sendSuccess(command.id, {});
728
- }
729
- /**
730
- * Handles an unsubscribe command and returns a typed response.
731
- *
732
- * @param command - Unsubscribe command to process.
733
- * @param meta - Optional response metadata from the outer transport.
734
- * @returns A typed success or error response.
735
- */
736
- async handleUnsubscribeForResponse(command, meta) {
737
- const params = isRecord(command.params)
738
- ? command.params
739
- : undefined;
740
- const subscriptionId = params?.subscription_id;
741
- if (typeof subscriptionId !== "string") {
742
- return this.error(command.id, "invalid_argument", "subscription.unsubscribe requires a subscription_id.", meta);
743
- }
744
- if (!this.subscriptions.delete(subscriptionId)) {
745
- return this.error(command.id, "no_such_subscription", `Unknown subscription: ${subscriptionId}`, meta);
746
- }
747
- return this.success(command.id, {}, meta);
748
- }
749
- /**
750
- * Sends a success response over the bound transport.
751
- *
752
- * @param id - Command identifier being acknowledged.
753
- * @param result - Typed success payload.
754
- */
755
- async sendSuccess(id, result) {
756
- await this.sendJson({
757
- type: "success",
758
- id,
759
- result,
760
- });
761
- }
762
- /**
763
- * Sends an error response over the bound transport.
764
- *
765
- * @param id - Command identifier, when available.
766
- * @param error - Protocol error code.
767
- * @param message - Human-readable error message.
768
- * @param stacktrace - Optional stack trace for debugging.
769
- */
770
- async sendError(id, error, message, stacktrace) {
771
- await this.sendJson({
772
- type: "error",
773
- id,
774
- error,
775
- message,
776
- ...(stacktrace != null ? { stacktrace } : {}),
777
- });
778
- }
779
- /**
780
- * Serializes and writes a protocol payload using the session transport queue.
781
- *
782
- * @param message - Protocol message to send.
783
- */
784
- async sendJson(message) {
785
- this.sendQueue = this.sendQueue
786
- .then(() => this.send(serialiseAsDict(message)))
787
- .catch(() => undefined);
788
- await this.sendQueue;
789
- }
790
- /**
791
- * Creates a typed success response object.
792
- *
793
- * @param id - Command identifier being acknowledged.
794
- * @param result - Typed success payload.
795
- * @param meta - Optional response metadata from the outer transport.
796
- * @returns A typed protocol success response.
797
- */
798
- success(id, result, meta) {
799
- return {
800
- type: "success",
801
- id,
802
- result,
803
- ...(meta != null ? { meta } : {}),
804
- };
805
- }
806
- /**
807
- * Creates a typed error response object.
808
- *
809
- * @param id - Command identifier, when available.
810
- * @param error - Protocol error code.
811
- * @param message - Human-readable error message.
812
- * @param meta - Optional response metadata from the outer transport.
813
- * @param stacktrace - Optional stack trace for debugging.
814
- * @returns A typed protocol error response.
815
- */
816
- error(id, error, message, meta, stacktrace) {
817
- return {
818
- type: "error",
819
- id,
820
- error,
821
- message,
822
- ...(stacktrace != null ? { stacktrace } : {}),
823
- ...(meta != null ? { meta } : {}),
824
- };
825
- }
826
- }