@ably/ai-transport 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -47
- package/dist/ably-ai-transport.js +1006 -539
- package/dist/ably-ai-transport.js.map +1 -1
- package/dist/ably-ai-transport.umd.cjs +1 -1
- package/dist/ably-ai-transport.umd.cjs.map +1 -1
- package/dist/constants.d.ts +4 -0
- package/dist/core/codec/types.d.ts +19 -2
- package/dist/core/transport/decode-history.d.ts +8 -6
- package/dist/core/transport/headers.d.ts +4 -2
- package/dist/core/transport/index.d.ts +4 -1
- package/dist/core/transport/pipe-stream.d.ts +3 -2
- package/dist/core/transport/stream-router.d.ts +11 -1
- package/dist/core/transport/tree.d.ts +171 -0
- package/dist/core/transport/turn-manager.d.ts +4 -1
- package/dist/core/transport/types.d.ts +270 -119
- package/dist/core/transport/view.d.ts +166 -0
- package/dist/errors.d.ts +19 -2
- package/dist/index.d.ts +3 -1
- package/dist/react/ably-ai-transport-react.js +1019 -486
- package/dist/react/ably-ai-transport-react.js.map +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs +1 -1
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -1
- package/dist/react/contexts/transport-context.d.ts +31 -0
- package/dist/react/contexts/transport-provider.d.ts +49 -0
- package/dist/react/create-transport-hooks.d.ts +124 -0
- package/dist/react/index.d.ts +14 -8
- package/dist/react/use-ably-messages.d.ts +14 -8
- package/dist/react/use-active-turns.d.ts +7 -3
- package/dist/react/use-client-transport.d.ts +78 -5
- package/dist/react/use-create-view.d.ts +22 -0
- package/dist/react/use-tree.d.ts +20 -0
- package/dist/react/use-view.d.ts +79 -0
- package/dist/vercel/ably-ai-transport-vercel.js +1478 -842
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +1 -1
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -1
- package/dist/vercel/codec/tool-transitions.d.ts +50 -0
- package/dist/vercel/index.d.ts +3 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +9099 -852
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +45 -1
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -1
- package/dist/vercel/react/contexts/chat-transport-context.d.ts +32 -0
- package/dist/vercel/react/contexts/chat-transport-provider.d.ts +84 -0
- package/dist/vercel/react/index.d.ts +5 -0
- package/dist/vercel/react/use-chat-transport.d.ts +61 -20
- package/dist/vercel/react/use-message-sync.d.ts +41 -9
- package/dist/vercel/react/use-staged-add-tool-approval-response.d.ts +30 -0
- package/dist/vercel/tool-approvals.d.ts +124 -0
- package/dist/vercel/tool-events.d.ts +26 -0
- package/dist/vercel/transport/chat-transport.d.ts +33 -11
- package/dist/vercel/transport/index.d.ts +5 -2
- package/package.json +23 -17
- package/src/constants.ts +6 -0
- package/src/core/codec/encoder.ts +10 -1
- package/src/core/codec/types.ts +19 -3
- package/src/core/transport/client-transport.ts +382 -364
- package/src/core/transport/decode-history.ts +229 -81
- package/src/core/transport/headers.ts +6 -2
- package/src/core/transport/index.ts +13 -5
- package/src/core/transport/pipe-stream.ts +8 -5
- package/src/core/transport/server-transport.ts +212 -58
- package/src/core/transport/stream-router.ts +21 -3
- package/src/core/transport/{conversation-tree.ts → tree.ts} +192 -77
- package/src/core/transport/turn-manager.ts +28 -10
- package/src/core/transport/types.ts +318 -139
- package/src/core/transport/view.ts +840 -0
- package/src/errors.ts +21 -1
- package/src/index.ts +10 -5
- package/src/react/contexts/transport-context.ts +37 -0
- package/src/react/contexts/transport-provider.tsx +164 -0
- package/src/react/create-transport-hooks.ts +144 -0
- package/src/react/index.ts +15 -8
- package/src/react/use-ably-messages.ts +34 -16
- package/src/react/use-active-turns.ts +28 -17
- package/src/react/use-client-transport.ts +184 -24
- package/src/react/use-create-view.ts +68 -0
- package/src/react/use-tree.ts +53 -0
- package/src/react/use-view.ts +233 -0
- package/src/react/vite.config.ts +4 -1
- package/src/vercel/codec/accumulator.ts +64 -79
- package/src/vercel/codec/decoder.ts +11 -8
- package/src/vercel/codec/encoder.ts +68 -54
- package/src/vercel/codec/index.ts +0 -2
- package/src/vercel/codec/tool-transitions.ts +122 -0
- package/src/vercel/index.ts +17 -0
- package/src/vercel/react/contexts/chat-transport-context.ts +40 -0
- package/src/vercel/react/contexts/chat-transport-provider.tsx +122 -0
- package/src/vercel/react/index.ts +14 -0
- package/src/vercel/react/use-chat-transport.ts +164 -42
- package/src/vercel/react/use-message-sync.ts +77 -19
- package/src/vercel/react/use-staged-add-tool-approval-response.ts +87 -0
- package/src/vercel/react/vite.config.ts +4 -2
- package/src/vercel/tool-approvals.ts +380 -0
- package/src/vercel/tool-events.ts +53 -0
- package/src/vercel/transport/chat-transport.ts +225 -79
- package/src/vercel/transport/index.ts +14 -3
- package/dist/core/transport/conversation-tree.d.ts +0 -9
- package/dist/react/use-conversation-tree.d.ts +0 -20
- package/dist/react/use-edit.d.ts +0 -7
- package/dist/react/use-history.d.ts +0 -19
- package/dist/react/use-messages.d.ts +0 -7
- package/dist/react/use-regenerate.d.ts +0 -7
- package/dist/react/use-send.d.ts +0 -7
- package/src/react/use-conversation-tree.ts +0 -71
- package/src/react/use-edit.ts +0 -24
- package/src/react/use-history.ts +0 -111
- package/src/react/use-messages.ts +0 -32
- package/src/react/use-regenerate.ts +0 -24
- package/src/react/use-send.ts +0 -25
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Core client-side transport, parameterized by codec.
|
|
3
3
|
*
|
|
4
|
-
* Composes StreamRouter and
|
|
4
|
+
* Composes StreamRouter and Tree to handle the full client-side
|
|
5
5
|
* lifecycle. Subscribes to the Ably channel on construction. The same
|
|
6
6
|
* subscription, decoder, and channel are reused across turns.
|
|
7
7
|
*
|
|
@@ -16,13 +16,14 @@ import {
|
|
|
16
16
|
EVENT_CANCEL,
|
|
17
17
|
EVENT_TURN_END,
|
|
18
18
|
EVENT_TURN_START,
|
|
19
|
+
HEADER_AMEND,
|
|
19
20
|
HEADER_CANCEL_ALL,
|
|
20
21
|
HEADER_CANCEL_CLIENT_ID,
|
|
21
22
|
HEADER_CANCEL_OWN,
|
|
22
23
|
HEADER_CANCEL_TURN_ID,
|
|
24
|
+
HEADER_FORK_OF,
|
|
23
25
|
HEADER_MSG_ID,
|
|
24
26
|
HEADER_PARENT,
|
|
25
|
-
HEADER_ROLE,
|
|
26
27
|
HEADER_TURN_CLIENT_ID,
|
|
27
28
|
HEADER_TURN_ID,
|
|
28
29
|
HEADER_TURN_REASON,
|
|
@@ -33,25 +34,26 @@ import type { Logger } from '../../logger.js';
|
|
|
33
34
|
import { LogLevel, makeLogger } from '../../logger.js';
|
|
34
35
|
import { getHeaders } from '../../utils.js';
|
|
35
36
|
import type { DecoderOutput, MessageAccumulator, StreamDecoder } from '../codec/types.js';
|
|
36
|
-
import { createConversationTree } from './conversation-tree.js';
|
|
37
|
-
import { decodeHistory } from './decode-history.js';
|
|
38
37
|
import { buildTransportHeaders } from './headers.js';
|
|
39
38
|
import type { StreamRouter } from './stream-router.js';
|
|
40
39
|
import { createStreamRouter } from './stream-router.js';
|
|
40
|
+
import type { DefaultTree } from './tree.js';
|
|
41
|
+
import { createTree } from './tree.js';
|
|
41
42
|
import type {
|
|
42
43
|
ActiveTurn,
|
|
43
44
|
CancelFilter,
|
|
44
45
|
ClientTransport,
|
|
45
46
|
ClientTransportOptions,
|
|
46
47
|
CloseOptions,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
MessageWithHeaders,
|
|
50
|
-
PaginatedMessages,
|
|
48
|
+
EventsNode,
|
|
49
|
+
MessageNode,
|
|
51
50
|
SendOptions,
|
|
51
|
+
Tree,
|
|
52
52
|
TurnEndReason,
|
|
53
53
|
TurnLifecycleEvent,
|
|
54
|
+
View,
|
|
54
55
|
} from './types.js';
|
|
56
|
+
import { createView, type DefaultView } from './view.js';
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
59
|
* Returned from `on()` when the transport is already closed — the subscription
|
|
@@ -60,15 +62,21 @@ import type {
|
|
|
60
62
|
// eslint-disable-next-line @typescript-eslint/no-empty-function -- intentional no-op
|
|
61
63
|
const noopUnsubscribe = (): void => {};
|
|
62
64
|
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Internal state machine
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
enum ClientTransportState {
|
|
70
|
+
READY = 'ready',
|
|
71
|
+
CLOSED = 'closed',
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
// ---------------------------------------------------------------------------
|
|
64
75
|
// Event map for the transport's typed EventEmitter
|
|
65
76
|
// ---------------------------------------------------------------------------
|
|
66
77
|
|
|
67
78
|
interface ClientTransportEventsMap {
|
|
68
|
-
message: undefined;
|
|
69
|
-
turn: TurnLifecycleEvent;
|
|
70
79
|
error: Ably.ErrorInfo;
|
|
71
|
-
'ably-message': undefined;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
// ---------------------------------------------------------------------------
|
|
@@ -97,15 +105,13 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
97
105
|
private readonly _fetchFn: typeof globalThis.fetch;
|
|
98
106
|
private readonly _logger: Logger;
|
|
99
107
|
|
|
100
|
-
// Typed event emitter
|
|
108
|
+
// Typed event emitter — only 'error' remains on the transport
|
|
101
109
|
private readonly _emitter: EventEmitter<ClientTransportEventsMap>;
|
|
102
110
|
|
|
103
111
|
// Relay detection — tracks msg-ids of optimistic inserts for reconciliation
|
|
104
112
|
private readonly _ownMsgIds = new Set<string>();
|
|
105
113
|
private readonly _ownTurnIds = new Set<string>();
|
|
106
114
|
|
|
107
|
-
// Track clientId per turn for getActiveTurnIds()
|
|
108
|
-
private readonly _turnClientIds = new Map<string, string>();
|
|
109
115
|
// Track msgIds per turn for cleanup on turn-end
|
|
110
116
|
private readonly _turnMsgIds = new Map<string, Set<string>>();
|
|
111
117
|
|
|
@@ -113,29 +119,40 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
113
119
|
// A single .delete(turnId) cleans up all three.
|
|
114
120
|
private readonly _turnObservers = new Map<string, TurnObserverState<TEvent, TMessage>>();
|
|
115
121
|
|
|
116
|
-
//
|
|
117
|
-
private readonly
|
|
118
|
-
|
|
119
|
-
// History pagination: withheld messages hidden from getMessages()
|
|
120
|
-
private readonly _withheldKeys = new Set<string>();
|
|
122
|
+
// Callbacks to resolve pending waitForTurn promises on close, preventing leaked subscriptions.
|
|
123
|
+
private readonly _closeResolvers: (() => void)[] = [];
|
|
121
124
|
|
|
122
125
|
// Sub-components
|
|
123
|
-
private readonly _tree:
|
|
126
|
+
private readonly _tree: DefaultTree<TMessage>;
|
|
127
|
+
private readonly _view: DefaultView<TEvent, TMessage>;
|
|
128
|
+
private readonly _views = new Set<DefaultView<TEvent, TMessage>>();
|
|
124
129
|
private readonly _router: StreamRouter<TEvent>;
|
|
125
130
|
private readonly _decoder: StreamDecoder<TEvent, TMessage>;
|
|
126
131
|
|
|
132
|
+
// Spec: AIT-CT10, AIT-CT10a
|
|
133
|
+
readonly tree: Tree<TMessage>;
|
|
134
|
+
readonly view: View<TEvent, TMessage>;
|
|
135
|
+
|
|
127
136
|
// Channel subscription — subscribe() returns a Promise that resolves when the channel attaches
|
|
128
137
|
private readonly _attachPromise: Promise<unknown>;
|
|
129
138
|
private readonly _onMessage: (msg: Ably.InboundMessage) => void;
|
|
130
139
|
|
|
131
|
-
private
|
|
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>[] = [];
|
|
132
147
|
|
|
133
148
|
constructor(options: ClientTransportOptions<TEvent, TMessage>) {
|
|
134
149
|
this._channel = options.channel;
|
|
135
150
|
this._codec = options.codec;
|
|
136
151
|
this._clientId = options.clientId;
|
|
137
|
-
this._api = options.api
|
|
152
|
+
this._api = options.api;
|
|
138
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.
|
|
139
156
|
this._headersFn =
|
|
140
157
|
typeof options.headers === 'function'
|
|
141
158
|
? options.headers
|
|
@@ -154,23 +171,37 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
154
171
|
});
|
|
155
172
|
|
|
156
173
|
this._emitter = new EventEmitter<ClientTransportEventsMap>(this._logger);
|
|
174
|
+
this._hasAttachedOnce = this._channel.state === 'attached';
|
|
157
175
|
|
|
158
176
|
// Compose sub-components
|
|
159
|
-
this._tree =
|
|
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
|
+
});
|
|
160
186
|
this._router = createStreamRouter<TEvent>(this._codec.isTerminal.bind(this._codec), this._logger);
|
|
161
187
|
this._decoder = this._codec.createDecoder();
|
|
162
188
|
|
|
163
|
-
|
|
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
|
|
164
196
|
if (options.messages) {
|
|
165
197
|
let prevMsgId: string | undefined;
|
|
166
198
|
for (const msg of options.messages) {
|
|
167
|
-
const msgId =
|
|
168
|
-
const seedHeaders: Record<string, string> = {};
|
|
199
|
+
const msgId = crypto.randomUUID();
|
|
200
|
+
const seedHeaders: Record<string, string> = { [HEADER_MSG_ID]: msgId };
|
|
169
201
|
if (prevMsgId) seedHeaders[HEADER_PARENT] = prevMsgId;
|
|
170
202
|
this._tree.upsert(msgId, msg, seedHeaders);
|
|
171
203
|
prevMsgId = msgId;
|
|
172
204
|
}
|
|
173
|
-
this._emitter.emit('message');
|
|
174
205
|
}
|
|
175
206
|
|
|
176
207
|
// Spec: AIT-CT2
|
|
@@ -179,6 +210,15 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
179
210
|
this._handleMessage(ablyMessage);
|
|
180
211
|
};
|
|
181
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);
|
|
182
222
|
}
|
|
183
223
|
|
|
184
224
|
// ---------------------------------------------------------------------------
|
|
@@ -186,10 +226,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
186
226
|
// ---------------------------------------------------------------------------
|
|
187
227
|
|
|
188
228
|
private _handleMessage(ablyMessage: Ably.InboundMessage): void {
|
|
189
|
-
if (this.
|
|
190
|
-
|
|
191
|
-
this._ablyMessages.push(ablyMessage);
|
|
192
|
-
this._emitter.emit('ably-message');
|
|
229
|
+
if (this._state === ClientTransportState.CLOSED) return;
|
|
193
230
|
|
|
194
231
|
try {
|
|
195
232
|
// Spec: AIT-CT16a
|
|
@@ -199,9 +236,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
199
236
|
const turnId = headers[HEADER_TURN_ID];
|
|
200
237
|
const turnCid = headers[HEADER_TURN_CLIENT_ID] ?? '';
|
|
201
238
|
if (turnId) {
|
|
202
|
-
this.
|
|
203
|
-
|
|
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
|
+
});
|
|
204
249
|
}
|
|
250
|
+
this._tree.emitAblyMessage(ablyMessage);
|
|
205
251
|
return;
|
|
206
252
|
}
|
|
207
253
|
|
|
@@ -214,7 +260,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
214
260
|
if (turnId) {
|
|
215
261
|
this._router.closeStream(turnId);
|
|
216
262
|
this._turnObservers.delete(turnId);
|
|
217
|
-
this.
|
|
263
|
+
this._tree.untrackTurn(turnId);
|
|
218
264
|
// Clean up per-turn relay-detection state
|
|
219
265
|
const msgIds = this._turnMsgIds.get(turnId);
|
|
220
266
|
if (msgIds) {
|
|
@@ -222,8 +268,9 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
222
268
|
this._turnMsgIds.delete(turnId);
|
|
223
269
|
}
|
|
224
270
|
this._ownTurnIds.delete(turnId);
|
|
225
|
-
this.
|
|
271
|
+
this._tree.emitTurn({ type: EVENT_TURN_END, turnId, clientId: turnCid, reason });
|
|
226
272
|
}
|
|
273
|
+
this._tree.emitAblyMessage(ablyMessage);
|
|
227
274
|
return;
|
|
228
275
|
}
|
|
229
276
|
|
|
@@ -232,6 +279,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
232
279
|
const headers = getHeaders(ablyMessage);
|
|
233
280
|
const serial = ablyMessage.serial;
|
|
234
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
|
+
|
|
235
294
|
// Always update observer headers, even when the decoder produces no outputs.
|
|
236
295
|
// This ensures header transitions (e.g. x-ably-status: streaming → aborted)
|
|
237
296
|
// are captured for events that the decoder suppresses (AIT-CD8: aborted
|
|
@@ -248,6 +307,11 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
248
307
|
this._handleEventOutput(output, headers);
|
|
249
308
|
}
|
|
250
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);
|
|
251
315
|
} catch (error) {
|
|
252
316
|
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
253
317
|
this._emitter.emit(
|
|
@@ -319,6 +383,79 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
319
383
|
if (this._codec.isTerminal(event)) this._turnObservers.delete(turnId);
|
|
320
384
|
}
|
|
321
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
|
+
|
|
322
459
|
// ---------------------------------------------------------------------------
|
|
323
460
|
// Tree mutation + notification helpers
|
|
324
461
|
// ---------------------------------------------------------------------------
|
|
@@ -330,10 +467,9 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
330
467
|
* @param serial - Ably serial for tree ordering.
|
|
331
468
|
*/
|
|
332
469
|
private _upsertAndNotify(message: TMessage, headers: Record<string, string>, serial?: string): void {
|
|
333
|
-
const
|
|
334
|
-
|
|
470
|
+
const msgId = headers[HEADER_MSG_ID];
|
|
471
|
+
if (!msgId) return;
|
|
335
472
|
this._tree.upsert(msgId, message, headers, serial);
|
|
336
|
-
this._emitter.emit('message');
|
|
337
473
|
}
|
|
338
474
|
|
|
339
475
|
// ---------------------------------------------------------------------------
|
|
@@ -380,6 +516,18 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
380
516
|
const observer = this._turnObservers.get(turnId);
|
|
381
517
|
if (!observer) return;
|
|
382
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
|
+
|
|
383
531
|
observer.accumulator.processOutputs([output]);
|
|
384
532
|
|
|
385
533
|
const messages = observer.accumulator.messages;
|
|
@@ -398,13 +546,10 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
398
546
|
}
|
|
399
547
|
|
|
400
548
|
if (message) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
message,
|
|
404
|
-
|
|
405
|
-
observer.serial,
|
|
406
|
-
);
|
|
407
|
-
this._emitter.emit('message');
|
|
549
|
+
const msgId = observer.headers[HEADER_MSG_ID];
|
|
550
|
+
if (msgId) {
|
|
551
|
+
this._tree.upsert(msgId, message, { ...observer.headers }, observer.serial);
|
|
552
|
+
}
|
|
408
553
|
}
|
|
409
554
|
}
|
|
410
555
|
|
|
@@ -437,39 +582,37 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
437
582
|
// The observer must remain alive so that late server events (e.g. abort,
|
|
438
583
|
// x-ably-status: aborted) arriving before turn-end are still accumulated
|
|
439
584
|
// into the message store. The turn-end handler cleans up observers.
|
|
440
|
-
|
|
441
|
-
|
|
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);
|
|
585
|
+
for (const turnId of this._getMatchingTurnIds(filter)) {
|
|
586
|
+
this._router.closeStream(turnId);
|
|
456
587
|
}
|
|
457
588
|
}
|
|
458
589
|
|
|
459
590
|
private _getMatchingTurnIds(filter: CancelFilter): Set<string> {
|
|
460
591
|
const matched = new Set<string>();
|
|
592
|
+
const activeTurns = this._tree.getActiveTurnIds();
|
|
593
|
+
|
|
461
594
|
if (filter.all) {
|
|
462
|
-
for (const
|
|
595
|
+
for (const turnIds of activeTurns.values()) {
|
|
596
|
+
for (const turnId of turnIds) matched.add(turnId);
|
|
597
|
+
}
|
|
463
598
|
} else if (filter.own) {
|
|
464
|
-
|
|
465
|
-
|
|
599
|
+
const ownTurns = activeTurns.get(this._clientId ?? '');
|
|
600
|
+
if (ownTurns) {
|
|
601
|
+
for (const turnId of ownTurns) matched.add(turnId);
|
|
466
602
|
}
|
|
467
603
|
} else if (filter.clientId) {
|
|
468
|
-
|
|
469
|
-
|
|
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
|
+
}
|
|
470
615
|
}
|
|
471
|
-
} else if (filter.turnId && this._turnClientIds.has(filter.turnId)) {
|
|
472
|
-
matched.add(filter.turnId);
|
|
473
616
|
}
|
|
474
617
|
return matched;
|
|
475
618
|
}
|
|
@@ -478,125 +621,89 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
478
621
|
// Input message helpers
|
|
479
622
|
// ---------------------------------------------------------------------------
|
|
480
623
|
|
|
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
624
|
// ---------------------------------------------------------------------------
|
|
501
|
-
//
|
|
625
|
+
// Public API
|
|
502
626
|
// ---------------------------------------------------------------------------
|
|
503
627
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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');
|
|
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);
|
|
554
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;
|
|
555
644
|
}
|
|
556
645
|
|
|
557
|
-
// ---------------------------------------------------------------------------
|
|
558
|
-
// Public API
|
|
559
|
-
// ---------------------------------------------------------------------------
|
|
560
|
-
|
|
561
646
|
// Spec: AIT-CT3, AIT-CT4
|
|
562
|
-
async
|
|
563
|
-
|
|
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) {
|
|
564
654
|
throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
|
|
565
655
|
}
|
|
566
656
|
await this._attachPromise;
|
|
567
657
|
// CAST: re-check after await — close() may have been called while waiting for attach.
|
|
568
|
-
// TypeScript's control flow narrows
|
|
569
|
-
// await yields and close() can mutate
|
|
570
|
-
if (this.
|
|
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) {
|
|
571
661
|
throw new Ably.ErrorInfo('unable to send; transport is closed', ErrorCode.TransportClosed, 400);
|
|
572
662
|
}
|
|
573
663
|
|
|
574
|
-
|
|
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();');
|
|
575
671
|
|
|
576
672
|
const msgs = Array.isArray(input) ? input : [input];
|
|
577
673
|
const turnId = crypto.randomUUID();
|
|
578
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 ?? [])];
|
|
579
692
|
|
|
580
693
|
const msgIds = new Set<string>();
|
|
581
|
-
const postMessages:
|
|
694
|
+
const postMessages: MessageNode<TMessage>[] = [];
|
|
582
695
|
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
|
|
586
|
-
const preInsertHistory = this._getMessagesWithHeaders();
|
|
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;
|
|
587
699
|
|
|
588
700
|
// Spec: AIT-CT3d
|
|
589
701
|
// Auto-compute parent from the current thread if not explicitly provided
|
|
590
702
|
let autoParent: string | undefined;
|
|
591
703
|
if (sendOptions?.parent === undefined && !sendOptions?.forkOf) {
|
|
592
|
-
const
|
|
593
|
-
if (
|
|
594
|
-
|
|
595
|
-
if (lastMsg) {
|
|
596
|
-
const lastKey = this._codec.getMessageKey(lastMsg);
|
|
597
|
-
const lastNode = this._tree.getNodeByKey(lastKey);
|
|
598
|
-
autoParent = lastNode?.msgId ?? lastKey;
|
|
599
|
-
}
|
|
704
|
+
const lastNode = preInsertHistory.at(-1);
|
|
705
|
+
if (lastNode) {
|
|
706
|
+
autoParent = lastNode.msgId;
|
|
600
707
|
}
|
|
601
708
|
}
|
|
602
709
|
|
|
@@ -608,7 +715,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
608
715
|
this._ownMsgIds.add(msgId);
|
|
609
716
|
msgIds.add(msgId);
|
|
610
717
|
|
|
611
|
-
const resolvedParent = sendOptions?.parent === undefined ? autoParent :
|
|
718
|
+
const resolvedParent = sendOptions?.parent === undefined ? autoParent : sendOptions.parent;
|
|
612
719
|
|
|
613
720
|
const optimisticHeaders = buildTransportHeaders({
|
|
614
721
|
role: 'user',
|
|
@@ -622,10 +729,16 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
622
729
|
// Optimistically insert each user message into the tree
|
|
623
730
|
this._upsertAndNotify(message, optimisticHeaders);
|
|
624
731
|
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
+
});
|
|
629
742
|
|
|
630
743
|
// Spec: AIT-CT3e
|
|
631
744
|
// Chain: each subsequent message in the batch parents off the previous
|
|
@@ -653,6 +766,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
653
766
|
messages: postMessages,
|
|
654
767
|
...(sendOptions?.forkOf !== undefined && { forkOf: sendOptions.forkOf }),
|
|
655
768
|
...(postParent !== undefined && { parent: postParent }),
|
|
769
|
+
...(allEventNodes.length > 0 && { events: allEventNodes }),
|
|
656
770
|
};
|
|
657
771
|
|
|
658
772
|
const postHeaders: Record<string, string> = {
|
|
@@ -674,90 +788,103 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
674
788
|
})
|
|
675
789
|
.then((response) => {
|
|
676
790
|
if (!response.ok) {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
ErrorCode.TransportSendFailed,
|
|
682
|
-
response.status,
|
|
683
|
-
),
|
|
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,
|
|
684
795
|
);
|
|
685
|
-
this.
|
|
796
|
+
this._emitter.emit('error', err);
|
|
797
|
+
this._router.errorStream(turnId, err);
|
|
686
798
|
}
|
|
687
799
|
})
|
|
688
800
|
.catch((error: unknown) => {
|
|
689
801
|
const cause = error instanceof Ably.ErrorInfo ? error : undefined;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
500,
|
|
696
|
-
cause,
|
|
697
|
-
),
|
|
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,
|
|
698
807
|
);
|
|
699
|
-
this.
|
|
808
|
+
this._emitter.emit('error', err);
|
|
809
|
+
this._router.errorStream(turnId, err);
|
|
700
810
|
});
|
|
701
811
|
|
|
702
812
|
return {
|
|
703
813
|
stream,
|
|
704
814
|
turnId,
|
|
705
815
|
cancel: async () => this.cancel({ turnId }),
|
|
816
|
+
optimisticMsgIds: [...msgIds],
|
|
706
817
|
};
|
|
707
818
|
}
|
|
708
819
|
|
|
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
820
|
// Spec: AIT-CT7, AIT-CT7a
|
|
750
821
|
async cancel(filter?: CancelFilter): Promise<void> {
|
|
751
|
-
if (this.
|
|
822
|
+
if (this._state === ClientTransportState.CLOSED) return;
|
|
752
823
|
const resolved = filter ?? { own: true };
|
|
753
824
|
this._logger.debug('ClientTransport.cancel();', { filter: resolved });
|
|
754
825
|
await this._publishCancel(resolved);
|
|
755
826
|
this._closeMatchingTurnStreams(resolved);
|
|
756
827
|
}
|
|
757
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
|
+
|
|
758
885
|
// Spec: AIT-CT18
|
|
759
886
|
async waitForTurn(filter?: CancelFilter): Promise<void> {
|
|
760
|
-
if (this.
|
|
887
|
+
if (this._state === ClientTransportState.CLOSED) return;
|
|
761
888
|
const resolved = filter ?? { own: true };
|
|
762
889
|
const remaining = this._getMatchingTurnIds(resolved);
|
|
763
890
|
if (remaining.size === 0) return;
|
|
@@ -765,153 +892,42 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
765
892
|
this._logger.debug('ClientTransport.waitForTurn();', { turnIds: [...remaining] });
|
|
766
893
|
|
|
767
894
|
return new Promise<void>((resolve) => {
|
|
768
|
-
|
|
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) => {
|
|
769
906
|
if (event.type !== EVENT_TURN_END) return;
|
|
770
907
|
remaining.delete(event.turnId);
|
|
771
|
-
if (remaining.size === 0)
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
this._emitter.on('turn', handler);
|
|
908
|
+
if (remaining.size === 0) done();
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
// Resolve on transport close to prevent leaked subscriptions
|
|
912
|
+
this._closeResolvers.push(done);
|
|
777
913
|
});
|
|
778
914
|
}
|
|
779
915
|
|
|
780
|
-
// Spec: AIT-CT8, AIT-
|
|
781
|
-
on(event: '
|
|
782
|
-
|
|
783
|
-
|
|
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.
|
|
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.
|
|
791
920
|
const cb = handler as (arg: ClientTransportEventsMap[keyof ClientTransportEventsMap]) => void;
|
|
792
|
-
this._emitter.on(
|
|
921
|
+
this._emitter.on(event, cb);
|
|
793
922
|
return () => {
|
|
794
|
-
this._emitter.off(
|
|
923
|
+
this._emitter.off(event, cb);
|
|
795
924
|
};
|
|
796
925
|
}
|
|
797
926
|
|
|
798
|
-
// Spec: AIT-
|
|
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
|
|
927
|
+
// Spec: AIT-CT12, AIT-CT12a, AIT-CT12b, AIT-CT10c
|
|
912
928
|
async close(options?: CloseOptions): Promise<void> {
|
|
913
|
-
if (this.
|
|
914
|
-
this.
|
|
929
|
+
if (this._state === ClientTransportState.CLOSED) return;
|
|
930
|
+
this._state = ClientTransportState.CLOSED;
|
|
915
931
|
this._logger.info('ClientTransport.close();');
|
|
916
932
|
|
|
917
933
|
// Best-effort cancel publish before tearing down local state
|
|
@@ -925,6 +941,7 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
925
941
|
}
|
|
926
942
|
|
|
927
943
|
this._channel.unsubscribe(this._onMessage);
|
|
944
|
+
this._channel.off(this._onChannelStateChange);
|
|
928
945
|
|
|
929
946
|
// Close any remaining active streams
|
|
930
947
|
for (const turnId of this._ownTurnIds) {
|
|
@@ -933,12 +950,13 @@ class DefaultClientTransport<TEvent, TMessage> implements ClientTransport<TEvent
|
|
|
933
950
|
|
|
934
951
|
this._turnObservers.clear();
|
|
935
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;
|
|
936
957
|
this._ownTurnIds.clear();
|
|
937
958
|
this._ownMsgIds.clear();
|
|
938
959
|
this._turnMsgIds.clear();
|
|
939
|
-
this._turnClientIds.clear();
|
|
940
|
-
this._withheldKeys.clear();
|
|
941
|
-
this._ablyMessages.length = 0;
|
|
942
960
|
}
|
|
943
961
|
}
|
|
944
962
|
|