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