@ably/ai-transport 0.1.0 → 0.2.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.
Files changed (163) hide show
  1. package/README.md +91 -100
  2. package/dist/ably-ai-transport.js +1553 -1238
  3. package/dist/ably-ai-transport.js.map +1 -1
  4. package/dist/ably-ai-transport.umd.cjs +1 -1
  5. package/dist/ably-ai-transport.umd.cjs.map +1 -1
  6. package/dist/constants.d.ts +116 -42
  7. package/dist/core/agent.d.ts +29 -0
  8. package/dist/core/codec/decoder.d.ts +20 -23
  9. package/dist/core/codec/encoder.d.ts +11 -8
  10. package/dist/core/codec/index.d.ts +1 -2
  11. package/dist/core/codec/lifecycle-tracker.d.ts +10 -9
  12. package/dist/core/codec/types.d.ts +407 -115
  13. package/dist/core/transport/agent-session.d.ts +10 -0
  14. package/dist/core/transport/branch-chain.d.ts +43 -0
  15. package/dist/core/transport/client-session.d.ts +13 -0
  16. package/dist/core/transport/decode-fold.d.ts +47 -0
  17. package/dist/core/transport/headers.d.ts +96 -18
  18. package/dist/core/transport/index.d.ts +5 -6
  19. package/dist/core/transport/internal/bounded-map.d.ts +20 -0
  20. package/dist/core/transport/invocation.d.ts +74 -0
  21. package/dist/core/transport/load-conversation.d.ts +128 -0
  22. package/dist/core/transport/load-history.d.ts +39 -0
  23. package/dist/core/transport/pipe-stream.d.ts +9 -9
  24. package/dist/core/transport/run-manager.d.ts +78 -0
  25. package/dist/core/transport/tree.d.ts +373 -109
  26. package/dist/core/transport/types/agent.d.ts +353 -0
  27. package/dist/core/transport/types/client.d.ts +168 -0
  28. package/dist/core/transport/types/shared.d.ts +24 -0
  29. package/dist/core/transport/types/tree.d.ts +315 -0
  30. package/dist/core/transport/types/view.d.ts +222 -0
  31. package/dist/core/transport/types.d.ts +13 -553
  32. package/dist/core/transport/view.d.ts +272 -84
  33. package/dist/errors.d.ts +21 -10
  34. package/dist/index.d.ts +6 -8
  35. package/dist/logger.d.ts +12 -0
  36. package/dist/react/ably-ai-transport-react.js +976 -990
  37. package/dist/react/ably-ai-transport-react.js.map +1 -1
  38. package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
  39. package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
  40. package/dist/react/contexts/client-session-context.d.ts +36 -0
  41. package/dist/react/contexts/client-session-provider.d.ts +53 -0
  42. package/dist/react/create-session-hooks.d.ts +116 -0
  43. package/dist/react/index.d.ts +12 -12
  44. package/dist/react/internal/use-resolved-session.d.ts +36 -0
  45. package/dist/react/use-ably-messages.d.ts +17 -14
  46. package/dist/react/use-client-session.d.ts +81 -0
  47. package/dist/react/use-create-view.d.ts +14 -13
  48. package/dist/react/use-tree.d.ts +30 -15
  49. package/dist/react/use-view.d.ts +82 -51
  50. package/dist/utils.d.ts +32 -23
  51. package/dist/vercel/ably-ai-transport-vercel.js +2573 -2086
  52. package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
  53. package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
  54. package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
  55. package/dist/vercel/codec/decoder.d.ts +5 -18
  56. package/dist/vercel/codec/encoder.d.ts +6 -36
  57. package/dist/vercel/codec/events.d.ts +51 -0
  58. package/dist/vercel/codec/index.d.ts +24 -12
  59. package/dist/vercel/codec/reducer.d.ts +144 -0
  60. package/dist/vercel/codec/tool-transitions.d.ts +2 -2
  61. package/dist/vercel/index.d.ts +4 -5
  62. package/dist/vercel/react/ably-ai-transport-vercel-react.js +3907 -3266
  63. package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
  64. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +33 -8
  65. package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
  66. package/dist/vercel/react/contexts/chat-transport-context.d.ts +7 -6
  67. package/dist/vercel/react/contexts/chat-transport-provider.d.ts +53 -41
  68. package/dist/vercel/react/index.d.ts +1 -2
  69. package/dist/vercel/react/use-chat-transport.d.ts +30 -26
  70. package/dist/vercel/react/use-message-sync.d.ts +17 -30
  71. package/dist/vercel/run-end-reason.d.ts +29 -0
  72. package/dist/vercel/transport/chat-transport.d.ts +43 -24
  73. package/dist/vercel/transport/index.d.ts +25 -21
  74. package/dist/vercel/transport/run-output-stream.d.ts +56 -0
  75. package/dist/version.d.ts +2 -0
  76. package/package.json +30 -23
  77. package/src/constants.ts +124 -51
  78. package/src/core/agent.ts +68 -0
  79. package/src/core/codec/decoder.ts +71 -98
  80. package/src/core/codec/encoder.ts +113 -65
  81. package/src/core/codec/index.ts +13 -6
  82. package/src/core/codec/lifecycle-tracker.ts +10 -9
  83. package/src/core/codec/types.ts +436 -120
  84. package/src/core/transport/agent-session.ts +1344 -0
  85. package/src/core/transport/branch-chain.ts +58 -0
  86. package/src/core/transport/client-session.ts +775 -0
  87. package/src/core/transport/decode-fold.ts +91 -0
  88. package/src/core/transport/headers.ts +181 -22
  89. package/src/core/transport/index.ts +25 -26
  90. package/src/core/transport/internal/bounded-map.ts +27 -0
  91. package/src/core/transport/invocation.ts +98 -0
  92. package/src/core/transport/load-conversation.ts +355 -0
  93. package/src/core/transport/load-history.ts +269 -0
  94. package/src/core/transport/pipe-stream.ts +54 -39
  95. package/src/core/transport/run-manager.ts +249 -0
  96. package/src/core/transport/tree.ts +926 -308
  97. package/src/core/transport/types/agent.ts +407 -0
  98. package/src/core/transport/types/client.ts +211 -0
  99. package/src/core/transport/types/shared.ts +27 -0
  100. package/src/core/transport/types/tree.ts +344 -0
  101. package/src/core/transport/types/view.ts +259 -0
  102. package/src/core/transport/types.ts +13 -706
  103. package/src/core/transport/view.ts +864 -433
  104. package/src/errors.ts +22 -9
  105. package/src/event-emitter.ts +3 -2
  106. package/src/index.ts +52 -41
  107. package/src/logger.ts +14 -1
  108. package/src/react/contexts/client-session-context.ts +41 -0
  109. package/src/react/contexts/client-session-provider.tsx +186 -0
  110. package/src/react/create-session-hooks.ts +141 -0
  111. package/src/react/index.ts +23 -13
  112. package/src/react/internal/use-resolved-session.ts +63 -0
  113. package/src/react/use-ably-messages.ts +32 -22
  114. package/src/react/use-client-session.ts +201 -0
  115. package/src/react/use-create-view.ts +33 -29
  116. package/src/react/use-tree.ts +61 -30
  117. package/src/react/use-view.ts +139 -97
  118. package/src/utils.ts +63 -45
  119. package/src/vercel/codec/decoder.ts +336 -258
  120. package/src/vercel/codec/encoder.ts +343 -205
  121. package/src/vercel/codec/events.ts +87 -0
  122. package/src/vercel/codec/index.ts +60 -13
  123. package/src/vercel/codec/reducer.ts +977 -0
  124. package/src/vercel/codec/tool-transitions.ts +2 -2
  125. package/src/vercel/index.ts +6 -19
  126. package/src/vercel/react/contexts/chat-transport-context.ts +7 -6
  127. package/src/vercel/react/contexts/chat-transport-provider.tsx +87 -59
  128. package/src/vercel/react/index.ts +3 -5
  129. package/src/vercel/react/use-chat-transport.ts +47 -49
  130. package/src/vercel/react/use-message-sync.ts +80 -39
  131. package/src/vercel/run-end-reason.ts +78 -0
  132. package/src/vercel/transport/chat-transport.ts +392 -98
  133. package/src/vercel/transport/index.ts +39 -38
  134. package/src/vercel/transport/run-output-stream.ts +170 -0
  135. package/src/version.ts +2 -0
  136. package/dist/core/transport/client-transport.d.ts +0 -10
  137. package/dist/core/transport/decode-history.d.ts +0 -43
  138. package/dist/core/transport/server-transport.d.ts +0 -7
  139. package/dist/core/transport/stream-router.d.ts +0 -29
  140. package/dist/core/transport/turn-manager.d.ts +0 -37
  141. package/dist/react/contexts/transport-context.d.ts +0 -31
  142. package/dist/react/contexts/transport-provider.d.ts +0 -49
  143. package/dist/react/create-transport-hooks.d.ts +0 -124
  144. package/dist/react/use-active-turns.d.ts +0 -12
  145. package/dist/react/use-client-transport.d.ts +0 -80
  146. package/dist/vercel/codec/accumulator.d.ts +0 -21
  147. package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +0 -30
  148. package/dist/vercel/tool-approvals.d.ts +0 -124
  149. package/dist/vercel/tool-events.d.ts +0 -26
  150. package/src/core/transport/client-transport.ts +0 -977
  151. package/src/core/transport/decode-history.ts +0 -485
  152. package/src/core/transport/server-transport.ts +0 -612
  153. package/src/core/transport/stream-router.ts +0 -136
  154. package/src/core/transport/turn-manager.ts +0 -165
  155. package/src/react/contexts/transport-context.ts +0 -37
  156. package/src/react/contexts/transport-provider.tsx +0 -164
  157. package/src/react/create-transport-hooks.ts +0 -144
  158. package/src/react/use-active-turns.ts +0 -72
  159. package/src/react/use-client-transport.ts +0 -197
  160. package/src/vercel/codec/accumulator.ts +0 -588
  161. package/src/vercel/react/use-staged-add-tool-approval-response.ts +0 -87
  162. package/src/vercel/tool-approvals.ts +0 -380
  163. package/src/vercel/tool-events.ts +0 -53
@@ -1,977 +0,0 @@
1
- /**
2
- * Core client-side transport, parameterized by codec.
3
- *
4
- * Composes StreamRouter and Tree to handle the full client-side
5
- * lifecycle. Subscribes to the Ably channel on construction. The same
6
- * subscription, decoder, and channel are reused across turns.
7
- *
8
- * The client never publishes user messages directly. Instead, it sends them
9
- * to the server via HTTP POST. The server publishes user messages and turn
10
- * lifecycle events (turn-start, turn-end) on behalf of the client.
11
- */
12
-
13
- import * as Ably from 'ably';
14
-
15
- import {
16
- EVENT_CANCEL,
17
- EVENT_TURN_END,
18
- EVENT_TURN_START,
19
- HEADER_AMEND,
20
- HEADER_CANCEL_ALL,
21
- HEADER_CANCEL_CLIENT_ID,
22
- HEADER_CANCEL_OWN,
23
- HEADER_CANCEL_TURN_ID,
24
- HEADER_FORK_OF,
25
- HEADER_MSG_ID,
26
- HEADER_PARENT,
27
- HEADER_TURN_CLIENT_ID,
28
- HEADER_TURN_ID,
29
- HEADER_TURN_REASON,
30
- } from '../../constants.js';
31
- import { ErrorCode } from '../../errors.js';
32
- import { EventEmitter } from '../../event-emitter.js';
33
- import type { Logger } from '../../logger.js';
34
- import { LogLevel, makeLogger } from '../../logger.js';
35
- import { getHeaders } from '../../utils.js';
36
- import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
37
- import { buildTransportHeaders } from './headers.js';
38
- import type { StreamRouter } from './stream-router.js';
39
- import { createStreamRouter } from './stream-router.js';
40
- import type { DefaultTree } from './tree.js';
41
- import { createTree } from './tree.js';
42
- import type {
43
- ActiveTurn,
44
- CancelFilter,
45
- ClientTransport,
46
- ClientTransportOptions,
47
- CloseOptions,
48
- EventsNode,
49
- MessageNode,
50
- SendOptions,
51
- Tree,
52
- TurnEndReason,
53
- TurnLifecycleEvent,
54
- View,
55
- } from './types.js';
56
- import { createView, type DefaultView } from './view.js';
57
-
58
- /**
59
- * Returned from `on()` when the transport is already closed — the subscription
60
- * is silently ignored since no further events will fire.
61
- */
62
- // eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
63
- const noopUnsubscribe = (): void => {};
64
-
65
- // ---------------------------------------------------------------------------
66
- // Internal state machine
67
- // ---------------------------------------------------------------------------
68
-
69
- enum ClientTransportState {
70
- READY = 'ready',
71
- CLOSED = 'closed',
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // Event map for the transport's typed EventEmitter
76
- // ---------------------------------------------------------------------------
77
-
78
- interface ClientTransportEventsMap {
79
- error: Ably.ErrorInfo;
80
- }
81
-
82
- // ---------------------------------------------------------------------------
83
- // Per-turn observer state — consolidated to avoid parallel-map bookkeeping
84
- // ---------------------------------------------------------------------------
85
-
86
- interface TurnObserverState<TEvent, TMessage> {
87
- headers: Record<string, string>;
88
- serial: string | undefined;
89
- accumulator: MessageAccumulator<TEvent, TMessage>;
90
- }
91
-
92
- // ---------------------------------------------------------------------------
93
- // Implementation
94
- // ---------------------------------------------------------------------------
95
-
96
- // Spec: AIT-CT1
97
- class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent, TMessage> {
98
- private readonly _channel: Ably.RealtimeChannel;
99
- private readonly _codec: ClientTransportOptions<TEvent, TMessage>['codec'];
100
- private readonly _clientId: string | undefined;
101
- private readonly _api: string;
102
- private readonly _credentials: RequestCredentials | undefined;
103
- private readonly _headersFn: (() => Record<string, string>) | undefined;
104
- private readonly _bodyFn: (() => Record<string, unknown>) | undefined;
105
- private readonly _fetchFn: typeof globalThis.fetch;
106
- private readonly _logger: Logger;
107
-
108
- // Typed event emitter — only 'error' remains on the transport
109
- private readonly _emitter: EventEmitter<ClientTransportEventsMap>;
110
-
111
- // Relay detection — tracks msg-ids of optimistic inserts for reconciliation
112
- private readonly _ownMsgIds = new Set<string>();
113
- private readonly _ownTurnIds = new Set<string>();
114
-
115
- // Track msgIds per turn for cleanup on turn-end
116
- private readonly _turnMsgIds = new Map<string, Set<string>>();
117
-
118
- // Per-turn observer state: headers, serial, and accumulator in one map.
119
- // A single .delete(turnId) cleans up all three.
120
- private readonly _turnObservers = new Map<string, TurnObserverState<TEvent, TMessage>>();
121
-
122
- // Callbacks to resolve pending waitForTurn promises on close, preventing leaked subscriptions.
123
- private readonly _closeResolvers: (() => void)[] = [];
124
-
125
- // Sub-components
126
- private readonly _tree: DefaultTree<TMessage>;
127
- private readonly _view: DefaultView<TEvent, TMessage>;
128
- private readonly _views = new Set<DefaultView<TEvent, TMessage>>();
129
- private readonly _router: StreamRouter<TEvent>;
130
- private readonly _decoder: StreamDecoder<TEvent, TMessage>;
131
-
132
- // Spec: AIT-CT10, AIT-CT10a
133
- readonly tree: Tree<TMessage>;
134
- readonly view: View<TEvent, TMessage>;
135
-
136
- // Channel subscription — subscribe() returns a Promise that resolves when the channel attaches
137
- private readonly _attachPromise: Promise<unknown>;
138
- private readonly _onMessage: (msg: Ably.InboundMessage) => void;
139
-
140
- private _state = ClientTransportState.READY;
141
- private _hasAttachedOnce: boolean;
142
- private readonly _onChannelStateChange: Ably.channelEventCallback;
143
-
144
- // Events staged locally via stageEvents(). Flushed into the eventNodes
145
- // parameter of _internalSend on the next send operation.
146
- private _pendingLocalEvents: EventsNode<TEvent>[] = [];
147
-
148
- constructor(options: ClientTransportOptions<TEvent, TMessage>) {
149
- this._channel = options.channel;
150
- this._codec = options.codec;
151
- this._clientId = options.clientId;
152
- this._api = options.api;
153
- this._credentials = options.credentials;
154
- // CAST: TS can't narrow options.headers/body inside a closure because the outer
155
- // object is mutable. The truthiness check on the preceding line guarantees non-nullish.
156
- this._headersFn =
157
- typeof options.headers === 'function'
158
- ? options.headers
159
- : options.headers
160
- ? () => options.headers as Record<string, string>
161
- : undefined;
162
- this._bodyFn =
163
- typeof options.body === 'function'
164
- ? options.body
165
- : options.body
166
- ? () => options.body as Record<string, unknown>
167
- : undefined;
168
- this._fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
169
- this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
170
- component: 'ClientTransport',
171
- });
172
-
173
- this._emitter = new EventEmitter<ClientTransportEventsMap>(this._logger);
174
- this._hasAttachedOnce = this._channel.state === 'attached';
175
-
176
- // Compose sub-components
177
- this._tree = createTree<TMessage>(this._logger);
178
- this._view = createView<TEvent, TMessage>({
179
- tree: this._tree,
180
- channel: this._channel,
181
- codec: this._codec,
182
- sendDelegate: this._internalSend.bind(this),
183
- logger: this._logger,
184
- onClose: () => this._views.delete(this._view),
185
- });
186
- this._router = createStreamRouter<TEvent>(this._codec.isTerminal.bind(this._codec), this._logger);
187
- this._decoder = this._codec.createDecoder();
188
-
189
- this._views.add(this._view);
190
-
191
- // Public accessors (typed as narrow interfaces)
192
- this.tree = this._tree;
193
- this.view = this._view;
194
-
195
- // Seed tree with initial messages — transport assigns its own msgId
196
- if (options.messages) {
197
- let prevMsgId: string | undefined;
198
- for (const msg of options.messages) {
199
- const msgId = crypto.randomUUID();
200
- const seedHeaders: Record<string, string> = { [HEADER_MSG_ID]: msgId };
201
- if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
202
- this._tree.upsert(msgId, msg, seedHeaders);
203
- prevMsgId = msgId;
204
- }
205
- }
206
-
207
- // Spec: AIT-CT2
208
- // Subscribe before attach (RTL7g)
209
- this._onMessage = (ablyMessage: Ably.InboundMessage) => {
210
- this._handleMessage(ablyMessage);
211
- };
212
- this._attachPromise = this._channel.subscribe(this._onMessage);
213
-
214
- // Listen for channel state changes that break message continuity.
215
- // _hasAttachedOnce is seeded from the channel's current state so that
216
- // pre-attached channels are handled correctly. It distinguishes the
217
- // initial attach (expected) from a genuine discontinuity.
218
- this._onChannelStateChange = (stateChange: Ably.ChannelStateChange) => {
219
- this._handleChannelStateChange(stateChange);
220
- };
221
- this._channel.on(this._onChannelStateChange);
222
- }
223
-
224
- // ---------------------------------------------------------------------------
225
- // Message subscription handler
226
- // ---------------------------------------------------------------------------
227
-
228
- private _handleMessage(ablyMessage: Ably.InboundMessage): void {
229
- if (this._state === ClientTransportState.CLOSED) return;
230
-
231
- try {
232
- // Spec: AIT-CT16a
233
- // --- Turn lifecycle events from the server ---
234
- if (ablyMessage.name === EVENT_TURN_START) {
235
- const headers = getHeaders(ablyMessage);
236
- const turnId = headers[HEADER_TURN_ID];
237
- const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
238
- if (turnId) {
239
- this._tree.trackTurn(turnId, turnCid);
240
- const parentRaw = headers[HEADER_PARENT];
241
- const forkOf = headers[HEADER_FORK_OF];
242
- this._tree.emitTurn({
243
- type: EVENT_TURN_START,
244
- turnId,
245
- clientId: turnCid,
246
- ...(parentRaw !== undefined && { parent: parentRaw }),
247
- ...(forkOf !== undefined && { forkOf }),
248
- });
249
- }
250
- this._tree.emitAblyMessage(ablyMessage);
251
- return;
252
- }
253
-
254
- if (ablyMessage.name === EVENT_TURN_END) {
255
- const headers = getHeaders(ablyMessage);
256
- const turnId = headers[HEADER_TURN_ID];
257
- const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
258
- // CAST: server always writes a valid TurnEndReason; default to 'complete' for robustness
259
- const reason = (headers[HEADER_TURN_REASON] ?? 'complete') as TurnEndReason;
260
- if (turnId) {
261
- this._router.closeStream(turnId);
262
- this._turnObservers.delete(turnId);
263
- this._tree.untrackTurn(turnId);
264
- // Clean up per-turn relay-detection state
265
- const msgIds = this._turnMsgIds.get(turnId);
266
- if (msgIds) {
267
- for (const mid of msgIds) this._ownMsgIds.delete(mid);
268
- this._turnMsgIds.delete(turnId);
269
- }
270
- this._ownTurnIds.delete(turnId);
271
- this._tree.emitTurn({ type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
272
- }
273
- this._tree.emitAblyMessage(ablyMessage);
274
- return;
275
- }
276
-
277
- // --- Codec-decoded messages ---
278
- const outputs = this._decoder.decode(ablyMessage);
279
- const headers = getHeaders(ablyMessage);
280
- const serial = ablyMessage.serial;
281
-
282
- // Cross-turn events target an existing message from a prior turn,
283
- // bypassing the current turn's accumulator.
284
- const amendTarget = headers[HEADER_AMEND];
285
- if (amendTarget) {
286
- for (const output of outputs) {
287
- if (output.kind === 'event') {
288
- this._handleAmendmentEvent(amendTarget, output);
289
- }
290
- }
291
- return;
292
- }
293
-
294
- // Always update observer headers, even when the decoder produces no outputs.
295
- // This ensures header transitions (e.g. x-ably-status: streaming → aborted)
296
- // are captured for events that the decoder suppresses (AIT-CD8: aborted
297
- // stream appends emit no events but still carry the updated status header).
298
- const turnId = headers[HEADER_TURN_ID];
299
- if (turnId) {
300
- this._updateTurnObserverHeaders(turnId, headers, serial);
301
- }
302
-
303
- for (const output of outputs) {
304
- if (output.kind === 'message') {
305
- this._handleMessageOutput(output.message, headers, serial, ablyMessage.action);
306
- } else {
307
- this._handleEventOutput(output, headers);
308
- }
309
- }
310
-
311
- // Emit ably-message AFTER decode/upsert so that View subscribers can
312
- // find the node in _lastVisibleIds (which is refreshed by tree 'update'
313
- // events triggered during upsert).
314
- this._tree.emitAblyMessage(ablyMessage);
315
- } catch (error) {
316
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
317
- this._emitter.emit(
318
- 'error',
319
- new Ably.ErrorInfo(
320
- `unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
321
- ErrorCode.TransportSubscriptionError,
322
- 500,
323
- cause,
324
- ),
325
- );
326
- }
327
- }
328
-
329
- /**
330
- * Handle a decoded domain message (user message create or relayed own message).
331
- * @param message - The decoded domain message.
332
- * @param headers - Ably headers from the wire message.
333
- * @param serial - Ably serial for tree ordering.
334
- * @param action - Ably message action (e.g. 'message.create').
335
- */
336
- private _handleMessageOutput(
337
- message: TMessage,
338
- headers: Record<string, string>,
339
- serial: string | undefined,
340
- action: string | undefined,
341
- ): void {
342
- // Spec: AIT-CT15
343
- const msgId = headers[HEADER_MSG_ID];
344
- if (msgId && this._ownMsgIds.has(msgId)) {
345
- // Relayed own message — reconcile optimistic entry with server-assigned fields
346
- this._upsertAndNotify(message, headers, serial);
347
- return;
348
- }
349
-
350
- if (action === 'message.create') {
351
- this._upsertAndNotify(message, headers, serial);
352
- }
353
- }
354
-
355
- /**
356
- * Handle a decoded streaming event: route to own-turn stream or accumulate for observer.
357
- * @param output - The decoded event output from the codec.
358
- * @param headers - Ably headers from the wire message.
359
- */
360
- private _handleEventOutput(output: DecoderOutput<TEvent, TMessage>, headers: Record<string, string>): void {
361
- if (output.kind !== 'event') return;
362
- const event = output.event;
363
- const turnId = headers[HEADER_TURN_ID];
364
- if (!turnId) return;
365
-
366
- // Observer headers are already updated in _handleMessage (before outputs
367
- // are iterated) so that header transitions are captured even when the
368
- // decoder produces no outputs (e.g. aborted stream appends per AIT-CD8).
369
-
370
- // Active own turn — route to the ReadableStream
371
- if (this._router.route(turnId, event)) {
372
- this._accumulateAndEmit(turnId, output);
373
- if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
374
- return;
375
- }
376
-
377
- // Completed own turn — late arrival, skip
378
- if (this._ownTurnIds.has(turnId) && !this._turnObservers.has(turnId)) return;
379
-
380
- // Spec: AIT-CT16
381
- // Observer turn — accumulate and emit
382
- this._accumulateAndEmit(turnId, output);
383
- if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
384
- }
385
-
386
- /**
387
- * Handle a cross-turn event targeting an existing message from a prior turn.
388
- * Creates a temporary accumulator, seeds it with the existing message,
389
- * processes the event, and upserts the updated message into the tree.
390
- * @param targetMsgId - The x-ably-msg-id of the message to update.
391
- * @param output - The decoded event output to apply.
392
- */
393
- private _handleAmendmentEvent(targetMsgId: string, output: DecoderOutput<TEvent, TMessage>): void {
394
- this._logger.trace('ClientTransport._handleAmendmentEvent();', { targetMsgId });
395
-
396
- const existingNode = this._tree.getNode(targetMsgId);
397
- if (!existingNode) {
398
- this._logger.debug('ClientTransport._handleAmendmentEvent(); target not found, dropping', { targetMsgId });
399
- return;
400
- }
401
-
402
- const accumulator = this._codec.createAccumulator();
403
- accumulator.initMessage(targetMsgId, existingNode.message);
404
- accumulator.processOutputs([output]);
405
-
406
- const updatedMsg = accumulator.messages.at(-1);
407
- if (updatedMsg) {
408
- this._tree.upsert(targetMsgId, updatedMsg, existingNode.headers, existingNode.serial);
409
- }
410
- }
411
-
412
- // ---------------------------------------------------------------------------
413
- // Channel state change handler
414
- // ---------------------------------------------------------------------------
415
-
416
- // Spec: AIT-CT19, AIT-CT19a
417
- private _handleChannelStateChange(stateChange: Ably.ChannelStateChange): void {
418
- if (this._state === ClientTransportState.CLOSED) return;
419
-
420
- const { current, resumed } = stateChange;
421
-
422
- // Track the initial attach so we don't treat it as a discontinuity
423
- if (current === 'attached' && !this._hasAttachedOnce) {
424
- this._hasAttachedOnce = true;
425
- return;
426
- }
427
-
428
- // Continuity-breaking states:
429
- // - FAILED, SUSPENDED, DETACHED: no more messages expected (or gap)
430
- // - ATTACHED with resumed: false (UPDATE): messages were lost
431
- const continuityLost =
432
- current === 'failed' || current === 'suspended' || current === 'detached' || (current === 'attached' && !resumed);
433
-
434
- if (!continuityLost) return;
435
-
436
- this._logger.error('ClientTransport._handleChannelStateChange(); channel continuity lost', {
437
- current,
438
- resumed,
439
- previous: stateChange.previous,
440
- });
441
-
442
- const err = new Ably.ErrorInfo(
443
- `unable to deliver events; channel continuity lost (${current}${current === 'attached' ? ', resumed: false' : ''})`,
444
- ErrorCode.ChannelContinuityLost,
445
- 500,
446
- stateChange.reason,
447
- );
448
-
449
- // As with cancellation (_closeMatchingTurnStreams), do not clear
450
- // _ownTurnIds or _turnObservers here — late events must still accumulate
451
- // into the tree. The turn-end handler cleans up observers.
452
- for (const turnId of this._ownTurnIds) {
453
- this._router.errorStream(turnId, err);
454
- }
455
-
456
- this._emitter.emit('error', err);
457
- }
458
-
459
- // ---------------------------------------------------------------------------
460
- // Tree mutation + notification helpers
461
- // ---------------------------------------------------------------------------
462
-
463
- /**
464
- * Upsert a message into the tree and notify subscribers.
465
- * @param message - The domain message to insert or update.
466
- * @param headers - Ably headers for the message.
467
- * @param serial - Ably serial for tree ordering.
468
- */
469
- private _upsertAndNotify(message: TMessage, headers: Record<string, string>, serial?: string): void {
470
- const msgId = headers[HEADER_MSG_ID];
471
- if (!msgId) return;
472
- this._tree.upsert(msgId, message, headers, serial);
473
- }
474
-
475
- // ---------------------------------------------------------------------------
476
- // Observer accumulation
477
- // ---------------------------------------------------------------------------
478
-
479
- /**
480
- * Ensure a TurnObserverState exists for turnId, updating headers and serial as new events arrive.
481
- * @param turnId - The turn to track.
482
- * @param headers - Headers from the current event.
483
- * @param serial - Ably serial from the current event.
484
- */
485
- private _updateTurnObserverHeaders(
486
- turnId: string,
487
- headers: Record<string, string>,
488
- serial: string | undefined,
489
- ): void {
490
- const existing = this._turnObservers.get(turnId);
491
- if (existing) {
492
- if (Object.keys(headers).length > 0) {
493
- Object.assign(existing.headers, headers);
494
- }
495
- // Always advance the serial so the tree node sorts after all
496
- // earlier messages in the turn (e.g. user-message relays that
497
- // arrive before the assistant response).
498
- if (serial !== undefined) {
499
- existing.serial = serial;
500
- }
501
- } else {
502
- this._turnObservers.set(turnId, {
503
- headers: { ...headers },
504
- serial,
505
- accumulator: this._codec.createAccumulator(),
506
- });
507
- }
508
- }
509
-
510
- /**
511
- * Process a streaming event through the turn's accumulator and emit the latest message.
512
- * @param turnId - The turn this event belongs to.
513
- * @param output - The decoded event output to accumulate.
514
- */
515
- private _accumulateAndEmit(turnId: string, output: DecoderOutput<TEvent, TMessage>): void {
516
- const observer = this._turnObservers.get(turnId);
517
- if (!observer) return;
518
-
519
- // Sync the accumulator with the tree before processing. If the message
520
- // was updated externally (via cross-turn events), initMessage syncs the
521
- // accumulator's state so the update isn't lost when processing
522
- // late turn events like finish-step/finish.
523
- const msgId = observer.headers[HEADER_MSG_ID];
524
- if (msgId) {
525
- const treeNode = this._tree.getNode(msgId);
526
- if (treeNode) {
527
- observer.accumulator.initMessage(msgId, treeNode.message);
528
- }
529
- }
530
-
531
- observer.accumulator.processOutputs([output]);
532
-
533
- const messages = observer.accumulator.messages;
534
- if (messages.length === 0) return;
535
-
536
- let message: TMessage | undefined;
537
- try {
538
- message = structuredClone(messages.at(-1));
539
- } catch {
540
- // CAST: structuredClone can fail if the message contains non-cloneable
541
- // values (e.g. functions). Fall back to the reference — the tree upsert
542
- // below copies headers independently, so shared message state is the
543
- // only risk. Accumulator messages are replaced on each event, so
544
- // mutation between events is not a practical concern.
545
- message = messages.at(-1);
546
- }
547
-
548
- if (message) {
549
- const msgId = observer.headers[HEADER_MSG_ID];
550
- if (msgId) {
551
- this._tree.upsert(msgId, message, { ...observer.headers }, observer.serial);
552
- }
553
- }
554
- }
555
-
556
- // ---------------------------------------------------------------------------
557
- // Cancel helpers
558
- // ---------------------------------------------------------------------------
559
-
560
- private async _publishCancel(filter: CancelFilter): Promise<void> {
561
- this._logger.trace('ClientTransport._publishCancel();', { filter });
562
-
563
- const headers: Record<string, string> = {};
564
- if (filter.turnId) {
565
- headers[HEADER_CANCEL_TURN_ID] = filter.turnId;
566
- } else if (filter.own) {
567
- headers[HEADER_CANCEL_OWN] = 'true';
568
- } else if (filter.clientId) {
569
- headers[HEADER_CANCEL_CLIENT_ID] = filter.clientId;
570
- } else if (filter.all) {
571
- headers[HEADER_CANCEL_ALL] = 'true';
572
- }
573
-
574
- await this._channel.publish({
575
- name: EVENT_CANCEL,
576
- extras: { headers },
577
- });
578
- }
579
-
580
- private _closeMatchingTurnStreams(filter: CancelFilter): void {
581
- // Only close the router streams here — do NOT clear _turnObservers.
582
- // The observer must remain alive so that late server events (e.g. abort,
583
- // x-ably-status: aborted) arriving before turn-end are still accumulated
584
- // into the message store. The turn-end handler cleans up observers.
585
- for (const turnId of this._getMatchingTurnIds(filter)) {
586
- this._router.closeStream(turnId);
587
- }
588
- }
589
-
590
- private _getMatchingTurnIds(filter: CancelFilter): Set<string> {
591
- const matched = new Set<string>();
592
- const activeTurns = this._tree.getActiveTurnIds();
593
-
594
- if (filter.all) {
595
- for (const turnIds of activeTurns.values()) {
596
- for (const turnId of turnIds) matched.add(turnId);
597
- }
598
- } else if (filter.own) {
599
- const ownTurns = activeTurns.get(this._clientId ?? '');
600
- if (ownTurns) {
601
- for (const turnId of ownTurns) matched.add(turnId);
602
- }
603
- } else if (filter.clientId) {
604
- const clientTurns = activeTurns.get(filter.clientId);
605
- if (clientTurns) {
606
- for (const turnId of clientTurns) matched.add(turnId);
607
- }
608
- } else if (filter.turnId) {
609
- // Check if the turnId exists in any client's turns
610
- for (const turnIds of activeTurns.values()) {
611
- if (turnIds.has(filter.turnId)) {
612
- matched.add(filter.turnId);
613
- break;
614
- }
615
- }
616
- }
617
- return matched;
618
- }
619
-
620
- // ---------------------------------------------------------------------------
621
- // Input message helpers
622
- // ---------------------------------------------------------------------------
623
-
624
- // ---------------------------------------------------------------------------
625
- // Public API
626
- // ---------------------------------------------------------------------------
627
-
628
- // Spec: AIT-CT10b
629
- createView(): View<TEvent, TMessage> {
630
- if (this._state === ClientTransportState.CLOSED) {
631
- throw new Ably.ErrorInfo('unable to create view; transport is closed', ErrorCode.TransportClosed, 400);
632
- }
633
- this._logger.trace('DefaultClientTransport.createView();');
634
- const view = createView<TEvent, TMessage>({
635
- tree: this._tree,
636
- channel: this._channel,
637
- codec: this._codec,
638
- sendDelegate: this._internalSend.bind(this),
639
- logger: this._logger,
640
- onClose: () => this._views.delete(view),
641
- });
642
- this._views.add(view);
643
- return view;
644
- }
645
-
646
- // Spec: AIT-CT3, AIT-CT4
647
- private async _internalSend(
648
- input: TMessage | TMessage[],
649
- sendOptions: SendOptions | undefined,
650
- history: MessageNode<TMessage>[],
651
- eventNodes?: EventsNode<TEvent>[],
652
- ): Promise<ActiveTurn<TEvent>> {
653
- if (this._state === ClientTransportState.CLOSED) {
654
- throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
655
- }
656
- await this._attachPromise;
657
- // CAST: re-check after await — close() may have been called while waiting for attach.
658
- // TypeScript's control flow narrows _state after the first check, but the
659
- // await yields and close() can mutate _state concurrently.
660
- if ((this._state as ClientTransportState) === ClientTransportState.CLOSED) {
661
- throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
662
- }
663
-
664
- // Spec: AIT-CT20
665
- const state = this._channel.state;
666
- if (state !== 'attached' && state !== 'attaching') {
667
- throw new Ably.ErrorInfo(`unable to send; channel is ${state}`, ErrorCode.ChannelNotReady, 400);
668
- }
669
-
670
- this._logger.trace('ClientTransport._internalSend();');
671
-
672
- const msgs = Array.isArray(input) ? input : [input];
673
- const turnId = crypto.randomUUID();
674
- this._ownTurnIds.add(turnId);
675
- this._tree.trackTurn(turnId, this._clientId ?? '');
676
-
677
- // Flush any events staged via stageEvents() since the last send. They
678
- // have already been applied to the tree, so merge them into the POST
679
- // body without re-applying. External eventNodes (e.g. from view.update)
680
- // have NOT been applied yet and need the optimistic tree update below.
681
- const flushedStaged = this._pendingLocalEvents;
682
- this._pendingLocalEvents = [];
683
-
684
- // Optimistic tree updates for external cross-turn events — must happen
685
- // before capturing history so the POST body includes the updated
686
- // message state.
687
- if (eventNodes && eventNodes.length > 0) {
688
- this._applyEventsToTree(eventNodes);
689
- }
690
-
691
- const allEventNodes: EventsNode<TEvent>[] = [...flushedStaged, ...(eventNodes ?? [])];
692
-
693
- const msgIds = new Set<string>();
694
- const postMessages: MessageNode<TMessage>[] = [];
695
-
696
- // The View pre-computed the visible branch before calling this delegate,
697
- // so preInsertHistory reflects the state before any optimistic inserts.
698
- const preInsertHistory = history;
699
-
700
- // Spec: AIT-CT3d
701
- // Auto-compute parent from the current thread if not explicitly provided
702
- let autoParent: string | undefined;
703
- if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
704
- const lastNode = preInsertHistory.at(-1);
705
- if (lastNode) {
706
- autoParent = lastNode.msgId;
707
- }
708
- }
709
-
710
- // Capture the first parent for the POST body before the loop advances it.
711
- const postParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
712
-
713
- for (const message of msgs) {
714
- const msgId = crypto.randomUUID();
715
- this._ownMsgIds.add(msgId);
716
- msgIds.add(msgId);
717
-
718
- const resolvedParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
719
-
720
- const optimisticHeaders = buildTransportHeaders({
721
- role: 'user',
722
- turnId,
723
- msgId,
724
- turnClientId: this._clientId,
725
- parent: resolvedParent,
726
- forkOf: sendOptions?.forkOf,
727
- });
728
- // Spec: AIT-CT3c
729
- // Optimistically insert each user message into the tree
730
- this._upsertAndNotify(message, optimisticHeaders);
731
-
732
- // Build MessageNode for the POST body
733
- postMessages.push({
734
- kind: 'message',
735
- message,
736
- msgId,
737
- parentId: resolvedParent,
738
- forkOf: sendOptions?.forkOf,
739
- headers: optimisticHeaders,
740
- serial: undefined,
741
- });
742
-
743
- // Spec: AIT-CT3e
744
- // Chain: each subsequent message in the batch parents off the previous
745
- // one, forming a linear conversation thread rather than siblings.
746
- if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
747
- autoParent = msgId;
748
- }
749
- }
750
-
751
- this._turnMsgIds.set(turnId, msgIds);
752
-
753
- // Create ReadableStream via router
754
- const stream = this._router.createStream(turnId);
755
-
756
- // Resolve headers and body
757
- const resolvedHeaders = this._headersFn?.() ?? {};
758
- const resolvedBody = this._bodyFn?.() ?? {};
759
-
760
- const postBody: Record<string, unknown> = {
761
- ...resolvedBody,
762
- history: preInsertHistory,
763
- ...sendOptions?.body,
764
- turnId,
765
- clientId: this._clientId,
766
- messages: postMessages,
767
- ...(sendOptions?.forkOf !== undefined && { forkOf: sendOptions.forkOf }),
768
- ...(postParent !== undefined && { parent: postParent }),
769
- ...(allEventNodes.length > 0 && { events: allEventNodes }),
770
- };
771
-
772
- const postHeaders: Record<string, string> = {
773
- ...resolvedHeaders,
774
- ...sendOptions?.headers,
775
- };
776
-
777
- // Spec: AIT-CT3a, AIT-CT3b
778
- // Fire-and-forget: POST must not block the stream return to the caller.
779
- // .catch() is intentional — async/await would delay stream availability.
780
- this._fetchFn(this._api, {
781
- method: 'POST',
782
- headers: {
783
- 'Content-Type': 'application/json',
784
- ...postHeaders,
785
- },
786
- body: JSON.stringify(postBody),
787
- ...(this._credentials ? { credentials: this._credentials } : {}),
788
- })
789
- .then((response) => {
790
- if (!response.ok) {
791
- const err = new Ably.ErrorInfo(
792
- `unable to send; HTTP POST to ${this._api} returned ${String(response.status)} ${response.statusText}`,
793
- ErrorCode.TransportSendFailed,
794
- response.status,
795
- );
796
- this._emitter.emit('error', err);
797
- this._router.errorStream(turnId, err);
798
- }
799
- })
800
- .catch((error: unknown) => {
801
- const cause = error instanceof Ably.ErrorInfo ? error : undefined;
802
- const err = new Ably.ErrorInfo(
803
- `unable to send; HTTP POST to ${this._api} failed: ${error instanceof Error ? error.message : String(error)}`,
804
- ErrorCode.TransportSendFailed,
805
- 500,
806
- cause,
807
- );
808
- this._emitter.emit('error', err);
809
- this._router.errorStream(turnId, err);
810
- });
811
-
812
- return {
813
- stream,
814
- turnId,
815
- cancel: async () => this.cancel({ turnId }),
816
- optimisticMsgIds: [...msgIds],
817
- };
818
- }
819
-
820
- // Spec: AIT-CT7, AIT-CT7a
821
- async cancel(filter?: CancelFilter): Promise<void> {
822
- if (this._state === ClientTransportState.CLOSED) return;
823
- const resolved = filter ?? { own: true };
824
- this._logger.debug('ClientTransport.cancel();', { filter: resolved });
825
- await this._publishCancel(resolved);
826
- this._closeMatchingTurnStreams(resolved);
827
- }
828
-
829
- stageEvents(msgId: string, events: TEvent[]): void {
830
- this._logger.trace('ClientTransport.stageEvents();', { msgId, eventCount: events.length });
831
- if (this._state === ClientTransportState.CLOSED) {
832
- this._logger.warn('ClientTransport.stageEvents(); transport is closed', { msgId });
833
- return;
834
- }
835
- if (!this._tree.getNode(msgId)) {
836
- this._logger.warn('ClientTransport.stageEvents(); msgId not found in tree', { msgId });
837
- return;
838
- }
839
- if (events.length === 0) return;
840
- const node: EventsNode<TEvent> = { kind: 'event', msgId, events };
841
- // Apply immediately so any subsequent useMessageSync / tree observer
842
- // sees the merged state — no window where the staged event can be
843
- // clobbered by an interleaved observer turn update.
844
- this._applyEventsToTree([node]);
845
- this._pendingLocalEvents.push(node);
846
- }
847
-
848
- stageMessage(msgId: string, message: TMessage): void {
849
- this._logger.trace('ClientTransport.stageMessage();', { msgId });
850
- if (this._state === ClientTransportState.CLOSED) {
851
- this._logger.warn('ClientTransport.stageMessage(); transport is closed', { msgId });
852
- return;
853
- }
854
- const existing = this._tree.getNode(msgId);
855
- if (!existing) {
856
- this._logger.warn('ClientTransport.stageMessage(); msgId not found in tree', { msgId });
857
- return;
858
- }
859
- // Preserve structural metadata; only the message body changes.
860
- this._tree.upsert(msgId, message, existing.headers, existing.serial);
861
- }
862
-
863
- // Apply events to the tree using the codec's accumulator. Shared by
864
- // stageEvents (local staging) and _internalSend (external eventNodes
865
- // arriving via view.update).
866
- private _applyEventsToTree(eventNodes: EventsNode<TEvent>[]): void {
867
- for (const node of eventNodes) {
868
- const existingNode = this._tree.getNode(node.msgId);
869
- if (!existingNode) continue;
870
- const outputs = node.events.map((event) => ({
871
- kind: 'event' as const,
872
- event,
873
- messageId: node.msgId,
874
- }));
875
- const accumulator = this._codec.createAccumulator();
876
- accumulator.initMessage(node.msgId, existingNode.message);
877
- accumulator.processOutputs(outputs);
878
- const updatedMsg = accumulator.messages.at(-1);
879
- if (updatedMsg) {
880
- this._tree.upsert(node.msgId, updatedMsg, existingNode.headers, existingNode.serial);
881
- }
882
- }
883
- }
884
-
885
- // Spec: AIT-CT18
886
- async waitForTurn(filter?: CancelFilter): Promise<void> {
887
- if (this._state === ClientTransportState.CLOSED) return;
888
- const resolved = filter ?? { own: true };
889
- const remaining = this._getMatchingTurnIds(resolved);
890
- if (remaining.size === 0) return;
891
-
892
- this._logger.debug('ClientTransport.waitForTurn();', { turnIds: [...remaining] });
893
-
894
- return new Promise<void>((resolve) => {
895
- let resolved = false;
896
- const done = (): void => {
897
- if (resolved) return;
898
- resolved = true;
899
- unsub();
900
- const idx = this._closeResolvers.indexOf(done);
901
- if (idx !== -1) this._closeResolvers.splice(idx, 1);
902
- resolve();
903
- };
904
-
905
- const unsub = this._tree.on('turn', (event: TurnLifecycleEvent) => {
906
- if (event.type !== EVENT_TURN_END) return;
907
- remaining.delete(event.turnId);
908
- if (remaining.size === 0) done();
909
- });
910
-
911
- // Resolve on transport close to prevent leaked subscriptions
912
- this._closeResolvers.push(done);
913
- });
914
- }
915
-
916
- // Spec: AIT-CT8, AIT-CT8c, AIT-CT8d
917
- on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void {
918
- if (this._state === ClientTransportState.CLOSED) return noopUnsubscribe;
919
- // CAST: the overload signature enforces the correct handler type.
920
- const cb = handler as (arg: ClientTransportEventsMap[keyof ClientTransportEventsMap]) => void;
921
- this._emitter.on(event, cb);
922
- return () => {
923
- this._emitter.off(event, cb);
924
- };
925
- }
926
-
927
- // Spec: AIT-CT12, AIT-CT12a, AIT-CT12b, AIT-CT10c
928
- async close(options?: CloseOptions): Promise<void> {
929
- if (this._state === ClientTransportState.CLOSED) return;
930
- this._state = ClientTransportState.CLOSED;
931
- this._logger.info('ClientTransport.close();');
932
-
933
- // Best-effort cancel publish before tearing down local state
934
- if (options?.cancel) {
935
- try {
936
- await this._publishCancel(options.cancel);
937
- } catch {
938
- // Swallow: cancel is best-effort during teardown
939
- }
940
- this._closeMatchingTurnStreams(options.cancel);
941
- }
942
-
943
- this._channel.unsubscribe(this._onMessage);
944
- this._channel.off(this._onChannelStateChange);
945
-
946
- // Close any remaining active streams
947
- for (const turnId of this._ownTurnIds) {
948
- this._router.closeStream(turnId);
949
- }
950
-
951
- this._turnObservers.clear();
952
- this._emitter.off();
953
- for (const v of this._views) v.close();
954
- this._views.clear();
955
- for (const resolve of this._closeResolvers) resolve();
956
- this._closeResolvers.length = 0;
957
- this._ownTurnIds.clear();
958
- this._ownMsgIds.clear();
959
- this._turnMsgIds.clear();
960
- }
961
- }
962
-
963
- // ---------------------------------------------------------------------------
964
- // Factory
965
- // ---------------------------------------------------------------------------
966
-
967
- /**
968
- * Create a client-side transport that manages conversation state over an Ably channel.
969
- *
970
- * Subscribes to the channel immediately (before attach per RTL7g). The caller should
971
- * ensure the channel is attached or will be attached shortly after creation.
972
- * @param options - Configuration for the client transport.
973
- * @returns A new {@link ClientTransport} instance.
974
- */
975
- export const createClientTransport = <TEvent, TMessage>(
976
- options: ClientTransportOptions<TEvent, TMessage>,
977
- ): ClientTransport<TEvent, TMessage> => new DefaultClientTransport(options);