@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.
- package/LICENSE +176 -0
- package/README.md +426 -0
- package/dist/ably-ai-transport.js +1388 -0
- package/dist/ably-ai-transport.js.map +1 -0
- package/dist/ably-ai-transport.umd.cjs +2 -0
- package/dist/ably-ai-transport.umd.cjs.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/core/codec/decoder.d.ts +62 -0
- package/dist/core/codec/encoder.d.ts +56 -0
- package/dist/core/codec/index.d.ts +8 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +74 -0
- package/dist/core/codec/types.d.ts +188 -0
- package/dist/core/transport/client-transport.d.ts +10 -0
- package/dist/core/transport/conversation-tree.d.ts +9 -0
- package/dist/core/transport/decode-history.d.ts +41 -0
- package/dist/core/transport/headers.d.ts +26 -0
- package/dist/core/transport/index.d.ts +4 -0
- package/dist/core/transport/pipe-stream.d.ts +16 -0
- package/dist/core/transport/server-transport.d.ts +7 -0
- package/dist/core/transport/stream-router.d.ts +19 -0
- package/dist/core/transport/turn-manager.d.ts +34 -0
- package/dist/core/transport/types.d.ts +407 -0
- package/dist/errors.d.ts +46 -0
- package/dist/event-emitter.d.ts +65 -0
- package/dist/index.d.ts +11 -0
- package/dist/logger.d.ts +103 -0
- package/dist/react/ably-ai-transport-react.js +823 -0
- package/dist/react/ably-ai-transport-react.js.map +1 -0
- package/dist/react/ably-ai-transport-react.umd.cjs +2 -0
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/use-ably-messages.d.ts +18 -0
- package/dist/react/use-active-turns.d.ts +8 -0
- package/dist/react/use-client-transport.d.ts +7 -0
- package/dist/react/use-conversation-tree.d.ts +20 -0
- package/dist/react/use-edit.d.ts +7 -0
- package/dist/react/use-history.d.ts +19 -0
- package/dist/react/use-messages.d.ts +7 -0
- package/dist/react/use-regenerate.d.ts +7 -0
- package/dist/react/use-send.d.ts +7 -0
- package/dist/utils.d.ts +127 -0
- package/dist/vercel/ably-ai-transport-vercel.js +2331 -0
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +2 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -0
- package/dist/vercel/codec/accumulator.d.ts +21 -0
- package/dist/vercel/codec/decoder.d.ts +22 -0
- package/dist/vercel/codec/encoder.d.ts +41 -0
- package/dist/vercel/codec/index.d.ts +22 -0
- package/dist/vercel/index.d.ts +3 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2082 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +2 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -0
- package/dist/vercel/react/index.d.ts +3 -0
- package/dist/vercel/react/use-chat-transport.d.ts +29 -0
- package/dist/vercel/react/use-message-sync.d.ts +19 -0
- package/dist/vercel/transport/chat-transport.d.ts +118 -0
- package/dist/vercel/transport/index.d.ts +36 -0
- package/package.json +123 -0
- package/react/README.md +3 -0
- package/react/index.d.ts +1 -0
- package/react/index.js +1 -0
- package/react/index.umd.cjs +1 -0
- package/src/constants.ts +98 -0
- package/src/core/codec/decoder.ts +402 -0
- package/src/core/codec/encoder.ts +470 -0
- package/src/core/codec/index.ts +28 -0
- package/src/core/codec/lifecycle-tracker.ts +140 -0
- package/src/core/codec/types.ts +249 -0
- package/src/core/transport/client-transport.ts +959 -0
- package/src/core/transport/conversation-tree.ts +434 -0
- package/src/core/transport/decode-history.ts +337 -0
- package/src/core/transport/headers.ts +46 -0
- package/src/core/transport/index.ts +34 -0
- package/src/core/transport/pipe-stream.ts +95 -0
- package/src/core/transport/server-transport.ts +458 -0
- package/src/core/transport/stream-router.ts +118 -0
- package/src/core/transport/turn-manager.ts +147 -0
- package/src/core/transport/types.ts +533 -0
- package/src/errors.ts +58 -0
- package/src/event-emitter.ts +103 -0
- package/src/index.ts +89 -0
- package/src/logger.ts +241 -0
- package/src/react/index.ts +11 -0
- package/src/react/use-ably-messages.ts +37 -0
- package/src/react/use-active-turns.ts +61 -0
- package/src/react/use-client-transport.ts +37 -0
- package/src/react/use-conversation-tree.ts +71 -0
- package/src/react/use-edit.ts +24 -0
- package/src/react/use-history.ts +111 -0
- package/src/react/use-messages.ts +32 -0
- package/src/react/use-regenerate.ts +24 -0
- package/src/react/use-send.ts +25 -0
- package/src/react/vite.config.ts +32 -0
- package/src/tsconfig.json +25 -0
- package/src/utils.ts +230 -0
- package/src/vercel/codec/accumulator.ts +603 -0
- package/src/vercel/codec/decoder.ts +615 -0
- package/src/vercel/codec/encoder.ts +396 -0
- package/src/vercel/codec/index.ts +37 -0
- package/src/vercel/index.ts +12 -0
- package/src/vercel/react/index.ts +4 -0
- package/src/vercel/react/use-chat-transport.ts +60 -0
- package/src/vercel/react/use-message-sync.ts +34 -0
- package/src/vercel/react/vite.config.ts +33 -0
- package/src/vercel/transport/chat-transport.ts +278 -0
- package/src/vercel/transport/index.ts +56 -0
- package/src/vercel/vite.config.ts +33 -0
- package/src/vite.config.ts +31 -0
- package/vercel/README.md +3 -0
- package/vercel/index.d.ts +1 -0
- package/vercel/index.js +1 -0
- package/vercel/index.umd.cjs +1 -0
- package/vercel/react/README.md +3 -0
- package/vercel/react/index.d.ts +1 -0
- package/vercel/react/index.js +1 -0
- package/vercel/react/index.umd.cjs +1 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core client-side transport, parameterized by codec.
|
|
3
|
+
*
|
|
4
|
+
* Composes StreamRouter and ConversationTree 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_CANCEL_ALL,
|
|
20
|
+
HEADER_CANCEL_CLIENT_ID,
|
|
21
|
+
HEADER_CANCEL_OWN,
|
|
22
|
+
HEADER_CANCEL_TURN_ID,
|
|
23
|
+
HEADER_MSG_ID,
|
|
24
|
+
HEADER_PARENT,
|
|
25
|
+
HEADER_ROLE,
|
|
26
|
+
HEADER_TURN_CLIENT_ID,
|
|
27
|
+
HEADER_TURN_ID,
|
|
28
|
+
HEADER_TURN_REASON,
|
|
29
|
+
} from '../../constants.js';
|
|
30
|
+
import { ErrorCode } from '../../errors.js';
|
|
31
|
+
import { EventEmitter } from '../../event-emitter.js';
|
|
32
|
+
import type { Logger } from '../../logger.js';
|
|
33
|
+
import { LogLevel, makeLogger } from '../../logger.js';
|
|
34
|
+
import { getHeaders } from '../../utils.js';
|
|
35
|
+
import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
|
|
36
|
+
import { createConversationTree } from './conversation-tree.js';
|
|
37
|
+
import { decodeHistory } from './decode-history.js';
|
|
38
|
+
import { buildTransportHeaders } from './headers.js';
|
|
39
|
+
import type { StreamRouter } from './stream-router.js';
|
|
40
|
+
import { createStreamRouter } from './stream-router.js';
|
|
41
|
+
import type {
|
|
42
|
+
ActiveTurn,
|
|
43
|
+
CancelFilter,
|
|
44
|
+
ClientTransport,
|
|
45
|
+
ClientTransportOptions,
|
|
46
|
+
CloseOptions,
|
|
47
|
+
ConversationTree,
|
|
48
|
+
LoadHistoryOptions,
|
|
49
|
+
MessageWithHeaders,
|
|
50
|
+
PaginatedMessages,
|
|
51
|
+
SendOptions,
|
|
52
|
+
TurnEndReason,
|
|
53
|
+
TurnLifecycleEvent,
|
|
54
|
+
} from './types.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returned from `on()` when the transport is already closed — the subscription
|
|
58
|
+
* is silently ignored since no further events will fire.
|
|
59
|
+
*/
|
|
60
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
|
|
61
|
+
const noopUnsubscribe = (): void => {};
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Event map for the transport's typed EventEmitter
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
interface ClientTransportEventsMap {
|
|
68
|
+
message: undefined;
|
|
69
|
+
turn: TurnLifecycleEvent;
|
|
70
|
+
error: Ably.ErrorInfo;
|
|
71
|
+
'ably-message': undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Per-turn observer state — consolidated to avoid parallel-map bookkeeping
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface TurnObserverState<TEvent, TMessage> {
|
|
79
|
+
headers: Record<string, string>;
|
|
80
|
+
serial: string | undefined;
|
|
81
|
+
accumulator: MessageAccumulator<TEvent, TMessage>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Implementation
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
// Spec: AIT-CT1
|
|
89
|
+
class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent, TMessage> {
|
|
90
|
+
private readonly _channel: Ably.RealtimeChannel;
|
|
91
|
+
private readonly _codec: ClientTransportOptions<TEvent, TMessage>['codec'];
|
|
92
|
+
private readonly _clientId: string | undefined;
|
|
93
|
+
private readonly _api: string;
|
|
94
|
+
private readonly _credentials: RequestCredentials | undefined;
|
|
95
|
+
private readonly _headersFn: (() => Record<string, string>) | undefined;
|
|
96
|
+
private readonly _bodyFn: (() => Record<string, unknown>) | undefined;
|
|
97
|
+
private readonly _fetchFn: typeof globalThis.fetch;
|
|
98
|
+
private readonly _logger: Logger;
|
|
99
|
+
|
|
100
|
+
// Typed event emitter for all transport events
|
|
101
|
+
private readonly _emitter: EventEmitter<ClientTransportEventsMap>;
|
|
102
|
+
|
|
103
|
+
// Relay detection — tracks msg-ids of optimistic inserts for reconciliation
|
|
104
|
+
private readonly _ownMsgIds = new Set<string>();
|
|
105
|
+
private readonly _ownTurnIds = new Set<string>();
|
|
106
|
+
|
|
107
|
+
// Track clientId per turn for getActiveTurnIds()
|
|
108
|
+
private readonly _turnClientIds = new Map<string, string>();
|
|
109
|
+
// Track msgIds per turn for cleanup on turn-end
|
|
110
|
+
private readonly _turnMsgIds = new Map<string, Set<string>>();
|
|
111
|
+
|
|
112
|
+
// Per-turn observer state: headers, serial, and accumulator in one map.
|
|
113
|
+
// A single .delete(turnId) cleans up all three.
|
|
114
|
+
private readonly _turnObservers = new Map<string, TurnObserverState<TEvent, TMessage>>();
|
|
115
|
+
|
|
116
|
+
// Raw Ably message log
|
|
117
|
+
private readonly _ablyMessages: Ably.InboundMessage[] = [];
|
|
118
|
+
|
|
119
|
+
// History pagination: withheld messages hidden from getMessages()
|
|
120
|
+
private readonly _withheldKeys = new Set<string>();
|
|
121
|
+
|
|
122
|
+
// Sub-components
|
|
123
|
+
private readonly _tree: ConversationTree<TMessage>;
|
|
124
|
+
private readonly _router: StreamRouter<TEvent>;
|
|
125
|
+
private readonly _decoder: StreamDecoder<TEvent, TMessage>;
|
|
126
|
+
|
|
127
|
+
// Channel subscription — subscribe() returns a Promise that resolves when the channel attaches
|
|
128
|
+
private readonly _attachPromise: Promise<unknown>;
|
|
129
|
+
private readonly _onMessage: (msg: Ably.InboundMessage) => void;
|
|
130
|
+
|
|
131
|
+
private _closed = false;
|
|
132
|
+
|
|
133
|
+
constructor(options: ClientTransportOptions<TEvent, TMessage>) {
|
|
134
|
+
this._channel = options.channel;
|
|
135
|
+
this._codec = options.codec;
|
|
136
|
+
this._clientId = options.clientId;
|
|
137
|
+
this._api = options.api ?? '/api/chat';
|
|
138
|
+
this._credentials = options.credentials;
|
|
139
|
+
this._headersFn =
|
|
140
|
+
typeof options.headers === 'function'
|
|
141
|
+
? options.headers
|
|
142
|
+
: options.headers
|
|
143
|
+
? () => options.headers as Record<string, string>
|
|
144
|
+
: undefined;
|
|
145
|
+
this._bodyFn =
|
|
146
|
+
typeof options.body === 'function'
|
|
147
|
+
? options.body
|
|
148
|
+
: options.body
|
|
149
|
+
? () => options.body as Record<string, unknown>
|
|
150
|
+
: undefined;
|
|
151
|
+
this._fetchFn = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
152
|
+
this._logger = (options.logger ?? makeLogger({ logLevel: LogLevel.Silent })).withContext({
|
|
153
|
+
component: 'ClientTransport',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this._emitter = new EventEmitter<ClientTransportEventsMap>(this._logger);
|
|
157
|
+
|
|
158
|
+
// Compose sub-components
|
|
159
|
+
this._tree = createConversationTree<TMessage>(this._codec.getMessageKey.bind(this._codec), this._logger);
|
|
160
|
+
this._router = createStreamRouter<TEvent>(this._codec.isTerminal.bind(this._codec), this._logger);
|
|
161
|
+
this._decoder = this._codec.createDecoder();
|
|
162
|
+
|
|
163
|
+
// Seed tree with initial messages
|
|
164
|
+
if (options.messages) {
|
|
165
|
+
let prevMsgId: string | undefined;
|
|
166
|
+
for (const msg of options.messages) {
|
|
167
|
+
const msgId = this._codec.getMessageKey(msg);
|
|
168
|
+
const seedHeaders: Record<string, string> = {};
|
|
169
|
+
if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
|
|
170
|
+
this._tree.upsert(msgId, msg, seedHeaders);
|
|
171
|
+
prevMsgId = msgId;
|
|
172
|
+
}
|
|
173
|
+
this._emitter.emit('message');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Spec: AIT-CT2
|
|
177
|
+
// Subscribe before attach (RTL7g)
|
|
178
|
+
this._onMessage = (ablyMessage: Ably.InboundMessage) => {
|
|
179
|
+
this._handleMessage(ablyMessage);
|
|
180
|
+
};
|
|
181
|
+
this._attachPromise = this._channel.subscribe(this._onMessage);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Message subscription handler
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
private _handleMessage(ablyMessage: Ably.InboundMessage): void {
|
|
189
|
+
if (this._closed) return;
|
|
190
|
+
|
|
191
|
+
this._ablyMessages.push(ablyMessage);
|
|
192
|
+
this._emitter.emit('ably-message');
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
// Spec: AIT-CT16a
|
|
196
|
+
// --- Turn lifecycle events from the server ---
|
|
197
|
+
if (ablyMessage.name === EVENT_TURN_START) {
|
|
198
|
+
const headers = getHeaders(ablyMessage);
|
|
199
|
+
const turnId = headers[HEADER_TURN_ID];
|
|
200
|
+
const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
|
|
201
|
+
if (turnId) {
|
|
202
|
+
this._turnClientIds.set(turnId, turnCid);
|
|
203
|
+
this._emitter.emit('turn', { type: EVENT_TURN_START, turnId, clientId: turnCid });
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (ablyMessage.name === EVENT_TURN_END) {
|
|
209
|
+
const headers = getHeaders(ablyMessage);
|
|
210
|
+
const turnId = headers[HEADER_TURN_ID];
|
|
211
|
+
const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
|
|
212
|
+
// CAST: server always writes a valid TurnEndReason; default to 'complete' for robustness
|
|
213
|
+
const reason = (headers[HEADER_TURN_REASON] ?? 'complete') as TurnEndReason;
|
|
214
|
+
if (turnId) {
|
|
215
|
+
this._router.closeStream(turnId);
|
|
216
|
+
this._turnObservers.delete(turnId);
|
|
217
|
+
this._turnClientIds.delete(turnId);
|
|
218
|
+
// Clean up per-turn relay-detection state
|
|
219
|
+
const msgIds = this._turnMsgIds.get(turnId);
|
|
220
|
+
if (msgIds) {
|
|
221
|
+
for (const mid of msgIds) this._ownMsgIds.delete(mid);
|
|
222
|
+
this._turnMsgIds.delete(turnId);
|
|
223
|
+
}
|
|
224
|
+
this._ownTurnIds.delete(turnId);
|
|
225
|
+
this._emitter.emit('turn', { type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --- Codec-decoded messages ---
|
|
231
|
+
const outputs = this._decoder.decode(ablyMessage);
|
|
232
|
+
const headers = getHeaders(ablyMessage);
|
|
233
|
+
const serial = ablyMessage.serial;
|
|
234
|
+
|
|
235
|
+
// Always update observer headers, even when the decoder produces no outputs.
|
|
236
|
+
// This ensures header transitions (e.g. x-ably-status: streaming → aborted)
|
|
237
|
+
// are captured for events that the decoder suppresses (AIT-CD8: aborted
|
|
238
|
+
// stream appends emit no events but still carry the updated status header).
|
|
239
|
+
const turnId = headers[HEADER_TURN_ID];
|
|
240
|
+
if (turnId) {
|
|
241
|
+
this._updateTurnObserverHeaders(turnId, headers, serial);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const output of outputs) {
|
|
245
|
+
if (output.kind === 'message') {
|
|
246
|
+
this._handleMessageOutput(output.message, headers, serial, ablyMessage.action);
|
|
247
|
+
} else {
|
|
248
|
+
this._handleEventOutput(output, headers);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
253
|
+
this._emitter.emit(
|
|
254
|
+
'error',
|
|
255
|
+
new Ably.ErrorInfo(
|
|
256
|
+
`unable to process channel message; ${error instanceof Error ? error.message : String(error)}`,
|
|
257
|
+
ErrorCode.TransportSubscriptionError,
|
|
258
|
+
500,
|
|
259
|
+
cause,
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Handle a decoded domain message (user message create or relayed own message).
|
|
267
|
+
* @param message - The decoded domain message.
|
|
268
|
+
* @param headers - Ably headers from the wire message.
|
|
269
|
+
* @param serial - Ably serial for tree ordering.
|
|
270
|
+
* @param action - Ably message action (e.g. 'message.create').
|
|
271
|
+
*/
|
|
272
|
+
private _handleMessageOutput(
|
|
273
|
+
message: TMessage,
|
|
274
|
+
headers: Record<string, string>,
|
|
275
|
+
serial: string | undefined,
|
|
276
|
+
action: string | undefined,
|
|
277
|
+
): void {
|
|
278
|
+
// Spec: AIT-CT15
|
|
279
|
+
const msgId = headers[HEADER_MSG_ID];
|
|
280
|
+
if (msgId && this._ownMsgIds.has(msgId)) {
|
|
281
|
+
// Relayed own message — reconcile optimistic entry with server-assigned fields
|
|
282
|
+
this._upsertAndNotify(message, headers, serial);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (action === 'message.create') {
|
|
287
|
+
this._upsertAndNotify(message, headers, serial);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Handle a decoded streaming event: route to own-turn stream or accumulate for observer.
|
|
293
|
+
* @param output - The decoded event output from the codec.
|
|
294
|
+
* @param headers - Ably headers from the wire message.
|
|
295
|
+
*/
|
|
296
|
+
private _handleEventOutput(output: DecoderOutput<TEvent, TMessage>, headers: Record<string, string>): void {
|
|
297
|
+
if (output.kind !== 'event') return;
|
|
298
|
+
const event = output.event;
|
|
299
|
+
const turnId = headers[HEADER_TURN_ID];
|
|
300
|
+
if (!turnId) return;
|
|
301
|
+
|
|
302
|
+
// Observer headers are already updated in _handleMessage (before outputs
|
|
303
|
+
// are iterated) so that header transitions are captured even when the
|
|
304
|
+
// decoder produces no outputs (e.g. aborted stream appends per AIT-CD8).
|
|
305
|
+
|
|
306
|
+
// Active own turn — route to the ReadableStream
|
|
307
|
+
if (this._router.route(turnId, event)) {
|
|
308
|
+
this._accumulateAndEmit(turnId, output);
|
|
309
|
+
if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Completed own turn — late arrival, skip
|
|
314
|
+
if (this._ownTurnIds.has(turnId) && !this._turnObservers.has(turnId)) return;
|
|
315
|
+
|
|
316
|
+
// Spec: AIT-CT16
|
|
317
|
+
// Observer turn — accumulate and emit
|
|
318
|
+
this._accumulateAndEmit(turnId, output);
|
|
319
|
+
if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Tree mutation + notification helpers
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Upsert a message into the tree and notify subscribers.
|
|
328
|
+
* @param message - The domain message to insert or update.
|
|
329
|
+
* @param headers - Ably headers for the message.
|
|
330
|
+
* @param serial - Ably serial for tree ordering.
|
|
331
|
+
*/
|
|
332
|
+
private _upsertAndNotify(message: TMessage, headers: Record<string, string>, serial?: string): void {
|
|
333
|
+
const key = this._codec.getMessageKey(message);
|
|
334
|
+
const msgId = headers[HEADER_MSG_ID] ?? key;
|
|
335
|
+
this._tree.upsert(msgId, message, headers, serial);
|
|
336
|
+
this._emitter.emit('message');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
// Observer accumulation
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Ensure a TurnObserverState exists for turnId, updating headers and serial as new events arrive.
|
|
345
|
+
* @param turnId - The turn to track.
|
|
346
|
+
* @param headers - Headers from the current event.
|
|
347
|
+
* @param serial - Ably serial from the current event.
|
|
348
|
+
*/
|
|
349
|
+
private _updateTurnObserverHeaders(
|
|
350
|
+
turnId: string,
|
|
351
|
+
headers: Record<string, string>,
|
|
352
|
+
serial: string | undefined,
|
|
353
|
+
): void {
|
|
354
|
+
const existing = this._turnObservers.get(turnId);
|
|
355
|
+
if (existing) {
|
|
356
|
+
if (Object.keys(headers).length > 0) {
|
|
357
|
+
Object.assign(existing.headers, headers);
|
|
358
|
+
}
|
|
359
|
+
// Always advance the serial so the tree node sorts after all
|
|
360
|
+
// earlier messages in the turn (e.g. user-message relays that
|
|
361
|
+
// arrive before the assistant response).
|
|
362
|
+
if (serial !== undefined) {
|
|
363
|
+
existing.serial = serial;
|
|
364
|
+
}
|
|
365
|
+
} else {
|
|
366
|
+
this._turnObservers.set(turnId, {
|
|
367
|
+
headers: { ...headers },
|
|
368
|
+
serial,
|
|
369
|
+
accumulator: this._codec.createAccumulator(),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Process a streaming event through the turn's accumulator and emit the latest message.
|
|
376
|
+
* @param turnId - The turn this event belongs to.
|
|
377
|
+
* @param output - The decoded event output to accumulate.
|
|
378
|
+
*/
|
|
379
|
+
private _accumulateAndEmit(turnId: string, output: DecoderOutput<TEvent, TMessage>): void {
|
|
380
|
+
const observer = this._turnObservers.get(turnId);
|
|
381
|
+
if (!observer) return;
|
|
382
|
+
|
|
383
|
+
observer.accumulator.processOutputs([output]);
|
|
384
|
+
|
|
385
|
+
const messages = observer.accumulator.messages;
|
|
386
|
+
if (messages.length === 0) return;
|
|
387
|
+
|
|
388
|
+
let message: TMessage | undefined;
|
|
389
|
+
try {
|
|
390
|
+
message = structuredClone(messages.at(-1));
|
|
391
|
+
} catch {
|
|
392
|
+
// CAST: structuredClone can fail if the message contains non-cloneable
|
|
393
|
+
// values (e.g. functions). Fall back to the reference — the tree upsert
|
|
394
|
+
// below copies headers independently, so shared message state is the
|
|
395
|
+
// only risk. Accumulator messages are replaced on each event, so
|
|
396
|
+
// mutation between events is not a practical concern.
|
|
397
|
+
message = messages.at(-1);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (message) {
|
|
401
|
+
this._tree.upsert(
|
|
402
|
+
observer.headers[HEADER_MSG_ID] ?? this._codec.getMessageKey(message),
|
|
403
|
+
message,
|
|
404
|
+
{ ...observer.headers },
|
|
405
|
+
observer.serial,
|
|
406
|
+
);
|
|
407
|
+
this._emitter.emit('message');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Cancel helpers
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
private async _publishCancel(filter: CancelFilter): Promise<void> {
|
|
416
|
+
this._logger.trace('ClientTransport._publishCancel();', { filter });
|
|
417
|
+
|
|
418
|
+
const headers: Record<string, string> = {};
|
|
419
|
+
if (filter.turnId) {
|
|
420
|
+
headers[HEADER_CANCEL_TURN_ID] = filter.turnId;
|
|
421
|
+
} else if (filter.own) {
|
|
422
|
+
headers[HEADER_CANCEL_OWN] = 'true';
|
|
423
|
+
} else if (filter.clientId) {
|
|
424
|
+
headers[HEADER_CANCEL_CLIENT_ID] = filter.clientId;
|
|
425
|
+
} else if (filter.all) {
|
|
426
|
+
headers[HEADER_CANCEL_ALL] = 'true';
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
await this._channel.publish({
|
|
430
|
+
name: EVENT_CANCEL,
|
|
431
|
+
extras: { headers },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private _closeMatchingTurnStreams(filter: CancelFilter): void {
|
|
436
|
+
// Only close the router streams here — do NOT clear _turnObservers.
|
|
437
|
+
// The observer must remain alive so that late server events (e.g. abort,
|
|
438
|
+
// x-ably-status: aborted) arriving before turn-end are still accumulated
|
|
439
|
+
// into the message store. The turn-end handler cleans up observers.
|
|
440
|
+
if (filter.all) {
|
|
441
|
+
for (const turnId of this._ownTurnIds) {
|
|
442
|
+
this._router.closeStream(turnId);
|
|
443
|
+
}
|
|
444
|
+
} else if (filter.own) {
|
|
445
|
+
for (const tid of this._ownTurnIds) {
|
|
446
|
+
this._router.closeStream(tid);
|
|
447
|
+
}
|
|
448
|
+
} else if (filter.clientId) {
|
|
449
|
+
for (const [tid, cid] of this._turnClientIds) {
|
|
450
|
+
if (cid === filter.clientId) {
|
|
451
|
+
this._router.closeStream(tid);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
} else if (filter.turnId) {
|
|
455
|
+
this._router.closeStream(filter.turnId);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private _getMatchingTurnIds(filter: CancelFilter): Set<string> {
|
|
460
|
+
const matched = new Set<string>();
|
|
461
|
+
if (filter.all) {
|
|
462
|
+
for (const turnId of this._turnClientIds.keys()) matched.add(turnId);
|
|
463
|
+
} else if (filter.own) {
|
|
464
|
+
for (const [turnId, cid] of this._turnClientIds) {
|
|
465
|
+
if (cid === this._clientId) matched.add(turnId);
|
|
466
|
+
}
|
|
467
|
+
} else if (filter.clientId) {
|
|
468
|
+
for (const [turnId, cid] of this._turnClientIds) {
|
|
469
|
+
if (cid === filter.clientId) matched.add(turnId);
|
|
470
|
+
}
|
|
471
|
+
} else if (filter.turnId && this._turnClientIds.has(filter.turnId)) {
|
|
472
|
+
matched.add(filter.turnId);
|
|
473
|
+
}
|
|
474
|
+
return matched;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
// Input message helpers
|
|
479
|
+
// ---------------------------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
private _getMessagesWithHeaders(): MessageWithHeaders<TMessage>[] {
|
|
482
|
+
return this._tree.flatten().map((m) => ({
|
|
483
|
+
message: m,
|
|
484
|
+
headers: this.getMessageHeaders(m),
|
|
485
|
+
}));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Compute truncated history: everything before the target message.
|
|
490
|
+
* Used by regenerate so the LLM doesn't see the response being replaced.
|
|
491
|
+
* @param messageId - The msg-id to truncate history before.
|
|
492
|
+
* @returns Input messages preceding the target.
|
|
493
|
+
*/
|
|
494
|
+
private _getHistoryBefore(messageId: string): MessageWithHeaders<TMessage>[] {
|
|
495
|
+
const all = this._getMessagesWithHeaders();
|
|
496
|
+
const idx = all.findIndex((inp) => inp.headers?.[HEADER_MSG_ID] === messageId);
|
|
497
|
+
return idx === -1 ? all : all.slice(0, idx);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// History pagination helpers
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
private _processHistoryPage(page: PaginatedMessages<TMessage>): void {
|
|
505
|
+
for (const [i, message] of page.items.entries()) {
|
|
506
|
+
const headers = page.itemHeaders?.[i] ?? {};
|
|
507
|
+
const serial = page.itemSerials?.[i];
|
|
508
|
+
const key = this._codec.getMessageKey(message);
|
|
509
|
+
const msgId = headers[HEADER_MSG_ID] ?? key;
|
|
510
|
+
this._tree.upsert(msgId, message, headers, serial);
|
|
511
|
+
}
|
|
512
|
+
this._emitter.emit('message');
|
|
513
|
+
|
|
514
|
+
// Prepend raw Ably messages (older messages go at the beginning)
|
|
515
|
+
if (page.rawMessages && page.rawMessages.length > 0) {
|
|
516
|
+
this._ablyMessages.unshift(...page.rawMessages);
|
|
517
|
+
this._emitter.emit('ably-message');
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async _loadUntilVisible(
|
|
522
|
+
firstPage: PaginatedMessages<TMessage>,
|
|
523
|
+
target: number,
|
|
524
|
+
beforeKeys: Set<string>,
|
|
525
|
+
): Promise<{ newVisible: TMessage[]; lastPage: PaginatedMessages<TMessage> }> {
|
|
526
|
+
this._processHistoryPage(firstPage);
|
|
527
|
+
let page = firstPage;
|
|
528
|
+
|
|
529
|
+
const newVisibleCount = (): number => {
|
|
530
|
+
let count = 0;
|
|
531
|
+
for (const m of this._tree.flatten()) {
|
|
532
|
+
if (!beforeKeys.has(this._codec.getMessageKey(m))) count++;
|
|
533
|
+
}
|
|
534
|
+
return count;
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
while (newVisibleCount() < target && page.hasNext()) {
|
|
538
|
+
const nextPage = await page.next();
|
|
539
|
+
if (!nextPage) break;
|
|
540
|
+
this._processHistoryPage(nextPage);
|
|
541
|
+
page = nextPage;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const newVisible = this._tree.flatten().filter((m) => !beforeKeys.has(this._codec.getMessageKey(m)));
|
|
545
|
+
return { newVisible, lastPage: page };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private _releaseWithheld(messages: TMessage[]): void {
|
|
549
|
+
for (const m of messages) {
|
|
550
|
+
this._withheldKeys.delete(this._codec.getMessageKey(m));
|
|
551
|
+
}
|
|
552
|
+
if (messages.length > 0) {
|
|
553
|
+
this._emitter.emit('message');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---------------------------------------------------------------------------
|
|
558
|
+
// Public API
|
|
559
|
+
// ---------------------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
// Spec: AIT-CT3, AIT-CT4
|
|
562
|
+
async send(input: TMessage | TMessage[], sendOptions?: SendOptions): Promise<ActiveTurn<TEvent>> {
|
|
563
|
+
if (this._closed) {
|
|
564
|
+
throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
|
|
565
|
+
}
|
|
566
|
+
await this._attachPromise;
|
|
567
|
+
// CAST: re-check after await — close() may have been called while waiting for attach.
|
|
568
|
+
// TypeScript's control flow narrows _closed to false after the first check, but the
|
|
569
|
+
// await yields and close() can mutate _closed concurrently.
|
|
570
|
+
if (this._closed as boolean) {
|
|
571
|
+
throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
this._logger.trace('ClientTransport.send();');
|
|
575
|
+
|
|
576
|
+
const msgs = Array.isArray(input) ? input : [input];
|
|
577
|
+
const turnId = crypto.randomUUID();
|
|
578
|
+
this._ownTurnIds.add(turnId);
|
|
579
|
+
|
|
580
|
+
const msgIds = new Set<string>();
|
|
581
|
+
const postMessages: { message: TMessage; headers: Record<string, string> }[] = [];
|
|
582
|
+
|
|
583
|
+
// Capture history BEFORE optimistic inserts. The optimistic messages are
|
|
584
|
+
// sent in the `messages` field — including them in `history` too would
|
|
585
|
+
// cause the server to see them twice.
|
|
586
|
+
const preInsertHistory = this._getMessagesWithHeaders();
|
|
587
|
+
|
|
588
|
+
// Spec: AIT-CT3d
|
|
589
|
+
// Auto-compute parent from the current thread if not explicitly provided
|
|
590
|
+
let autoParent: string | undefined;
|
|
591
|
+
if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
|
|
592
|
+
const flat = this._tree.flatten();
|
|
593
|
+
if (flat.length > 0) {
|
|
594
|
+
const lastMsg = flat.at(-1);
|
|
595
|
+
if (lastMsg) {
|
|
596
|
+
const lastKey = this._codec.getMessageKey(lastMsg);
|
|
597
|
+
const lastNode = this._tree.getNodeByKey(lastKey);
|
|
598
|
+
autoParent = lastNode?.msgId ?? lastKey;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Capture the first parent for the POST body before the loop advances it.
|
|
604
|
+
const postParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
|
|
605
|
+
|
|
606
|
+
for (const message of msgs) {
|
|
607
|
+
const msgId = crypto.randomUUID();
|
|
608
|
+
this._ownMsgIds.add(msgId);
|
|
609
|
+
msgIds.add(msgId);
|
|
610
|
+
|
|
611
|
+
const resolvedParent = sendOptions?.parent === undefined ? autoParent : (sendOptions.parent ?? undefined);
|
|
612
|
+
|
|
613
|
+
const optimisticHeaders = buildTransportHeaders({
|
|
614
|
+
role: 'user',
|
|
615
|
+
turnId,
|
|
616
|
+
msgId,
|
|
617
|
+
turnClientId: this._clientId,
|
|
618
|
+
parent: resolvedParent,
|
|
619
|
+
forkOf: sendOptions?.forkOf,
|
|
620
|
+
});
|
|
621
|
+
// Spec: AIT-CT3c
|
|
622
|
+
// Optimistically insert each user message into the tree
|
|
623
|
+
this._upsertAndNotify(message, optimisticHeaders);
|
|
624
|
+
|
|
625
|
+
// Include per-message parent so the server chains messages correctly.
|
|
626
|
+
const postHeaders: Record<string, string> = { [HEADER_MSG_ID]: msgId, [HEADER_ROLE]: 'user' };
|
|
627
|
+
if (resolvedParent) postHeaders[HEADER_PARENT] = resolvedParent;
|
|
628
|
+
postMessages.push({ message, headers: postHeaders });
|
|
629
|
+
|
|
630
|
+
// Spec: AIT-CT3e
|
|
631
|
+
// Chain: each subsequent message in the batch parents off the previous
|
|
632
|
+
// one, forming a linear conversation thread rather than siblings.
|
|
633
|
+
if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
|
|
634
|
+
autoParent = msgId;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
this._turnMsgIds.set(turnId, msgIds);
|
|
639
|
+
|
|
640
|
+
// Create ReadableStream via router
|
|
641
|
+
const stream = this._router.createStream(turnId);
|
|
642
|
+
|
|
643
|
+
// Resolve headers and body
|
|
644
|
+
const resolvedHeaders = this._headersFn?.() ?? {};
|
|
645
|
+
const resolvedBody = this._bodyFn?.() ?? {};
|
|
646
|
+
|
|
647
|
+
const postBody: Record<string, unknown> = {
|
|
648
|
+
...resolvedBody,
|
|
649
|
+
history: preInsertHistory,
|
|
650
|
+
...sendOptions?.body,
|
|
651
|
+
turnId,
|
|
652
|
+
clientId: this._clientId,
|
|
653
|
+
messages: postMessages,
|
|
654
|
+
...(sendOptions?.forkOf !== undefined && { forkOf: sendOptions.forkOf }),
|
|
655
|
+
...(postParent !== undefined && { parent: postParent }),
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const postHeaders: Record<string, string> = {
|
|
659
|
+
...resolvedHeaders,
|
|
660
|
+
...sendOptions?.headers,
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
// Spec: AIT-CT3a, AIT-CT3b
|
|
664
|
+
// Fire-and-forget: POST must not block the stream return to the caller.
|
|
665
|
+
// .catch() is intentional — async/await would delay stream availability.
|
|
666
|
+
this._fetchFn(this._api, {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: {
|
|
669
|
+
'Content-Type': 'application/json',
|
|
670
|
+
...postHeaders,
|
|
671
|
+
},
|
|
672
|
+
body: JSON.stringify(postBody),
|
|
673
|
+
...(this._credentials ? { credentials: this._credentials } : {}),
|
|
674
|
+
})
|
|
675
|
+
.then((response) => {
|
|
676
|
+
if (!response.ok) {
|
|
677
|
+
this._emitter.emit(
|
|
678
|
+
'error',
|
|
679
|
+
new Ably.ErrorInfo(
|
|
680
|
+
`unable to send; HTTP POST to ${this._api} returned ${String(response.status)} ${response.statusText}`,
|
|
681
|
+
ErrorCode.TransportSendFailed,
|
|
682
|
+
response.status,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
this._router.closeStream(turnId);
|
|
686
|
+
}
|
|
687
|
+
})
|
|
688
|
+
.catch((error: unknown) => {
|
|
689
|
+
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
690
|
+
this._emitter.emit(
|
|
691
|
+
'error',
|
|
692
|
+
new Ably.ErrorInfo(
|
|
693
|
+
`unable to send; HTTP POST to ${this._api} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
694
|
+
ErrorCode.TransportSendFailed,
|
|
695
|
+
500,
|
|
696
|
+
cause,
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
this._router.closeStream(turnId);
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
return {
|
|
703
|
+
stream,
|
|
704
|
+
turnId,
|
|
705
|
+
cancel: async () => this.cancel({ turnId }),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Spec: AIT-CT5
|
|
710
|
+
async regenerate(messageId: string, sendOptions?: SendOptions): Promise<ActiveTurn<TEvent>> {
|
|
711
|
+
this._logger.trace('ClientTransport.regenerate();', { messageId });
|
|
712
|
+
|
|
713
|
+
const node = this._tree.getNode(messageId);
|
|
714
|
+
const parentId = node?.parentId;
|
|
715
|
+
|
|
716
|
+
return this.send([], {
|
|
717
|
+
...sendOptions,
|
|
718
|
+
body: {
|
|
719
|
+
history: this._getHistoryBefore(messageId),
|
|
720
|
+
...sendOptions?.body,
|
|
721
|
+
},
|
|
722
|
+
forkOf: messageId,
|
|
723
|
+
parent: parentId,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Spec: AIT-CT6
|
|
728
|
+
async edit(
|
|
729
|
+
messageId: string,
|
|
730
|
+
newMessages: TMessage | TMessage[],
|
|
731
|
+
sendOptions?: SendOptions,
|
|
732
|
+
): Promise<ActiveTurn<TEvent>> {
|
|
733
|
+
this._logger.trace('ClientTransport.edit();', { messageId });
|
|
734
|
+
|
|
735
|
+
const node = this._tree.getNode(messageId);
|
|
736
|
+
const parentId = node?.parentId;
|
|
737
|
+
|
|
738
|
+
return this.send(newMessages, {
|
|
739
|
+
...sendOptions,
|
|
740
|
+
body: {
|
|
741
|
+
history: this._getHistoryBefore(messageId),
|
|
742
|
+
...sendOptions?.body,
|
|
743
|
+
},
|
|
744
|
+
forkOf: messageId,
|
|
745
|
+
parent: parentId,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// Spec: AIT-CT7, AIT-CT7a
|
|
750
|
+
async cancel(filter?: CancelFilter): Promise<void> {
|
|
751
|
+
if (this._closed) return;
|
|
752
|
+
const resolved = filter ?? { own: true };
|
|
753
|
+
this._logger.debug('ClientTransport.cancel();', { filter: resolved });
|
|
754
|
+
await this._publishCancel(resolved);
|
|
755
|
+
this._closeMatchingTurnStreams(resolved);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Spec: AIT-CT18
|
|
759
|
+
async waitForTurn(filter?: CancelFilter): Promise<void> {
|
|
760
|
+
if (this._closed) return;
|
|
761
|
+
const resolved = filter ?? { own: true };
|
|
762
|
+
const remaining = this._getMatchingTurnIds(resolved);
|
|
763
|
+
if (remaining.size === 0) return;
|
|
764
|
+
|
|
765
|
+
this._logger.debug('ClientTransport.waitForTurn();', { turnIds: [...remaining] });
|
|
766
|
+
|
|
767
|
+
return new Promise<void>((resolve) => {
|
|
768
|
+
const handler = (event: TurnLifecycleEvent): void => {
|
|
769
|
+
if (event.type !== EVENT_TURN_END) return;
|
|
770
|
+
remaining.delete(event.turnId);
|
|
771
|
+
if (remaining.size === 0) {
|
|
772
|
+
this._emitter.off('turn', handler);
|
|
773
|
+
resolve();
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
this._emitter.on('turn', handler);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Spec: AIT-CT8, AIT-CT8a, AIT-CT8b, AIT-CT8c, AIT-CT8d
|
|
781
|
+
on(event: 'message' | 'ably-message', handler: () => void): () => void;
|
|
782
|
+
on(event: 'turn', handler: (event: TurnLifecycleEvent) => void): () => void;
|
|
783
|
+
on(event: 'error', handler: (error: Ably.ErrorInfo) => void): () => void;
|
|
784
|
+
on(
|
|
785
|
+
eventName: 'message' | 'turn' | 'error' | 'ably-message',
|
|
786
|
+
handler: (() => void) | ((event: TurnLifecycleEvent) => void) | ((error: Ably.ErrorInfo) => void),
|
|
787
|
+
): () => void {
|
|
788
|
+
if (this._closed) return noopUnsubscribe;
|
|
789
|
+
// CAST: the overload signatures enforce correct handler types per event name.
|
|
790
|
+
// The implementation must cast to satisfy the EventEmitter's generic callback type.
|
|
791
|
+
const cb = handler as (arg: ClientTransportEventsMap[keyof ClientTransportEventsMap]) => void;
|
|
792
|
+
this._emitter.on(eventName, cb);
|
|
793
|
+
return () => {
|
|
794
|
+
this._emitter.off(eventName, cb);
|
|
795
|
+
};
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Spec: AIT-CT10
|
|
799
|
+
getTree(): ConversationTree<TMessage> {
|
|
800
|
+
return this._tree;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Spec: AIT-CT17
|
|
804
|
+
getActiveTurnIds(): Map<string, Set<string>> {
|
|
805
|
+
const result = new Map<string, Set<string>>();
|
|
806
|
+
for (const [turnId, cid] of this._turnClientIds) {
|
|
807
|
+
let set = result.get(cid);
|
|
808
|
+
if (!set) {
|
|
809
|
+
set = new Set();
|
|
810
|
+
result.set(cid, set);
|
|
811
|
+
}
|
|
812
|
+
set.add(turnId);
|
|
813
|
+
}
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
getMessageHeaders(message: TMessage): Record<string, string> | undefined {
|
|
818
|
+
const key = this._codec.getMessageKey(message);
|
|
819
|
+
return this._tree.getNodeByKey(key)?.headers;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Spec: AIT-CT9
|
|
823
|
+
getMessages(): TMessage[] {
|
|
824
|
+
if (this._withheldKeys.size === 0) return this._tree.flatten();
|
|
825
|
+
return this._tree.flatten().filter((m) => !this._withheldKeys.has(this._codec.getMessageKey(m)));
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
getMessagesWithHeaders(): MessageWithHeaders<TMessage>[] {
|
|
829
|
+
return this._getMessagesWithHeaders();
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
getAblyMessages(): Ably.InboundMessage[] {
|
|
833
|
+
return [...this._ablyMessages];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Spec: AIT-CT11, AIT-CT11a, AIT-CT11b, AIT-CT11c
|
|
837
|
+
async history(opts?: LoadHistoryOptions): Promise<PaginatedMessages<TMessage>> {
|
|
838
|
+
if (this._closed) {
|
|
839
|
+
throw new Ably.ErrorInfo('unable to load history; transport is closed', ErrorCode.TransportClosed, 400);
|
|
840
|
+
}
|
|
841
|
+
this._logger.trace('ClientTransport.history();', { limit: opts?.limit });
|
|
842
|
+
const limit = opts?.limit ?? 100;
|
|
843
|
+
|
|
844
|
+
// Snapshot before loading — everything already in the tree stays visible
|
|
845
|
+
const beforeKeys = new Set(this._tree.flatten().map((m) => this._codec.getMessageKey(m)));
|
|
846
|
+
|
|
847
|
+
let lastPage = await decodeHistory(this._channel, this._codec, opts, this._logger);
|
|
848
|
+
|
|
849
|
+
const initial = await this._loadUntilVisible(lastPage, limit, beforeKeys);
|
|
850
|
+
lastPage = initial.lastPage;
|
|
851
|
+
|
|
852
|
+
// newVisible is chronological (oldest-first from flatten).
|
|
853
|
+
// For "load older" pagination: release the NEWEST `limit` now,
|
|
854
|
+
// withhold the older ones for subsequent next() calls.
|
|
855
|
+
const newVisible = initial.newVisible;
|
|
856
|
+
|
|
857
|
+
// Withhold ALL new visible messages first, then release the newest batch
|
|
858
|
+
for (const m of newVisible) {
|
|
859
|
+
this._withheldKeys.add(this._codec.getMessageKey(m));
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const released = newVisible.slice(-limit);
|
|
863
|
+
// Mutable buffer of older messages, drained newest-first by successive next() calls
|
|
864
|
+
const withheldBuffer = newVisible.slice(0, -limit);
|
|
865
|
+
this._releaseWithheld(released);
|
|
866
|
+
|
|
867
|
+
const buildPage = (items: TMessage[]): PaginatedMessages<TMessage> => ({
|
|
868
|
+
items,
|
|
869
|
+
hasNext: () => withheldBuffer.length > 0 || lastPage.hasNext(),
|
|
870
|
+
next: async () => {
|
|
871
|
+
// Drain withheld buffer first (older messages, released newest-first)
|
|
872
|
+
if (withheldBuffer.length > 0) {
|
|
873
|
+
// Remove and return the newest `limit` items from the buffer
|
|
874
|
+
const batch = withheldBuffer.splice(-limit, limit);
|
|
875
|
+
this._releaseWithheld(batch);
|
|
876
|
+
return buildPage(batch);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Buffer exhausted — load more pages from decodeHistory
|
|
880
|
+
if (!lastPage.hasNext()) return;
|
|
881
|
+
|
|
882
|
+
const nextInternal = await lastPage.next();
|
|
883
|
+
if (!nextInternal) return;
|
|
884
|
+
|
|
885
|
+
// Everything currently in the tree is "already known"
|
|
886
|
+
const alreadyKnown = new Set(beforeKeys);
|
|
887
|
+
for (const m of this._tree.flatten()) {
|
|
888
|
+
alreadyKnown.add(this._codec.getMessageKey(m));
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const loaded = await this._loadUntilVisible(nextInternal, limit, alreadyKnown);
|
|
892
|
+
lastPage = loaded.lastPage;
|
|
893
|
+
|
|
894
|
+
const moreVisible = loaded.newVisible;
|
|
895
|
+
for (const m of moreVisible) {
|
|
896
|
+
this._withheldKeys.add(this._codec.getMessageKey(m));
|
|
897
|
+
}
|
|
898
|
+
// Remove and return the newest `limit` items; rest stays in buffer
|
|
899
|
+
const moreBatch = moreVisible.splice(-limit, limit);
|
|
900
|
+
withheldBuffer.push(...moreVisible);
|
|
901
|
+
this._releaseWithheld(moreBatch);
|
|
902
|
+
|
|
903
|
+
if (moreBatch.length === 0) return;
|
|
904
|
+
return buildPage(moreBatch);
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
return buildPage(released);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Spec: AIT-CT12, AIT-CT12a, AIT-CT12b
|
|
912
|
+
async close(options?: CloseOptions): Promise<void> {
|
|
913
|
+
if (this._closed) return;
|
|
914
|
+
this._closed = true;
|
|
915
|
+
this._logger.info('ClientTransport.close();');
|
|
916
|
+
|
|
917
|
+
// Best-effort cancel publish before tearing down local state
|
|
918
|
+
if (options?.cancel) {
|
|
919
|
+
try {
|
|
920
|
+
await this._publishCancel(options.cancel);
|
|
921
|
+
} catch {
|
|
922
|
+
// Swallow: cancel is best-effort during teardown
|
|
923
|
+
}
|
|
924
|
+
this._closeMatchingTurnStreams(options.cancel);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
this._channel.unsubscribe(this._onMessage);
|
|
928
|
+
|
|
929
|
+
// Close any remaining active streams
|
|
930
|
+
for (const turnId of this._ownTurnIds) {
|
|
931
|
+
this._router.closeStream(turnId);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
this._turnObservers.clear();
|
|
935
|
+
this._emitter.off();
|
|
936
|
+
this._ownTurnIds.clear();
|
|
937
|
+
this._ownMsgIds.clear();
|
|
938
|
+
this._turnMsgIds.clear();
|
|
939
|
+
this._turnClientIds.clear();
|
|
940
|
+
this._withheldKeys.clear();
|
|
941
|
+
this._ablyMessages.length = 0;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
// Factory
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Create a client-side transport that manages conversation state over an Ably channel.
|
|
951
|
+
*
|
|
952
|
+
* Subscribes to the channel immediately (before attach per RTL7g). The caller should
|
|
953
|
+
* ensure the channel is attached or will be attached shortly after creation.
|
|
954
|
+
* @param options - Configuration for the client transport.
|
|
955
|
+
* @returns A new {@link ClientTransport} instance.
|
|
956
|
+
*/
|
|
957
|
+
export const createClientTransport = <TEvent, TMessage>(
|
|
958
|
+
options: ClientTransportOptions<TEvent, TMessage>,
|
|
959
|
+
): ClientTransport<TEvent, TMessage> => new DefaultClientTransport(options);
|