@ably/ai-transport 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +426 -0
  3. package/dist/ably-ai-transport.js +1388 -0
  4. package/dist/ably-ai-transport.js.map +1 -0
  5. package/dist/ably-ai-transport.umd.cjs +2 -0
  6. package/dist/ably-ai-transport.umd.cjs.map +1 -0
  7. package/dist/constants.d.ts +50 -0
  8. package/dist/core/codec/decoder.d.ts +62 -0
  9. package/dist/core/codec/encoder.d.ts +56 -0
  10. package/dist/core/codec/index.d.ts +8 -0
  11. package/dist/core/codec/lifecycle-tracker.d.ts +74 -0
  12. package/dist/core/codec/types.d.ts +188 -0
  13. package/dist/core/transport/client-transport.d.ts +10 -0
  14. package/dist/core/transport/conversation-tree.d.ts +9 -0
  15. package/dist/core/transport/decode-history.d.ts +41 -0
  16. package/dist/core/transport/headers.d.ts +26 -0
  17. package/dist/core/transport/index.d.ts +4 -0
  18. package/dist/core/transport/pipe-stream.d.ts +16 -0
  19. package/dist/core/transport/server-transport.d.ts +7 -0
  20. package/dist/core/transport/stream-router.d.ts +19 -0
  21. package/dist/core/transport/turn-manager.d.ts +34 -0
  22. package/dist/core/transport/types.d.ts +407 -0
  23. package/dist/errors.d.ts +46 -0
  24. package/dist/event-emitter.d.ts +65 -0
  25. package/dist/index.d.ts +11 -0
  26. package/dist/logger.d.ts +103 -0
  27. package/dist/react/ably-ai-transport-react.js +823 -0
  28. package/dist/react/ably-ai-transport-react.js.map +1 -0
  29. package/dist/react/ably-ai-transport-react.umd.cjs +2 -0
  30. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -0
  31. package/dist/react/index.d.ts +11 -0
  32. package/dist/react/use-ably-messages.d.ts +18 -0
  33. package/dist/react/use-active-turns.d.ts +8 -0
  34. package/dist/react/use-client-transport.d.ts +7 -0
  35. package/dist/react/use-conversation-tree.d.ts +20 -0
  36. package/dist/react/use-edit.d.ts +7 -0
  37. package/dist/react/use-history.d.ts +19 -0
  38. package/dist/react/use-messages.d.ts +7 -0
  39. package/dist/react/use-regenerate.d.ts +7 -0
  40. package/dist/react/use-send.d.ts +7 -0
  41. package/dist/utils.d.ts +127 -0
  42. package/dist/vercel/ably-ai-transport-vercel.js +2331 -0
  43. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -0
  44. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +2 -0
  45. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -0
  46. package/dist/vercel/codec/accumulator.d.ts +21 -0
  47. package/dist/vercel/codec/decoder.d.ts +22 -0
  48. package/dist/vercel/codec/encoder.d.ts +41 -0
  49. package/dist/vercel/codec/index.d.ts +22 -0
  50. package/dist/vercel/index.d.ts +3 -0
  51. package/dist/vercel/react/ably-ai-transport-vercel-react.js +2082 -0
  52. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -0
  53. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +2 -0
  54. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -0
  55. package/dist/vercel/react/index.d.ts +3 -0
  56. package/dist/vercel/react/use-chat-transport.d.ts +29 -0
  57. package/dist/vercel/react/use-message-sync.d.ts +19 -0
  58. package/dist/vercel/transport/chat-transport.d.ts +118 -0
  59. package/dist/vercel/transport/index.d.ts +36 -0
  60. package/package.json +123 -0
  61. package/react/README.md +3 -0
  62. package/react/index.d.ts +1 -0
  63. package/react/index.js +1 -0
  64. package/react/index.umd.cjs +1 -0
  65. package/src/constants.ts +98 -0
  66. package/src/core/codec/decoder.ts +402 -0
  67. package/src/core/codec/encoder.ts +470 -0
  68. package/src/core/codec/index.ts +28 -0
  69. package/src/core/codec/lifecycle-tracker.ts +140 -0
  70. package/src/core/codec/types.ts +249 -0
  71. package/src/core/transport/client-transport.ts +959 -0
  72. package/src/core/transport/conversation-tree.ts +434 -0
  73. package/src/core/transport/decode-history.ts +337 -0
  74. package/src/core/transport/headers.ts +46 -0
  75. package/src/core/transport/index.ts +34 -0
  76. package/src/core/transport/pipe-stream.ts +95 -0
  77. package/src/core/transport/server-transport.ts +458 -0
  78. package/src/core/transport/stream-router.ts +118 -0
  79. package/src/core/transport/turn-manager.ts +147 -0
  80. package/src/core/transport/types.ts +533 -0
  81. package/src/errors.ts +58 -0
  82. package/src/event-emitter.ts +103 -0
  83. package/src/index.ts +89 -0
  84. package/src/logger.ts +241 -0
  85. package/src/react/index.ts +11 -0
  86. package/src/react/use-ably-messages.ts +37 -0
  87. package/src/react/use-active-turns.ts +61 -0
  88. package/src/react/use-client-transport.ts +37 -0
  89. package/src/react/use-conversation-tree.ts +71 -0
  90. package/src/react/use-edit.ts +24 -0
  91. package/src/react/use-history.ts +111 -0
  92. package/src/react/use-messages.ts +32 -0
  93. package/src/react/use-regenerate.ts +24 -0
  94. package/src/react/use-send.ts +25 -0
  95. package/src/react/vite.config.ts +32 -0
  96. package/src/tsconfig.json +25 -0
  97. package/src/utils.ts +230 -0
  98. package/src/vercel/codec/accumulator.ts +603 -0
  99. package/src/vercel/codec/decoder.ts +615 -0
  100. package/src/vercel/codec/encoder.ts +396 -0
  101. package/src/vercel/codec/index.ts +37 -0
  102. package/src/vercel/index.ts +12 -0
  103. package/src/vercel/react/index.ts +4 -0
  104. package/src/vercel/react/use-chat-transport.ts +60 -0
  105. package/src/vercel/react/use-message-sync.ts +34 -0
  106. package/src/vercel/react/vite.config.ts +33 -0
  107. package/src/vercel/transport/chat-transport.ts +278 -0
  108. package/src/vercel/transport/index.ts +56 -0
  109. package/src/vercel/vite.config.ts +33 -0
  110. package/src/vite.config.ts +31 -0
  111. package/vercel/README.md +3 -0
  112. package/vercel/index.d.ts +1 -0
  113. package/vercel/index.js +1 -0
  114. package/vercel/index.umd.cjs +1 -0
  115. package/vercel/react/README.md +3 -0
  116. package/vercel/react/index.d.ts +1 -0
  117. package/vercel/react/index.js +1 -0
  118. package/vercel/react/index.umd.cjs +1 -0
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Core server-side transport, parameterized by codec.
3
+ *
4
+ * Composes TurnManager and pipeStream to handle the full server-side turn
5
+ * lifecycle. Cancel message routing is handled directly by the transport's
6
+ * single channel subscription — no separate cancel manager needed.
7
+ *
8
+ * The transport exposes a single factory method — `newTurn()` — which returns
9
+ * a Turn object with explicit lifecycle methods: start(), addMessages(),
10
+ * streamResponse(), and end().
11
+ */
12
+
13
+ import * as Ably from 'ably';
14
+
15
+ import {
16
+ EVENT_CANCEL,
17
+ HEADER_CANCEL_ALL,
18
+ HEADER_CANCEL_CLIENT_ID,
19
+ HEADER_CANCEL_OWN,
20
+ HEADER_CANCEL_TURN_ID,
21
+ HEADER_MSG_ID,
22
+ } from '../../constants.js';
23
+ import { ErrorCode } from '../../errors.js';
24
+ import type { Logger } from '../../logger.js';
25
+ import { getHeaders, mergeHeaders } from '../../utils.js';
26
+ import { buildTransportHeaders } from './headers.js';
27
+ import { pipeStream } from './pipe-stream.js';
28
+ import type { TurnManager } from './turn-manager.js';
29
+ import { createTurnManager } from './turn-manager.js';
30
+ import type {
31
+ AddMessageOptions,
32
+ AddMessagesResult,
33
+ CancelFilter,
34
+ CancelRequest,
35
+ MessageWithHeaders,
36
+ NewTurnOptions,
37
+ ServerTransport,
38
+ ServerTransportOptions,
39
+ StreamResponseOptions,
40
+ StreamResult,
41
+ Turn,
42
+ TurnEndReason,
43
+ } from './types.js';
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Internal turn record for cancel routing
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface RegisteredTurn {
50
+ turnId: string;
51
+ clientId: string;
52
+ controller: AbortController;
53
+ onCancel?: (request: CancelRequest) => Promise<boolean>;
54
+ onError?: (error: Ably.ErrorInfo) => void;
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Implementation
59
+ // ---------------------------------------------------------------------------
60
+
61
+ // Spec: AIT-ST1
62
+ class DefaultServerTransport<TEvent, TMessage> implements ServerTransport<TEvent, TMessage> {
63
+ private readonly _channel: Ably.RealtimeChannel;
64
+ private readonly _codec: ServerTransportOptions<TEvent, TMessage>['codec'];
65
+ private readonly _logger: Logger | undefined;
66
+ private readonly _onError: ((error: Ably.ErrorInfo) => void) | undefined;
67
+ private readonly _turnManager: TurnManager;
68
+ private readonly _registeredTurns = new Map<string, RegisteredTurn>();
69
+ private readonly _channelListener: (msg: Ably.InboundMessage) => void;
70
+ private readonly _attachPromise: Promise<void>;
71
+
72
+ constructor(options: ServerTransportOptions<TEvent, TMessage>) {
73
+ this._channel = options.channel;
74
+ this._codec = options.codec;
75
+ this._logger = options.logger?.withContext({ component: 'ServerTransport' });
76
+ this._onError = options.onError;
77
+ this._turnManager = createTurnManager(this._channel, this._logger);
78
+
79
+ this._channelListener = (msg: Ably.InboundMessage) => {
80
+ this._handleChannelMessage(msg);
81
+ };
82
+
83
+ // Spec: AIT-ST2
84
+ // Subscribe before attach (RTL7g) — ensures no messages are missed.
85
+ this._attachPromise = this._channel.subscribe(EVENT_CANCEL, this._channelListener).then(
86
+ /* eslint-disable @typescript-eslint/no-empty-function -- discard subscription handle */
87
+ () => {},
88
+ /* eslint-enable @typescript-eslint/no-empty-function */
89
+ (error: unknown) => {
90
+ const errInfo = new Ably.ErrorInfo(
91
+ `unable to subscribe to cancel messages; ${error instanceof Error ? error.message : String(error)}`,
92
+ ErrorCode.TransportSubscriptionError,
93
+ 500,
94
+ error instanceof Ably.ErrorInfo ? error : undefined,
95
+ );
96
+ this._logger?.error('DefaultServerTransport(); subscribe failed');
97
+ this._onError?.(errInfo);
98
+ },
99
+ );
100
+
101
+ this._logger?.debug('DefaultServerTransport(); transport created');
102
+ }
103
+
104
+ // -------------------------------------------------------------------------
105
+ // Public API
106
+ // -------------------------------------------------------------------------
107
+
108
+ // Spec: AIT-ST3
109
+ newTurn(turnOpts: NewTurnOptions<TEvent>): Turn<TEvent, TMessage> {
110
+ this._logger?.trace('DefaultServerTransport.newTurn();', { turnId: turnOpts.turnId });
111
+ return this._createTurn(turnOpts);
112
+ }
113
+
114
+ // Spec: AIT-ST11
115
+ close(): void {
116
+ this._logger?.trace('DefaultServerTransport.close();');
117
+ this._channel.unsubscribe(EVENT_CANCEL, this._channelListener);
118
+ for (const reg of this._registeredTurns.values()) {
119
+ reg.controller.abort();
120
+ }
121
+ this._registeredTurns.clear();
122
+ this._turnManager.close();
123
+ this._logger?.debug('DefaultServerTransport.close(); transport closed');
124
+ }
125
+
126
+ // -------------------------------------------------------------------------
127
+ // Cancel message routing
128
+ // -------------------------------------------------------------------------
129
+
130
+ private _resolveFilter(filter: CancelFilter, senderClientId?: string): string[] {
131
+ const turnIds = [...this._registeredTurns.keys()];
132
+
133
+ if (filter.all) return turnIds;
134
+ if (filter.own && senderClientId) {
135
+ return turnIds.filter((id) => this._registeredTurns.get(id)?.clientId === senderClientId);
136
+ }
137
+ if (filter.clientId) {
138
+ return turnIds.filter((id) => this._registeredTurns.get(id)?.clientId === filter.clientId);
139
+ }
140
+ if (filter.turnId && this._registeredTurns.has(filter.turnId)) {
141
+ return [filter.turnId];
142
+ }
143
+ return [];
144
+ }
145
+
146
+ // Spec: AIT-ST8, AIT-ST9
147
+ private async _handleCancelMessage(msg: Ably.InboundMessage): Promise<void> {
148
+ const headers = getHeaders(msg);
149
+
150
+ const filter: CancelFilter = {};
151
+ if (headers[HEADER_CANCEL_TURN_ID]) {
152
+ filter.turnId = headers[HEADER_CANCEL_TURN_ID];
153
+ } else if (headers[HEADER_CANCEL_OWN] === 'true') {
154
+ filter.own = true;
155
+ } else if (headers[HEADER_CANCEL_CLIENT_ID]) {
156
+ filter.clientId = headers[HEADER_CANCEL_CLIENT_ID];
157
+ } else if (headers[HEADER_CANCEL_ALL] === 'true') {
158
+ filter.all = true;
159
+ }
160
+
161
+ const matchedTurnIds = this._resolveFilter(filter, msg.clientId);
162
+ if (matchedTurnIds.length === 0) return;
163
+
164
+ this._logger?.debug('DefaultServerTransport._handleCancelMessage(); matched turns', {
165
+ matchedTurnIds,
166
+ filter,
167
+ });
168
+
169
+ const owners = new Map<string, string>();
170
+ for (const tid of matchedTurnIds) {
171
+ const reg = this._registeredTurns.get(tid);
172
+ owners.set(tid, reg?.clientId ?? '');
173
+ }
174
+ const request: CancelRequest = { message: msg, filter, matchedTurnIds, turnOwners: owners };
175
+
176
+ for (const turnId of matchedTurnIds) {
177
+ const reg = this._registeredTurns.get(turnId);
178
+ if (!reg) continue;
179
+
180
+ try {
181
+ if (reg.onCancel) {
182
+ const allowed = await reg.onCancel(request);
183
+ if (!allowed) {
184
+ this._logger?.debug('DefaultServerTransport._handleCancelMessage(); cancel rejected by onCancel', {
185
+ turnId,
186
+ });
187
+ continue;
188
+ }
189
+ }
190
+ reg.controller.abort();
191
+ this._logger?.debug('DefaultServerTransport._handleCancelMessage(); turn aborted', { turnId });
192
+ } catch (error) {
193
+ // A throwing onCancel handler must not prevent other turns from being cancelled.
194
+ const errInfo = new Ably.ErrorInfo(
195
+ `unable to process cancel for turn ${turnId}; onCancel handler threw: ${error instanceof Error ? error.message : String(error)}`,
196
+ ErrorCode.CancelListenerError,
197
+ 500,
198
+ error instanceof Ably.ErrorInfo ? error : undefined,
199
+ );
200
+ this._logger?.error('DefaultServerTransport._handleCancelMessage(); onCancel threw', { turnId });
201
+ (reg.onError ?? this._onError)?.(errInfo);
202
+ }
203
+ }
204
+ }
205
+
206
+ // -------------------------------------------------------------------------
207
+ // Channel subscription handler
208
+ // -------------------------------------------------------------------------
209
+
210
+ private _handleChannelMessage(msg: Ably.InboundMessage): void {
211
+ try {
212
+ if (msg.name === EVENT_CANCEL) {
213
+ // Fire-and-forget async handler — errors are caught internally.
214
+ this._handleCancelMessage(msg).catch((error: unknown) => {
215
+ const errInfo = new Ably.ErrorInfo(
216
+ `unable to route cancel message; ${error instanceof Error ? error.message : String(error)}`,
217
+ ErrorCode.CancelListenerError,
218
+ 500,
219
+ error instanceof Ably.ErrorInfo ? error : undefined,
220
+ );
221
+ this._logger?.error('DefaultServerTransport._handleChannelMessage(); cancel routing error');
222
+ this._onError?.(errInfo);
223
+ });
224
+ }
225
+ } catch (error) {
226
+ const errInfo = new Ably.ErrorInfo(
227
+ `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
228
+ ErrorCode.TransportSubscriptionError,
229
+ 500,
230
+ error instanceof Ably.ErrorInfo ? error : undefined,
231
+ );
232
+ this._logger?.error('DefaultServerTransport._handleChannelMessage(); subscription error');
233
+ this._onError?.(errInfo);
234
+ }
235
+ }
236
+
237
+ // -------------------------------------------------------------------------
238
+ // Turn creation
239
+ // -------------------------------------------------------------------------
240
+
241
+ private _createTurn(turnOpts: NewTurnOptions<TEvent>): Turn<TEvent, TMessage> {
242
+ const {
243
+ turnId,
244
+ clientId: turnClientId,
245
+ onMessage,
246
+ onAbort,
247
+ onCancel,
248
+ onError: turnOnError,
249
+ parent: turnParent,
250
+ forkOf: turnForkOf,
251
+ } = turnOpts;
252
+
253
+ const controller = new AbortController();
254
+ let started = false;
255
+ let ended = false;
256
+
257
+ // Register immediately so early cancels can fire the abort signal.
258
+ const registration: RegisteredTurn = {
259
+ turnId,
260
+ clientId: turnClientId ?? '',
261
+ controller,
262
+ onCancel,
263
+ onError: turnOnError,
264
+ };
265
+ this._registeredTurns.set(turnId, registration);
266
+
267
+ // Capture instance members as locals so arrow functions close over them
268
+ // without needing `this` (avoids unicorn/no-this-assignment).
269
+ const logger = this._logger;
270
+ const turnManager = this._turnManager;
271
+ const attachPromise = this._attachPromise;
272
+ const codec = this._codec;
273
+ const channel = this._channel;
274
+ const registeredTurns = this._registeredTurns;
275
+
276
+ const turn: Turn<TEvent, TMessage> = {
277
+ get turnId() {
278
+ return turnId;
279
+ },
280
+ get abortSignal() {
281
+ return controller.signal;
282
+ },
283
+
284
+ // Spec: AIT-ST4
285
+ start: async (): Promise<void> => {
286
+ logger?.trace('Turn.start();', { turnId });
287
+
288
+ if (controller.signal.aborted) {
289
+ throw new Ably.ErrorInfo(
290
+ `unable to start turn; turn ${turnId} was cancelled before start()`,
291
+ ErrorCode.InvalidArgument,
292
+ 400,
293
+ );
294
+ }
295
+ if (started) return;
296
+ started = true;
297
+
298
+ try {
299
+ await turnManager.startTurn(turnId, turnClientId, controller);
300
+ } catch (error) {
301
+ const errInfo = new Ably.ErrorInfo(
302
+ `unable to publish turn-start for turn ${turnId}; ${error instanceof Error ? error.message : String(error)}`,
303
+ ErrorCode.TurnLifecycleError,
304
+ 500,
305
+ error instanceof Ably.ErrorInfo ? error : undefined,
306
+ );
307
+ logger?.error('Turn.start(); failed to publish turn-start', { turnId });
308
+ turnOnError?.(errInfo);
309
+ throw errInfo;
310
+ }
311
+
312
+ logger?.debug('Turn.start(); turn started', { turnId });
313
+ },
314
+
315
+ // Spec: AIT-ST5
316
+ addMessages: async (
317
+ inputs: MessageWithHeaders<TMessage>[],
318
+ opts?: AddMessageOptions,
319
+ ): Promise<AddMessagesResult> => {
320
+ logger?.trace('Turn.addMessages();', { turnId, count: inputs.length });
321
+
322
+ if (!started) {
323
+ throw new Ably.ErrorInfo(
324
+ `unable to add messages; start() must be called before addMessages() (turn ${turnId})`,
325
+ ErrorCode.InvalidArgument,
326
+ 400,
327
+ );
328
+ }
329
+ await attachPromise;
330
+
331
+ const msgIds: string[] = [];
332
+
333
+ for (const input of inputs) {
334
+ const msgId = crypto.randomUUID();
335
+
336
+ // Transport headers are the defaults; per-message headers from the
337
+ // client override them. This lets the client's x-ably-msg-id pass
338
+ // through for optimistic reconciliation with client inserts.
339
+ const headers = mergeHeaders(
340
+ buildTransportHeaders({
341
+ role: 'user',
342
+ turnId,
343
+ msgId,
344
+ turnClientId: opts?.clientId,
345
+ // Per-operation options override turn-level defaults
346
+ parent: opts?.parent === undefined ? (turnParent ?? undefined) : (opts.parent ?? undefined),
347
+ forkOf: opts?.forkOf ?? turnForkOf,
348
+ }),
349
+ input.headers,
350
+ );
351
+
352
+ const encoder = codec.createEncoder(channel, {
353
+ extras: { headers },
354
+ onMessage,
355
+ });
356
+
357
+ await encoder.writeMessages([input.message], opts?.clientId ? { clientId: opts.clientId } : undefined);
358
+
359
+ // Capture the effective msg-id after input.headers may have overridden it.
360
+ msgIds.push(headers[HEADER_MSG_ID] ?? msgId);
361
+ }
362
+
363
+ logger?.debug('Turn.addMessages(); messages published', { turnId, count: inputs.length });
364
+ return { msgIds };
365
+ },
366
+
367
+ // Spec: AIT-ST6
368
+ streamResponse: async (
369
+ stream: ReadableStream<TEvent>,
370
+ streamOpts?: StreamResponseOptions,
371
+ ): Promise<StreamResult> => {
372
+ logger?.trace('Turn.streamResponse();', { turnId });
373
+
374
+ if (!started) {
375
+ throw new Ably.ErrorInfo(
376
+ `unable to stream response; start() must be called before streamResponse() (turn ${turnId})`,
377
+ ErrorCode.InvalidArgument,
378
+ 400,
379
+ );
380
+ }
381
+ await attachPromise;
382
+
383
+ const signal = turnManager.getSignal(turnId);
384
+ const turnOwnerClientId = turnManager.getClientId(turnId);
385
+
386
+ // Per-operation parent overrides the turn-level default.
387
+ const assistantParent =
388
+ streamOpts?.parent === undefined ? (turnParent ?? undefined) : (streamOpts.parent ?? undefined);
389
+
390
+ const defaultHeaders = buildTransportHeaders({
391
+ role: 'assistant',
392
+ turnId,
393
+ msgId: crypto.randomUUID(),
394
+ turnClientId: turnOwnerClientId,
395
+ parent: assistantParent,
396
+ forkOf: streamOpts?.forkOf ?? turnForkOf,
397
+ });
398
+ const encoder = codec.createEncoder(channel, {
399
+ extras: { headers: defaultHeaders },
400
+ onMessage,
401
+ });
402
+
403
+ const result = await pipeStream(stream, encoder, signal, onAbort, logger);
404
+
405
+ logger?.debug('Turn.streamResponse(); stream finished', { turnId, reason: result.reason });
406
+ return result;
407
+ },
408
+
409
+ // Spec: AIT-ST7
410
+ end: async (reason: TurnEndReason): Promise<void> => {
411
+ logger?.trace('Turn.end();', { turnId, reason });
412
+
413
+ if (!started) {
414
+ throw new Ably.ErrorInfo(
415
+ `unable to end turn; start() must be called before end() (turn ${turnId})`,
416
+ ErrorCode.InvalidArgument,
417
+ 400,
418
+ );
419
+ }
420
+ if (ended) return;
421
+ ended = true;
422
+
423
+ try {
424
+ await turnManager.endTurn(turnId, reason);
425
+ } catch (error) {
426
+ const errInfo = new Ably.ErrorInfo(
427
+ `unable to publish turn-end for turn ${turnId}; ${error instanceof Error ? error.message : String(error)}`,
428
+ ErrorCode.TurnLifecycleError,
429
+ 500,
430
+ error instanceof Ably.ErrorInfo ? error : undefined,
431
+ );
432
+ logger?.error('Turn.end(); failed to publish turn-end', { turnId });
433
+ turnOnError?.(errInfo);
434
+ throw errInfo;
435
+ } finally {
436
+ registeredTurns.delete(turnId);
437
+ }
438
+
439
+ logger?.debug('Turn.end(); turn ended', { turnId, reason });
440
+ },
441
+ };
442
+
443
+ return turn;
444
+ }
445
+ }
446
+
447
+ // ---------------------------------------------------------------------------
448
+ // Factory
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /**
452
+ * Create a server transport bound to the given channel and codec.
453
+ * @param options - Transport configuration.
454
+ * @returns A new {@link ServerTransport} instance.
455
+ */
456
+ export const createServerTransport = <TEvent, TMessage>(
457
+ options: ServerTransportOptions<TEvent, TMessage>,
458
+ ): ServerTransport<TEvent, TMessage> => new DefaultServerTransport(options);
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Client-side stream routing.
3
+ *
4
+ * Maintains a map of turnId to ReadableStreamController. Routes decoded events
5
+ * to the correct stream. Closes streams on terminal events or explicit close.
6
+ */
7
+
8
+ import * as Ably from 'ably';
9
+
10
+ import { ErrorCode } from '../../errors.js';
11
+ import type { Logger } from '../../logger.js';
12
+ import type { TurnEntry } from './types.js';
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Interface
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Routes decoded events to the correct turn's ReadableStream. */
19
+ export interface StreamRouter<TEvent> {
20
+ /** Register a new stream for a turnId. Returns the ReadableStream the consumer reads from. */
21
+ createStream(turnId: string): ReadableStream<TEvent>;
22
+ /** Close the stream for a turnId. Returns true if a stream was closed. */
23
+ closeStream(turnId: string): boolean;
24
+ /** Enqueue an event to the correct stream. Returns true if routed successfully. */
25
+ route(turnId: string, event: TEvent): boolean;
26
+ /** Whether a specific turnId has an active stream. */
27
+ has(turnId: string): boolean;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Implementation
32
+ // ---------------------------------------------------------------------------
33
+
34
+ // Spec: AIT-CT14
35
+ class DefaultStreamRouter<TEvent> implements StreamRouter<TEvent> {
36
+ private readonly _turns = new Map<string, TurnEntry<TEvent>>();
37
+ private readonly _isTerminal: (event: TEvent) => boolean;
38
+ private readonly _logger: Logger;
39
+
40
+ constructor(isTerminal: (event: TEvent) => boolean, logger: Logger) {
41
+ this._isTerminal = isTerminal;
42
+ this._logger = logger;
43
+ }
44
+
45
+ createStream(turnId: string): ReadableStream<TEvent> {
46
+ this._logger.trace('StreamRouter.createStream();', { turnId });
47
+
48
+ // Build stream+controller together. ReadableStream's start() runs synchronously
49
+ // per spec, so the controller is captured before the constructor returns.
50
+ const entry: { controller?: ReadableStreamDefaultController<TEvent> } = {};
51
+ const stream = new ReadableStream<TEvent>({
52
+ start(controller) {
53
+ entry.controller = controller;
54
+ },
55
+ });
56
+ if (!entry.controller) {
57
+ throw new Ably.ErrorInfo(
58
+ 'unable to create stream; ReadableStream start() was not called synchronously',
59
+ ErrorCode.TransportSubscriptionError,
60
+ 500,
61
+ );
62
+ }
63
+ this._turns.set(turnId, { controller: entry.controller, turnId });
64
+ return stream;
65
+ }
66
+
67
+ // Spec: AIT-CT14b
68
+ closeStream(turnId: string): boolean {
69
+ const turn = this._turns.get(turnId);
70
+ if (!turn) return false;
71
+
72
+ this._logger.debug('StreamRouter.closeStream(); closing stream', { turnId });
73
+ try {
74
+ turn.controller.close();
75
+ } catch {
76
+ /* already closed */
77
+ }
78
+ this._turns.delete(turnId);
79
+ return true;
80
+ }
81
+
82
+ // Spec: AIT-CT14a
83
+ route(turnId: string, event: TEvent): boolean {
84
+ const turn = this._turns.get(turnId);
85
+ if (!turn) return false;
86
+
87
+ try {
88
+ turn.controller.enqueue(event);
89
+ } catch {
90
+ this._turns.delete(turnId);
91
+ return false;
92
+ }
93
+
94
+ if (this._isTerminal(event)) {
95
+ this.closeStream(turnId);
96
+ }
97
+ return true;
98
+ }
99
+
100
+ has(turnId: string): boolean {
101
+ return this._turns.has(turnId);
102
+ }
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Factory
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Create a StreamRouter that routes decoded events to per-turn ReadableStreams.
111
+ * @param isTerminal - Predicate that returns true for events that close the stream.
112
+ * @param logger - Logger for diagnostic output.
113
+ * @returns A new {@link StreamRouter} instance.
114
+ */
115
+ export const createStreamRouter = <TEvent>(
116
+ isTerminal: (event: TEvent) => boolean,
117
+ logger: Logger,
118
+ ): StreamRouter<TEvent> => new DefaultStreamRouter(isTerminal, logger);