@ably/ai-transport 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +176 -0
- package/README.md +426 -0
- package/dist/ably-ai-transport.js +1388 -0
- package/dist/ably-ai-transport.js.map +1 -0
- package/dist/ably-ai-transport.umd.cjs +2 -0
- package/dist/ably-ai-transport.umd.cjs.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/core/codec/decoder.d.ts +62 -0
- package/dist/core/codec/encoder.d.ts +56 -0
- package/dist/core/codec/index.d.ts +8 -0
- package/dist/core/codec/lifecycle-tracker.d.ts +74 -0
- package/dist/core/codec/types.d.ts +188 -0
- package/dist/core/transport/client-transport.d.ts +10 -0
- package/dist/core/transport/conversation-tree.d.ts +9 -0
- package/dist/core/transport/decode-history.d.ts +41 -0
- package/dist/core/transport/headers.d.ts +26 -0
- package/dist/core/transport/index.d.ts +4 -0
- package/dist/core/transport/pipe-stream.d.ts +16 -0
- package/dist/core/transport/server-transport.d.ts +7 -0
- package/dist/core/transport/stream-router.d.ts +19 -0
- package/dist/core/transport/turn-manager.d.ts +34 -0
- package/dist/core/transport/types.d.ts +407 -0
- package/dist/errors.d.ts +46 -0
- package/dist/event-emitter.d.ts +65 -0
- package/dist/index.d.ts +11 -0
- package/dist/logger.d.ts +103 -0
- package/dist/react/ably-ai-transport-react.js +823 -0
- package/dist/react/ably-ai-transport-react.js.map +1 -0
- package/dist/react/ably-ai-transport-react.umd.cjs +2 -0
- package/dist/react/ably-ai-transport-react.umd.cjs.map +1 -0
- package/dist/react/index.d.ts +11 -0
- package/dist/react/use-ably-messages.d.ts +18 -0
- package/dist/react/use-active-turns.d.ts +8 -0
- package/dist/react/use-client-transport.d.ts +7 -0
- package/dist/react/use-conversation-tree.d.ts +20 -0
- package/dist/react/use-edit.d.ts +7 -0
- package/dist/react/use-history.d.ts +19 -0
- package/dist/react/use-messages.d.ts +7 -0
- package/dist/react/use-regenerate.d.ts +7 -0
- package/dist/react/use-send.d.ts +7 -0
- package/dist/utils.d.ts +127 -0
- package/dist/vercel/ably-ai-transport-vercel.js +2331 -0
- package/dist/vercel/ably-ai-transport-vercel.js.map +1 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs +2 -0
- package/dist/vercel/ably-ai-transport-vercel.umd.cjs.map +1 -0
- package/dist/vercel/codec/accumulator.d.ts +21 -0
- package/dist/vercel/codec/decoder.d.ts +22 -0
- package/dist/vercel/codec/encoder.d.ts +41 -0
- package/dist/vercel/codec/index.d.ts +22 -0
- package/dist/vercel/index.d.ts +3 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js +2082 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.js.map +1 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs +2 -0
- package/dist/vercel/react/ably-ai-transport-vercel-react.umd.cjs.map +1 -0
- package/dist/vercel/react/index.d.ts +3 -0
- package/dist/vercel/react/use-chat-transport.d.ts +29 -0
- package/dist/vercel/react/use-message-sync.d.ts +19 -0
- package/dist/vercel/transport/chat-transport.d.ts +118 -0
- package/dist/vercel/transport/index.d.ts +36 -0
- package/package.json +123 -0
- package/react/README.md +3 -0
- package/react/index.d.ts +1 -0
- package/react/index.js +1 -0
- package/react/index.umd.cjs +1 -0
- package/src/constants.ts +98 -0
- package/src/core/codec/decoder.ts +402 -0
- package/src/core/codec/encoder.ts +470 -0
- package/src/core/codec/index.ts +28 -0
- package/src/core/codec/lifecycle-tracker.ts +140 -0
- package/src/core/codec/types.ts +249 -0
- package/src/core/transport/client-transport.ts +959 -0
- package/src/core/transport/conversation-tree.ts +434 -0
- package/src/core/transport/decode-history.ts +337 -0
- package/src/core/transport/headers.ts +46 -0
- package/src/core/transport/index.ts +34 -0
- package/src/core/transport/pipe-stream.ts +95 -0
- package/src/core/transport/server-transport.ts +458 -0
- package/src/core/transport/stream-router.ts +118 -0
- package/src/core/transport/turn-manager.ts +147 -0
- package/src/core/transport/types.ts +533 -0
- package/src/errors.ts +58 -0
- package/src/event-emitter.ts +103 -0
- package/src/index.ts +89 -0
- package/src/logger.ts +241 -0
- package/src/react/index.ts +11 -0
- package/src/react/use-ably-messages.ts +37 -0
- package/src/react/use-active-turns.ts +61 -0
- package/src/react/use-client-transport.ts +37 -0
- package/src/react/use-conversation-tree.ts +71 -0
- package/src/react/use-edit.ts +24 -0
- package/src/react/use-history.ts +111 -0
- package/src/react/use-messages.ts +32 -0
- package/src/react/use-regenerate.ts +24 -0
- package/src/react/use-send.ts +25 -0
- package/src/react/vite.config.ts +32 -0
- package/src/tsconfig.json +25 -0
- package/src/utils.ts +230 -0
- package/src/vercel/codec/accumulator.ts +603 -0
- package/src/vercel/codec/decoder.ts +615 -0
- package/src/vercel/codec/encoder.ts +396 -0
- package/src/vercel/codec/index.ts +37 -0
- package/src/vercel/index.ts +12 -0
- package/src/vercel/react/index.ts +4 -0
- package/src/vercel/react/use-chat-transport.ts +60 -0
- package/src/vercel/react/use-message-sync.ts +34 -0
- package/src/vercel/react/vite.config.ts +33 -0
- package/src/vercel/transport/chat-transport.ts +278 -0
- package/src/vercel/transport/index.ts +56 -0
- package/src/vercel/vite.config.ts +33 -0
- package/src/vite.config.ts +31 -0
- package/vercel/README.md +3 -0
- package/vercel/index.d.ts +1 -0
- package/vercel/index.js +1 -0
- package/vercel/index.umd.cjs +1 -0
- package/vercel/react/README.md +3 -0
- package/vercel/react/index.d.ts +1 -0
- package/vercel/react/index.js +1 -0
- package/vercel/react/index.umd.cjs +1 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encoder core — message append lifecycle machinery.
|
|
3
|
+
*
|
|
4
|
+
* Provides Ably primitives (publish, append, close, abort, flush) that
|
|
5
|
+
* domain-specific encoders wire their event types to.
|
|
6
|
+
*
|
|
7
|
+
* Domain encoders call `createEncoderCore(writer, options)` and use the
|
|
8
|
+
* returned core to map domain events to Ably operations without
|
|
9
|
+
* reimplementing the message append lifecycle.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import * as Ably from 'ably';
|
|
13
|
+
|
|
14
|
+
import { HEADER_MSG_ID, HEADER_STATUS, HEADER_STREAM, HEADER_STREAM_ID } from '../../constants.js';
|
|
15
|
+
import { ErrorCode } from '../../errors.js';
|
|
16
|
+
import type { Logger } from '../../logger.js';
|
|
17
|
+
import { mergeHeaders } from '../../utils.js';
|
|
18
|
+
import type { ChannelWriter, EncoderOptions, Extras, MessagePayload, StreamPayload, WriteOptions } from './types.js';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Options
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Options for creating an encoder core. Extends {@link EncoderOptions} with a logger. */
|
|
25
|
+
export interface EncoderCoreOptions extends EncoderOptions {
|
|
26
|
+
/** Logger instance for diagnostic output. */
|
|
27
|
+
logger?: Logger;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Stream tracker (internal)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
interface StreamState {
|
|
35
|
+
serial: string;
|
|
36
|
+
name: string;
|
|
37
|
+
streamId: string;
|
|
38
|
+
accumulated: string;
|
|
39
|
+
persistentHeaders: Record<string, string>;
|
|
40
|
+
aborted: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PendingAppend {
|
|
44
|
+
promise: Promise<Ably.UpdateDeleteResult>;
|
|
45
|
+
streamId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Encoder core interface
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** The core encoder primitives that domain codec encoders delegate to. */
|
|
53
|
+
export interface EncoderCore {
|
|
54
|
+
/** Publish a single discrete (non-streaming) message described by a payload. */
|
|
55
|
+
publishDiscrete(payload: MessagePayload, opts?: WriteOptions): Promise<Ably.PublishResult>;
|
|
56
|
+
|
|
57
|
+
/** Publish multiple discrete messages atomically in a single channel publish. */
|
|
58
|
+
publishDiscreteBatch(payloads: MessagePayload[], opts?: WriteOptions): Promise<Ably.PublishResult>;
|
|
59
|
+
|
|
60
|
+
/** Start a streamed message with x-ably-status:streaming. */
|
|
61
|
+
startStream(streamId: string, payload: StreamPayload, opts?: WriteOptions): Promise<void>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Append data to an in-flight streamed message. Fire-and-forget: errors are
|
|
65
|
+
* collected internally and surfaced by {@link closeStream} or {@link close}.
|
|
66
|
+
*/
|
|
67
|
+
appendStream(streamId: string, data: string): void;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Close a streamed message with x-ably-status:finished. Flushes all pending
|
|
71
|
+
* appends for recovery before returning. Repeats persistent and payload headers.
|
|
72
|
+
*/
|
|
73
|
+
closeStream(streamId: string, payload: StreamPayload): Promise<void>;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Abort a single in-progress stream (x-ably-status:aborted) and flush all
|
|
77
|
+
* pending appends for recovery before returning.
|
|
78
|
+
*/
|
|
79
|
+
abortStream(streamId: string, opts?: WriteOptions): Promise<void>;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Abort all in-progress streams (x-ably-status:aborted) and flush all
|
|
83
|
+
* pending appends for recovery before returning.
|
|
84
|
+
*/
|
|
85
|
+
abortAllStreams(opts?: WriteOptions): Promise<void>;
|
|
86
|
+
|
|
87
|
+
/** Flush + clear trackers. Idempotent. */
|
|
88
|
+
close(): Promise<void>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Default implementation
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
// Spec: AIT-CD1
|
|
96
|
+
class DefaultEncoderCore implements EncoderCore {
|
|
97
|
+
private readonly _writer: ChannelWriter;
|
|
98
|
+
private readonly _defaultClientId: string | undefined;
|
|
99
|
+
private readonly _defaultExtras: Extras | undefined;
|
|
100
|
+
private readonly _onMessageHook: (message: Ably.Message) => void;
|
|
101
|
+
private readonly _logger: Logger | undefined;
|
|
102
|
+
private readonly _trackers = new Map<string, StreamState>();
|
|
103
|
+
private _pending: PendingAppend[] = [];
|
|
104
|
+
private _flushPromise: Promise<void> | undefined;
|
|
105
|
+
private _closed = false;
|
|
106
|
+
|
|
107
|
+
constructor(writer: ChannelWriter, options: EncoderCoreOptions = {}) {
|
|
108
|
+
this._writer = writer;
|
|
109
|
+
this._defaultClientId = options.clientId;
|
|
110
|
+
this._defaultExtras = options.extras;
|
|
111
|
+
this._onMessageHook =
|
|
112
|
+
options.onMessage ??
|
|
113
|
+
(() => {
|
|
114
|
+
/* noop */
|
|
115
|
+
});
|
|
116
|
+
this._logger = options.logger?.withContext({ component: 'EncoderCore' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Spec: AIT-CD11
|
|
120
|
+
async publishDiscrete(payload: MessagePayload, opts?: WriteOptions): Promise<Ably.PublishResult> {
|
|
121
|
+
this._assertNotClosed();
|
|
122
|
+
this._logger?.trace('DefaultEncoderCore.publishDiscrete();', { name: payload.name });
|
|
123
|
+
const msg = this._buildDiscreteMessage(payload, opts);
|
|
124
|
+
return this._writer.publish(msg);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Spec: AIT-CD11a
|
|
128
|
+
async publishDiscreteBatch(payloads: MessagePayload[], opts?: WriteOptions): Promise<Ably.PublishResult> {
|
|
129
|
+
this._assertNotClosed();
|
|
130
|
+
this._logger?.trace('DefaultEncoderCore.publishDiscreteBatch();', { count: payloads.length });
|
|
131
|
+
const msgs = payloads.map((p) => this._buildDiscreteMessage(p, opts));
|
|
132
|
+
return this._writer.publish(msgs);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Spec: AIT-CD2
|
|
136
|
+
async startStream(streamId: string, payload: StreamPayload, opts?: WriteOptions): Promise<void> {
|
|
137
|
+
this._assertNotClosed();
|
|
138
|
+
this._logger?.trace('DefaultEncoderCore.startStream();', { name: payload.name, streamId });
|
|
139
|
+
|
|
140
|
+
const allHeaders = this._buildHeaders(payload.headers ?? {}, opts);
|
|
141
|
+
allHeaders[HEADER_STREAM] = 'true';
|
|
142
|
+
allHeaders[HEADER_STATUS] = 'streaming';
|
|
143
|
+
allHeaders[HEADER_STREAM_ID] = streamId;
|
|
144
|
+
|
|
145
|
+
const clientId = this._resolveClientId(opts);
|
|
146
|
+
const msg: Ably.Message = {
|
|
147
|
+
name: payload.name,
|
|
148
|
+
data: payload.data,
|
|
149
|
+
extras: { headers: allHeaders },
|
|
150
|
+
...(clientId ? { clientId } : {}),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
this._invokeOnMessage(msg);
|
|
154
|
+
const result = await this._writer.publish(msg);
|
|
155
|
+
const serial = result.serials[0];
|
|
156
|
+
|
|
157
|
+
if (!serial) {
|
|
158
|
+
throw new Ably.ErrorInfo(
|
|
159
|
+
`unable to start stream; no serial returned for stream '${payload.name}' (streamId: ${streamId})`,
|
|
160
|
+
ErrorCode.BadRequest,
|
|
161
|
+
400,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this._trackers.set(streamId, {
|
|
166
|
+
serial,
|
|
167
|
+
name: payload.name,
|
|
168
|
+
streamId,
|
|
169
|
+
accumulated: payload.data,
|
|
170
|
+
persistentHeaders: allHeaders,
|
|
171
|
+
aborted: false,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this._logger?.debug('DefaultEncoderCore.startStream(); stream started', {
|
|
175
|
+
name: payload.name,
|
|
176
|
+
streamId,
|
|
177
|
+
serial,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Spec: AIT-CD3
|
|
182
|
+
appendStream(streamId: string, data: string): void {
|
|
183
|
+
this._assertNotClosed();
|
|
184
|
+
const tracker = this._trackers.get(streamId);
|
|
185
|
+
if (!tracker) {
|
|
186
|
+
throw new Ably.ErrorInfo(
|
|
187
|
+
`unable to append to stream; no active stream for streamId '${streamId}'`,
|
|
188
|
+
ErrorCode.InvalidArgument,
|
|
189
|
+
400,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
tracker.accumulated += data;
|
|
194
|
+
|
|
195
|
+
const appendMsg: Ably.Message = {
|
|
196
|
+
serial: tracker.serial,
|
|
197
|
+
data,
|
|
198
|
+
extras: { headers: { ...tracker.persistentHeaders } },
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
this._invokeOnMessage(appendMsg);
|
|
202
|
+
const p = this._writer.appendMessage(appendMsg);
|
|
203
|
+
this._pending.push({ promise: p, streamId });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Spec: AIT-CD4, AIT-CD4a
|
|
207
|
+
async closeStream(streamId: string, payload: StreamPayload): Promise<void> {
|
|
208
|
+
this._assertNotClosed();
|
|
209
|
+
this._logger?.trace('DefaultEncoderCore.closeStream();', { streamId });
|
|
210
|
+
|
|
211
|
+
const tracker = this._trackers.get(streamId);
|
|
212
|
+
if (!tracker) {
|
|
213
|
+
throw new Ably.ErrorInfo(
|
|
214
|
+
`unable to close stream; no active stream for streamId '${streamId}'`,
|
|
215
|
+
ErrorCode.InvalidArgument,
|
|
216
|
+
400,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Accumulate closing data so recovery has the full content
|
|
221
|
+
tracker.accumulated += payload.data;
|
|
222
|
+
|
|
223
|
+
const allHeaders = this._buildClosingHeaders(tracker, payload.headers ?? {});
|
|
224
|
+
allHeaders[HEADER_STATUS] = 'finished';
|
|
225
|
+
|
|
226
|
+
const msg: Ably.Message = {
|
|
227
|
+
serial: tracker.serial,
|
|
228
|
+
data: payload.data,
|
|
229
|
+
extras: { headers: allHeaders },
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
this._invokeOnMessage(msg);
|
|
233
|
+
const p = this._writer.appendMessage(msg);
|
|
234
|
+
this._pending.push({ promise: p, streamId });
|
|
235
|
+
|
|
236
|
+
await this._flushPending();
|
|
237
|
+
|
|
238
|
+
this._logger?.debug('DefaultEncoderCore.closeStream(); stream closed', { streamId });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Spec: AIT-CD5, AIT-CD5b
|
|
242
|
+
async abortStream(streamId: string, opts?: WriteOptions): Promise<void> {
|
|
243
|
+
this._assertNotClosed();
|
|
244
|
+
this._logger?.trace('DefaultEncoderCore.abortStream();', { streamId });
|
|
245
|
+
|
|
246
|
+
const tracker = this._trackers.get(streamId);
|
|
247
|
+
if (!tracker) {
|
|
248
|
+
throw new Ably.ErrorInfo(
|
|
249
|
+
`unable to abort stream; no active stream for streamId '${streamId}'`,
|
|
250
|
+
ErrorCode.InvalidArgument,
|
|
251
|
+
400,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
tracker.aborted = true;
|
|
256
|
+
|
|
257
|
+
const allHeaders = this._buildClosingHeaders(tracker, {}, opts);
|
|
258
|
+
allHeaders[HEADER_STATUS] = 'aborted';
|
|
259
|
+
|
|
260
|
+
const msg: Ably.Message = {
|
|
261
|
+
serial: tracker.serial,
|
|
262
|
+
data: '',
|
|
263
|
+
extras: { headers: allHeaders },
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
this._invokeOnMessage(msg);
|
|
267
|
+
const p = this._writer.appendMessage(msg);
|
|
268
|
+
this._pending.push({ promise: p, streamId });
|
|
269
|
+
|
|
270
|
+
await this._flushPending();
|
|
271
|
+
|
|
272
|
+
this._logger?.debug('DefaultEncoderCore.abortStream(); stream aborted', { streamId });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Spec: AIT-CD5a
|
|
276
|
+
async abortAllStreams(opts?: WriteOptions): Promise<void> {
|
|
277
|
+
this._assertNotClosed();
|
|
278
|
+
this._logger?.trace('DefaultEncoderCore.abortAllStreams();', { streamCount: this._trackers.size });
|
|
279
|
+
|
|
280
|
+
for (const tracker of this._trackers.values()) {
|
|
281
|
+
tracker.aborted = true;
|
|
282
|
+
|
|
283
|
+
const allHeaders = this._buildClosingHeaders(tracker, {}, opts);
|
|
284
|
+
allHeaders[HEADER_STATUS] = 'aborted';
|
|
285
|
+
|
|
286
|
+
const msg: Ably.Message = {
|
|
287
|
+
serial: tracker.serial,
|
|
288
|
+
data: '',
|
|
289
|
+
extras: { headers: allHeaders },
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
this._invokeOnMessage(msg);
|
|
293
|
+
const p = this._writer.appendMessage(msg);
|
|
294
|
+
this._pending.push({ promise: p, streamId: tracker.streamId });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await this._flushPending();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Spec: AIT-CD6
|
|
301
|
+
private async _flushPending(): Promise<void> {
|
|
302
|
+
// Re-entrancy guard: if a flush is already in progress, await it instead of starting a new one.
|
|
303
|
+
if (this._flushPromise) {
|
|
304
|
+
return this._flushPromise;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const snapshot = this._pending;
|
|
308
|
+
this._pending = [];
|
|
309
|
+
|
|
310
|
+
if (snapshot.length === 0) return;
|
|
311
|
+
|
|
312
|
+
this._logger?.trace('DefaultEncoderCore._flushPending();', { count: snapshot.length });
|
|
313
|
+
|
|
314
|
+
this._flushPromise = this._doFlush(snapshot);
|
|
315
|
+
try {
|
|
316
|
+
await this._flushPromise;
|
|
317
|
+
} finally {
|
|
318
|
+
this._flushPromise = undefined;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private async _doFlush(snapshot: PendingAppend[]): Promise<void> {
|
|
323
|
+
const results = await Promise.allSettled(snapshot.map(async (p) => p.promise));
|
|
324
|
+
const failures = new Set<string>();
|
|
325
|
+
|
|
326
|
+
for (const [i, result] of results.entries()) {
|
|
327
|
+
const entry = snapshot[i];
|
|
328
|
+
if (entry && result.status === 'rejected') {
|
|
329
|
+
failures.add(entry.streamId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (failures.size === 0) {
|
|
334
|
+
this._logger?.debug('DefaultEncoderCore._flushPending(); all appends succeeded');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
this._logger?.warn('DefaultEncoderCore._flushPending(); recovering failed appends', {
|
|
339
|
+
failedStreams: [...failures],
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const recoveryErrors: { streamId: string; error: unknown }[] = [];
|
|
343
|
+
|
|
344
|
+
for (const streamId of failures) {
|
|
345
|
+
const tracker = this._trackers.get(streamId);
|
|
346
|
+
if (!tracker) continue;
|
|
347
|
+
|
|
348
|
+
const recoveryStatus = tracker.aborted ? 'aborted' : 'finished';
|
|
349
|
+
const msg: Ably.Message = {
|
|
350
|
+
serial: tracker.serial,
|
|
351
|
+
data: tracker.accumulated,
|
|
352
|
+
extras: { headers: { ...tracker.persistentHeaders, [HEADER_STATUS]: recoveryStatus } },
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
try {
|
|
356
|
+
await this._writer.updateMessage(msg);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
recoveryErrors.push({ streamId, error });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (recoveryErrors.length > 0) {
|
|
363
|
+
const ids = recoveryErrors.map((e) => e.streamId).join(', ');
|
|
364
|
+
this._logger?.error('DefaultEncoderCore._flushPending(); recovery failed', { failedStreams: ids });
|
|
365
|
+
throw new Ably.ErrorInfo(
|
|
366
|
+
`unable to flush pending appends; recovery failed for stream(s): ${ids}`,
|
|
367
|
+
ErrorCode.EncoderRecoveryFailed,
|
|
368
|
+
500,
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Spec: AIT-CD12
|
|
374
|
+
async close(): Promise<void> {
|
|
375
|
+
if (this._closed) return;
|
|
376
|
+
this._logger?.trace('DefaultEncoderCore.close();');
|
|
377
|
+
this._closed = true;
|
|
378
|
+
try {
|
|
379
|
+
await this._flushPending();
|
|
380
|
+
} finally {
|
|
381
|
+
this._trackers.clear();
|
|
382
|
+
}
|
|
383
|
+
this._logger?.debug('DefaultEncoderCore.close(); encoder closed');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// -------------------------------------------------------------------------
|
|
387
|
+
// Private helpers
|
|
388
|
+
// -------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
// Spec: AIT-CD14
|
|
391
|
+
private _invokeOnMessage(msg: Ably.Message): void {
|
|
392
|
+
try {
|
|
393
|
+
this._onMessageHook(msg);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
this._logger?.error('DefaultEncoderCore._invokeOnMessage(); hook threw', { error });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private _assertNotClosed(): void {
|
|
400
|
+
if (this._closed) {
|
|
401
|
+
throw new Ably.ErrorInfo('unable to write to encoder; encoder has been closed', ErrorCode.InvalidArgument, 400);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private _resolveClientId(opts?: WriteOptions): string | undefined {
|
|
406
|
+
return opts?.clientId ?? this._defaultClientId;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private _buildHeaders(codecHeaders: Record<string, string>, opts?: WriteOptions): Record<string, string> {
|
|
410
|
+
const callerHeaders = mergeHeaders(this._defaultExtras?.headers, opts?.extras?.headers);
|
|
411
|
+
const merged = { ...callerHeaders, ...codecHeaders };
|
|
412
|
+
if (opts?.messageId !== undefined) {
|
|
413
|
+
merged[HEADER_MSG_ID] = opts.messageId;
|
|
414
|
+
}
|
|
415
|
+
return merged;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private _buildDiscreteMessage(payload: MessagePayload, opts?: WriteOptions): Ably.Message {
|
|
419
|
+
const headers = this._buildHeaders(payload.headers ?? {}, opts);
|
|
420
|
+
headers[HEADER_STREAM] = 'false';
|
|
421
|
+
const clientId = this._resolveClientId(opts);
|
|
422
|
+
|
|
423
|
+
const msg: Ably.Message = {
|
|
424
|
+
name: payload.name,
|
|
425
|
+
data: payload.data,
|
|
426
|
+
extras: {
|
|
427
|
+
headers,
|
|
428
|
+
...(payload.ephemeral ? { ephemeral: true } : {}),
|
|
429
|
+
},
|
|
430
|
+
...(clientId ? { clientId } : {}),
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
this._invokeOnMessage(msg);
|
|
434
|
+
return msg;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Build headers for a closing append. Closing appends must repeat ALL
|
|
439
|
+
* persistent headers (Ably replaces the entire extras object on append).
|
|
440
|
+
* Then layer caller and codec overrides.
|
|
441
|
+
* @param tracker - The stream tracker with persistent headers.
|
|
442
|
+
* @param codecHeaders - Codec-layer headers to merge.
|
|
443
|
+
* @param opts - Optional per-write overrides.
|
|
444
|
+
* @returns Merged headers for the closing append.
|
|
445
|
+
*/
|
|
446
|
+
private _buildClosingHeaders(
|
|
447
|
+
tracker: StreamState,
|
|
448
|
+
codecHeaders: Record<string, string>,
|
|
449
|
+
opts?: WriteOptions,
|
|
450
|
+
): Record<string, string> {
|
|
451
|
+
const h = { ...tracker.persistentHeaders };
|
|
452
|
+
const callerHeaders = mergeHeaders(this._defaultExtras?.headers, opts?.extras?.headers);
|
|
453
|
+
Object.assign(h, callerHeaders);
|
|
454
|
+
Object.assign(h, codecHeaders);
|
|
455
|
+
return h;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// Factory
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Create an encoder core bound to the given channel writer.
|
|
465
|
+
* @param writer - The channel writer to publish messages through.
|
|
466
|
+
* @param options - Encoder configuration (clientId, extras, hooks, logger).
|
|
467
|
+
* @returns A new {@link EncoderCore} instance.
|
|
468
|
+
*/
|
|
469
|
+
export const createEncoderCore = (writer: ChannelWriter, options: EncoderCoreOptions = {}): EncoderCore =>
|
|
470
|
+
new DefaultEncoderCore(writer, options);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { eventOutput } from './decoder.js';
|
|
2
|
+
export type {
|
|
3
|
+
ChannelWriter,
|
|
4
|
+
Codec,
|
|
5
|
+
DecoderOutput,
|
|
6
|
+
DiscreteEncoder,
|
|
7
|
+
EncoderOptions,
|
|
8
|
+
Extras,
|
|
9
|
+
MessageAccumulator,
|
|
10
|
+
MessagePayload,
|
|
11
|
+
StreamDecoder,
|
|
12
|
+
StreamEncoder,
|
|
13
|
+
StreamPayload,
|
|
14
|
+
StreamTrackerState,
|
|
15
|
+
WriteOptions,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
|
|
18
|
+
// Encoder core
|
|
19
|
+
export type { EncoderCore, EncoderCoreOptions } from './encoder.js';
|
|
20
|
+
export { createEncoderCore } from './encoder.js';
|
|
21
|
+
|
|
22
|
+
// Decoder core
|
|
23
|
+
export type { DecoderCore, DecoderCoreHooks, DecoderCoreOptions } from './decoder.js';
|
|
24
|
+
export { createDecoderCore } from './decoder.js';
|
|
25
|
+
|
|
26
|
+
// Lifecycle tracker
|
|
27
|
+
export type { LifecycleTracker, PhaseConfig } from './lifecycle-tracker.js';
|
|
28
|
+
export { createLifecycleTracker } from './lifecycle-tracker.js';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic lifecycle tracker for codec decoders.
|
|
3
|
+
*
|
|
4
|
+
* Manages per-scope (typically per-turn) tracking of lifecycle phases that
|
|
5
|
+
* must be emitted before content events. When a phase has not been emitted
|
|
6
|
+
* (e.g. mid-stream join), the tracker synthesizes the missing events using
|
|
7
|
+
* codec-provided build functions.
|
|
8
|
+
*
|
|
9
|
+
* Codecs configure the tracker with an ordered list of phases, then compose
|
|
10
|
+
* it into their decoder hooks. The tracker is independent of any specific
|
|
11
|
+
* codec or event type.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Phase configuration
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Configuration for a single lifecycle phase that may need to be
|
|
20
|
+
* synthesized when missing from the wire stream.
|
|
21
|
+
*/
|
|
22
|
+
export interface PhaseConfig<TEvent> {
|
|
23
|
+
/** Unique key identifying this phase (e.g. "start", "start-step"). */
|
|
24
|
+
key: string;
|
|
25
|
+
/**
|
|
26
|
+
* Build the synthetic event(s) for this phase. Called with a context
|
|
27
|
+
* record that codecs populate at the call site — the tracker passes
|
|
28
|
+
* it through without interpreting it.
|
|
29
|
+
* @param context - Key-value pairs from the call site (e.g. headers).
|
|
30
|
+
* @returns One or more synthetic events to emit for this phase.
|
|
31
|
+
*/
|
|
32
|
+
build(context: Record<string, string | undefined>): TEvent[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Tracker interface
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Per-scope lifecycle tracker that ensures required phases are emitted
|
|
41
|
+
* before content events, synthesizing missing ones for mid-stream joins.
|
|
42
|
+
*
|
|
43
|
+
* Scoped by an arbitrary string key (typically a turn ID). Each scope
|
|
44
|
+
* tracks independently which phases have been emitted.
|
|
45
|
+
*/
|
|
46
|
+
export interface LifecycleTracker<TEvent> {
|
|
47
|
+
/**
|
|
48
|
+
* Ensure all configured phases have been emitted for the given scope.
|
|
49
|
+
* Returns synthetic events for any phases not yet marked as emitted,
|
|
50
|
+
* then marks them. Returns an empty array if all phases are current.
|
|
51
|
+
* @param scopeId - The scope to check (e.g. turn ID).
|
|
52
|
+
* @param context - Key-value pairs passed through to phase build functions.
|
|
53
|
+
* @returns Synthetic events for missing phases, in configuration order.
|
|
54
|
+
*/
|
|
55
|
+
ensurePhases(scopeId: string, context: Record<string, string | undefined>): TEvent[];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Mark a phase as emitted from the wire (not synthetic). Call this
|
|
59
|
+
* when the real event arrives so the tracker does not re-synthesize it.
|
|
60
|
+
* @param scopeId - The scope (e.g. turn ID).
|
|
61
|
+
* @param phaseKey - The phase key to mark.
|
|
62
|
+
*/
|
|
63
|
+
markEmitted(scopeId: string, phaseKey: string): void;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reset a phase so it will be re-synthesized on the next
|
|
67
|
+
* {@link ensurePhases} call. Used for repeating phases (e.g. "start-step"
|
|
68
|
+
* resets after "finish-step").
|
|
69
|
+
* @param scopeId - The scope (e.g. turn ID).
|
|
70
|
+
* @param phaseKey - The phase key to reset.
|
|
71
|
+
*/
|
|
72
|
+
resetPhase(scopeId: string, phaseKey: string): void;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Remove all tracking state for a scope. Call on turn completion
|
|
76
|
+
* (finish, abort) to free memory.
|
|
77
|
+
* @param scopeId - The scope to clear.
|
|
78
|
+
*/
|
|
79
|
+
clearScope(scopeId: string): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Default implementation
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// Spec: AIT-CD13
|
|
87
|
+
class DefaultLifecycleTracker<TEvent> implements LifecycleTracker<TEvent> {
|
|
88
|
+
private readonly _phases: PhaseConfig<TEvent>[];
|
|
89
|
+
private readonly _emitted = new Map<string, Set<string>>();
|
|
90
|
+
|
|
91
|
+
constructor(phases: PhaseConfig<TEvent>[]) {
|
|
92
|
+
this._phases = phases;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ensurePhases(scopeId: string, context: Record<string, string | undefined>): TEvent[] {
|
|
96
|
+
const emitted = this._getOrCreate(scopeId);
|
|
97
|
+
const events: TEvent[] = [];
|
|
98
|
+
for (const phase of this._phases) {
|
|
99
|
+
if (!emitted.has(phase.key)) {
|
|
100
|
+
emitted.add(phase.key);
|
|
101
|
+
events.push(...phase.build(context));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return events;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
markEmitted(scopeId: string, phaseKey: string): void {
|
|
108
|
+
this._getOrCreate(scopeId).add(phaseKey);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
resetPhase(scopeId: string, phaseKey: string): void {
|
|
112
|
+
this._emitted.get(scopeId)?.delete(phaseKey);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
clearScope(scopeId: string): void {
|
|
116
|
+
this._emitted.delete(scopeId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private _getOrCreate(scopeId: string): Set<string> {
|
|
120
|
+
let set = this._emitted.get(scopeId);
|
|
121
|
+
if (!set) {
|
|
122
|
+
set = new Set();
|
|
123
|
+
this._emitted.set(scopeId, set);
|
|
124
|
+
}
|
|
125
|
+
return set;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Factory
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Create a lifecycle tracker configured with the given phases.
|
|
135
|
+
* Phases are checked and synthesized in array order.
|
|
136
|
+
* @param phases - Ordered phase configurations.
|
|
137
|
+
* @returns A new {@link LifecycleTracker} instance.
|
|
138
|
+
*/
|
|
139
|
+
export const createLifecycleTracker = <TEvent>(phases: PhaseConfig<TEvent>[]): LifecycleTracker<TEvent> =>
|
|
140
|
+
new DefaultLifecycleTracker(phases);
|