@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,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* decodeHistory — load conversation history from an Ably channel and
|
|
3
|
+
* return decoded messages as a PaginatedMessages result.
|
|
4
|
+
*
|
|
5
|
+
* Uses a fresh decoder (not shared with the live subscription) to avoid
|
|
6
|
+
* state conflicts. Per-turn accumulators handle interleaved turns correctly.
|
|
7
|
+
*
|
|
8
|
+
* The `limit` option controls the number of **messages** returned,
|
|
9
|
+
* not the number of Ably wire messages fetched. The implementation pages
|
|
10
|
+
* back through Ably history until `limit` complete messages have
|
|
11
|
+
* been assembled. Partial turns (incomplete at the page boundary) are
|
|
12
|
+
* buffered internally and completed when `next()` fetches more pages.
|
|
13
|
+
*
|
|
14
|
+
* Only completed messages appear in `items`. A message is complete when
|
|
15
|
+
* its terminal event (finish/abort/error) has been received.
|
|
16
|
+
*
|
|
17
|
+
* Because Ably history returns newest-first while the decoder requires
|
|
18
|
+
* chronological order, all collected Ably messages are re-decoded from
|
|
19
|
+
* oldest to newest after each page fetch. This handles turns that span
|
|
20
|
+
* page boundaries correctly.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type * as Ably from 'ably';
|
|
24
|
+
|
|
25
|
+
import { HEADER_MSG_ID, HEADER_TURN_ID } from '../../constants.js';
|
|
26
|
+
import type { Logger } from '../../logger.js';
|
|
27
|
+
import { getHeaders } from '../../utils.js';
|
|
28
|
+
import type { Codec, DecoderOutput, MessageAccumulator } from '../codec/types.js';
|
|
29
|
+
import type { LoadHistoryOptions, PaginatedMessages } from './types.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Shared state across pages within one history traversal
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
interface HistoryState<TEvent, TMessage> {
|
|
36
|
+
codec: Codec<TEvent, TMessage>;
|
|
37
|
+
/** All raw Ably messages collected so far, in newest-first order (as received from Ably). */
|
|
38
|
+
rawMessages: Ably.InboundMessage[];
|
|
39
|
+
/** How many completed messages have been returned to the consumer so far. */
|
|
40
|
+
returnedCount: number;
|
|
41
|
+
/** How many raw Ably messages have been returned to the consumer so far. */
|
|
42
|
+
returnedRawCount: number;
|
|
43
|
+
/** The last Ably page cursor for continued pagination. */
|
|
44
|
+
lastAblyPage: Ably.PaginatedResult<Ably.InboundMessage> | undefined;
|
|
45
|
+
/** Key function for domain messages (codec.getMessageKey). */
|
|
46
|
+
getMessageKey: (message: TMessage) => string;
|
|
47
|
+
logger: Logger;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A completed message paired with its canonical wire headers and serial. */
|
|
51
|
+
interface DecodedItem<TMessage> {
|
|
52
|
+
message: TMessage;
|
|
53
|
+
headers: Record<string, string>;
|
|
54
|
+
/** Ably serial from the first Ably message for this domain message. */
|
|
55
|
+
serial: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Decode all collected messages from scratch (chronological order)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Re-decode all collected raw messages into completed domain messages.
|
|
64
|
+
* @param state - The shared history traversal state.
|
|
65
|
+
* @returns Completed messages in newest-first order.
|
|
66
|
+
*/
|
|
67
|
+
const decodeAll = <TEvent, TMessage>(state: HistoryState<TEvent, TMessage>): DecodedItem<TMessage>[] => {
|
|
68
|
+
// Reverse to chronological (oldest first)
|
|
69
|
+
const chronological = [...state.rawMessages].toReversed();
|
|
70
|
+
|
|
71
|
+
// Fresh decoder and per-turn accumulators for each full re-decode.
|
|
72
|
+
const decoder = state.codec.createDecoder();
|
|
73
|
+
const turns = new Map<
|
|
74
|
+
string,
|
|
75
|
+
{
|
|
76
|
+
accumulator: MessageAccumulator<TEvent, TMessage>;
|
|
77
|
+
firstSeen: number;
|
|
78
|
+
/** Headers from the first Ably message per x-ably-msg-id within this turn. */
|
|
79
|
+
msgHeaders: Map<string, Record<string, string>>;
|
|
80
|
+
/** Ably serial from the first Ably message per x-ably-msg-id within this turn. */
|
|
81
|
+
msgSerials: Map<string, string>;
|
|
82
|
+
}
|
|
83
|
+
>();
|
|
84
|
+
const defaultAccumulator = state.codec.createAccumulator();
|
|
85
|
+
let orderCounter = 0;
|
|
86
|
+
|
|
87
|
+
// Headers for discrete messages (writeMessages output), keyed by codec message key.
|
|
88
|
+
const discreteHeaders = new Map<string, Record<string, string>>();
|
|
89
|
+
// Serials for discrete messages, keyed by codec message key.
|
|
90
|
+
const discreteSerials = new Map<string, string>();
|
|
91
|
+
|
|
92
|
+
for (const msg of chronological) {
|
|
93
|
+
const outputs: DecoderOutput<TEvent, TMessage>[] = decoder.decode(msg);
|
|
94
|
+
const headers = getHeaders(msg);
|
|
95
|
+
const turnId = headers[HEADER_TURN_ID];
|
|
96
|
+
const msgId = headers[HEADER_MSG_ID];
|
|
97
|
+
const serial = msg.serial;
|
|
98
|
+
|
|
99
|
+
if (turnId) {
|
|
100
|
+
let turn = turns.get(turnId);
|
|
101
|
+
if (!turn) {
|
|
102
|
+
turn = {
|
|
103
|
+
accumulator: state.codec.createAccumulator(),
|
|
104
|
+
firstSeen: orderCounter++,
|
|
105
|
+
msgHeaders: new Map(),
|
|
106
|
+
msgSerials: new Map(),
|
|
107
|
+
};
|
|
108
|
+
turns.set(turnId, turn);
|
|
109
|
+
}
|
|
110
|
+
// Capture headers per msg-id within this turn. Update on later
|
|
111
|
+
// messages too (e.g. closing append overrides status from
|
|
112
|
+
// "streaming" to "finished"/"aborted"). Only merge when the
|
|
113
|
+
// incoming message has non-empty headers.
|
|
114
|
+
if (msgId) {
|
|
115
|
+
const existing = turn.msgHeaders.get(msgId);
|
|
116
|
+
if (!existing) {
|
|
117
|
+
turn.msgHeaders.set(msgId, { ...headers });
|
|
118
|
+
if (serial) turn.msgSerials.set(msgId, serial);
|
|
119
|
+
} else if (Object.keys(headers).length > 0) {
|
|
120
|
+
Object.assign(existing, headers);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
turn.accumulator.processOutputs(outputs);
|
|
124
|
+
} else {
|
|
125
|
+
defaultAccumulator.processOutputs(outputs);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Capture headers and serial for discrete messages by codec key.
|
|
129
|
+
for (const output of outputs) {
|
|
130
|
+
if (output.kind === 'message') {
|
|
131
|
+
const key = state.getMessageKey(output.message);
|
|
132
|
+
const existingDiscrete = discreteHeaders.get(key);
|
|
133
|
+
if (!existingDiscrete) {
|
|
134
|
+
discreteHeaders.set(key, { ...headers });
|
|
135
|
+
if (serial) discreteSerials.set(key, serial);
|
|
136
|
+
} else if (Object.keys(headers).length > 0) {
|
|
137
|
+
Object.assign(existingDiscrete, headers);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Collect completed messages in chronological order (oldest first) by turn.
|
|
144
|
+
const completed: DecodedItem<TMessage>[] = [];
|
|
145
|
+
|
|
146
|
+
for (const msg of defaultAccumulator.completedMessages) {
|
|
147
|
+
const key = state.getMessageKey(msg);
|
|
148
|
+
completed.push({
|
|
149
|
+
message: msg,
|
|
150
|
+
headers: discreteHeaders.get(key) ?? {},
|
|
151
|
+
serial: discreteSerials.get(key) ?? '',
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sorted = [...turns.values()].toSorted((a, b) => a.firstSeen - b.firstSeen);
|
|
156
|
+
for (const turn of sorted) {
|
|
157
|
+
// Assign headers and serials to each completed message in this turn.
|
|
158
|
+
// Discrete messages were already captured by codec key. Accumulated
|
|
159
|
+
// messages need to be matched to the turn's per-msg-id headers.
|
|
160
|
+
const claimedMsgIds = new Set<string>();
|
|
161
|
+
|
|
162
|
+
// First pass: resolve discrete messages and mark their msg-ids as claimed
|
|
163
|
+
const turnKeyHeaders = new Map<string, Record<string, string>>();
|
|
164
|
+
const turnKeySerials = new Map<string, string>();
|
|
165
|
+
for (const msg of turn.accumulator.completedMessages) {
|
|
166
|
+
const key = state.getMessageKey(msg);
|
|
167
|
+
const discrete = discreteHeaders.get(key);
|
|
168
|
+
if (discrete) {
|
|
169
|
+
turnKeyHeaders.set(key, discrete);
|
|
170
|
+
const dSerial = discreteSerials.get(key);
|
|
171
|
+
if (dSerial) turnKeySerials.set(key, dSerial);
|
|
172
|
+
const mid = discrete[HEADER_MSG_ID];
|
|
173
|
+
if (mid) claimedMsgIds.add(mid);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Second pass: assign unclaimed msg-id entries to remaining messages
|
|
178
|
+
const unclaimedEntries = [...turn.msgHeaders.entries()].filter(([mid]) => !claimedMsgIds.has(mid));
|
|
179
|
+
let unclaimedIdx = 0;
|
|
180
|
+
|
|
181
|
+
for (const msg of turn.accumulator.completedMessages) {
|
|
182
|
+
const key = state.getMessageKey(msg);
|
|
183
|
+
const unclaimed = unclaimedEntries[unclaimedIdx];
|
|
184
|
+
if (!turnKeyHeaders.has(key) && unclaimed) {
|
|
185
|
+
const [mid, hdrs] = unclaimed;
|
|
186
|
+
turnKeyHeaders.set(key, hdrs);
|
|
187
|
+
const mSerial = turn.msgSerials.get(mid);
|
|
188
|
+
if (mSerial) turnKeySerials.set(key, mSerial);
|
|
189
|
+
unclaimedIdx++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const msg of turn.accumulator.completedMessages) {
|
|
194
|
+
const key = state.getMessageKey(msg);
|
|
195
|
+
completed.push({
|
|
196
|
+
message: msg,
|
|
197
|
+
headers: turnKeyHeaders.get(key) ?? {},
|
|
198
|
+
serial: turnKeySerials.get(key) ?? '',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Reverse to newest-first. The consumer slices from the front for the
|
|
204
|
+
// most recent page, and progressively deeper for older pages.
|
|
205
|
+
return completed.toReversed();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Fetch Ably pages until we have enough completed messages
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Fetch Ably history pages until we have enough completed messages.
|
|
214
|
+
* @param state - The shared history traversal state.
|
|
215
|
+
* @param ablyPage - The current Ably paginated result to start from.
|
|
216
|
+
* @param limit - Target number of completed messages beyond what has already been returned.
|
|
217
|
+
*/
|
|
218
|
+
const fetchUntilLimit = async <TEvent, TMessage>(
|
|
219
|
+
state: HistoryState<TEvent, TMessage>,
|
|
220
|
+
ablyPage: Ably.PaginatedResult<Ably.InboundMessage>,
|
|
221
|
+
limit: number,
|
|
222
|
+
): Promise<void> => {
|
|
223
|
+
state.rawMessages.push(...ablyPage.items);
|
|
224
|
+
state.lastAblyPage = ablyPage;
|
|
225
|
+
|
|
226
|
+
let decodedCount = decodeAll(state).length;
|
|
227
|
+
while (decodedCount < state.returnedCount + limit && ablyPage.hasNext()) {
|
|
228
|
+
state.logger.debug('decodeHistory.fetchUntilLimit(); fetching next page', {
|
|
229
|
+
collected: state.rawMessages.length,
|
|
230
|
+
decoded: decodedCount,
|
|
231
|
+
});
|
|
232
|
+
const nextPage = await ablyPage.next();
|
|
233
|
+
if (!nextPage) break;
|
|
234
|
+
ablyPage = nextPage;
|
|
235
|
+
state.rawMessages.push(...nextPage.items);
|
|
236
|
+
state.lastAblyPage = nextPage;
|
|
237
|
+
decodedCount = decodeAll(state).length;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Build PaginatedMessages result from current state
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build a PaginatedMessages page from the current decode state.
|
|
247
|
+
* @param state - The shared history traversal state.
|
|
248
|
+
* @param limit - Max messages per page.
|
|
249
|
+
* @returns A page of decoded messages with a `next()` cursor.
|
|
250
|
+
*/
|
|
251
|
+
const buildResult = <TEvent, TMessage>(
|
|
252
|
+
state: HistoryState<TEvent, TMessage>,
|
|
253
|
+
limit: number,
|
|
254
|
+
): PaginatedMessages<TMessage> => {
|
|
255
|
+
// allCompleted is newest-first. Slice from returnedCount for this page,
|
|
256
|
+
// then reverse to chronological for display.
|
|
257
|
+
const allCompleted = decodeAll(state);
|
|
258
|
+
|
|
259
|
+
const pageSlice = allCompleted.slice(state.returnedCount, state.returnedCount + limit);
|
|
260
|
+
const chronSlice = [...pageSlice].toReversed();
|
|
261
|
+
state.returnedCount += pageSlice.length;
|
|
262
|
+
|
|
263
|
+
const moreCompleted = allCompleted.length > state.returnedCount;
|
|
264
|
+
const moreAblyPages = state.lastAblyPage?.hasNext() ?? false;
|
|
265
|
+
|
|
266
|
+
// Raw Ably messages for this page in chronological order.
|
|
267
|
+
const newRawCount = state.rawMessages.length - state.returnedRawCount;
|
|
268
|
+
const rawSlice = newRawCount > 0 ? state.rawMessages.slice(state.returnedRawCount).toReversed() : [];
|
|
269
|
+
state.returnedRawCount = state.rawMessages.length;
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
items: chronSlice.map((d) => d.message),
|
|
273
|
+
itemHeaders: chronSlice.map((d) => d.headers),
|
|
274
|
+
itemSerials: chronSlice.map((d) => d.serial),
|
|
275
|
+
rawMessages: rawSlice,
|
|
276
|
+
hasNext: () => moreCompleted || moreAblyPages,
|
|
277
|
+
next: async () => {
|
|
278
|
+
if (moreCompleted) {
|
|
279
|
+
return buildResult(state, limit);
|
|
280
|
+
}
|
|
281
|
+
if (!moreAblyPages || !state.lastAblyPage) return;
|
|
282
|
+
const nextAbly = await state.lastAblyPage.next();
|
|
283
|
+
if (!nextAbly) return;
|
|
284
|
+
await fetchUntilLimit(state, nextAbly, limit);
|
|
285
|
+
return buildResult(state, limit);
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Public API
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Load conversation history from a channel and return decoded messages.
|
|
296
|
+
*
|
|
297
|
+
* Attaches the channel if not already attached, then calls
|
|
298
|
+
* `channel.history({ untilAttach: true })` to guarantee no gap between
|
|
299
|
+
* historical and live messages. The attach is idempotent.
|
|
300
|
+
*
|
|
301
|
+
* The `limit` option controls the number of complete messages
|
|
302
|
+
* returned per page, not the number of Ably wire messages fetched.
|
|
303
|
+
* @param channel - The Ably channel to load history from.
|
|
304
|
+
* @param codec - The codec for decoding wire messages into domain messages.
|
|
305
|
+
* @param options - Pagination options.
|
|
306
|
+
* @param logger - Logger for diagnostic output.
|
|
307
|
+
* @returns The first page of decoded history messages.
|
|
308
|
+
*/
|
|
309
|
+
// Spec: AIT-CT11, AIT-CT11b
|
|
310
|
+
export const decodeHistory = async <TEvent, TMessage>(
|
|
311
|
+
channel: Ably.RealtimeChannel,
|
|
312
|
+
codec: Codec<TEvent, TMessage>,
|
|
313
|
+
options: LoadHistoryOptions | undefined,
|
|
314
|
+
logger: Logger,
|
|
315
|
+
): Promise<PaginatedMessages<TMessage>> => {
|
|
316
|
+
const limit = options?.limit ?? 100;
|
|
317
|
+
const state: HistoryState<TEvent, TMessage> = {
|
|
318
|
+
codec,
|
|
319
|
+
rawMessages: [],
|
|
320
|
+
returnedCount: 0,
|
|
321
|
+
returnedRawCount: 0,
|
|
322
|
+
lastAblyPage: undefined,
|
|
323
|
+
getMessageKey: codec.getMessageKey.bind(codec),
|
|
324
|
+
logger,
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
logger.trace('decodeHistory();', { limit });
|
|
328
|
+
|
|
329
|
+
// Request more Ably messages than the domain limit to account for
|
|
330
|
+
// the many-to-one ratio (multiple wire messages per message).
|
|
331
|
+
const wireLimit = limit * 10;
|
|
332
|
+
|
|
333
|
+
await channel.attach();
|
|
334
|
+
const ablyPage = await channel.history({ untilAttach: true, limit: wireLimit });
|
|
335
|
+
await fetchUntilLimit(state, ablyPage, limit);
|
|
336
|
+
return buildResult(state, limit);
|
|
337
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport header builder.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for which `x-ably-*` headers every transport
|
|
5
|
+
* message carries. Used by the server transport (addMessages, streamResponse)
|
|
6
|
+
* and will be used by the client transport (optimistic message stamping).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
HEADER_FORK_OF,
|
|
11
|
+
HEADER_MSG_ID,
|
|
12
|
+
HEADER_PARENT,
|
|
13
|
+
HEADER_ROLE,
|
|
14
|
+
HEADER_TURN_CLIENT_ID,
|
|
15
|
+
HEADER_TURN_ID,
|
|
16
|
+
} from '../../constants.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the standard transport header set for a message.
|
|
20
|
+
* @param opts - The header values to include.
|
|
21
|
+
* @param opts.role - Message role (e.g. "user", "assistant").
|
|
22
|
+
* @param opts.turnId - Turn correlation ID.
|
|
23
|
+
* @param opts.msgId - Message identity.
|
|
24
|
+
* @param opts.turnClientId - ClientId of the turn initiator.
|
|
25
|
+
* @param opts.parent - Preceding message's msg-id (for branching). Null means root.
|
|
26
|
+
* @param opts.forkOf - Forked message's msg-id (for edit/regen).
|
|
27
|
+
* @returns A headers record with the `x-ably-*` transport headers set.
|
|
28
|
+
*/
|
|
29
|
+
export const buildTransportHeaders = (opts: {
|
|
30
|
+
role: string;
|
|
31
|
+
turnId: string;
|
|
32
|
+
msgId: string;
|
|
33
|
+
turnClientId?: string;
|
|
34
|
+
parent?: string | null;
|
|
35
|
+
forkOf?: string;
|
|
36
|
+
}): Record<string, string> => {
|
|
37
|
+
const h: Record<string, string> = {
|
|
38
|
+
[HEADER_ROLE]: opts.role,
|
|
39
|
+
[HEADER_TURN_ID]: opts.turnId,
|
|
40
|
+
[HEADER_MSG_ID]: opts.msgId,
|
|
41
|
+
};
|
|
42
|
+
if (opts.turnClientId !== undefined) h[HEADER_TURN_CLIENT_ID] = opts.turnClientId;
|
|
43
|
+
if (opts.parent) h[HEADER_PARENT] = opts.parent;
|
|
44
|
+
if (opts.forkOf) h[HEADER_FORK_OF] = opts.forkOf;
|
|
45
|
+
return h;
|
|
46
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared types
|
|
2
|
+
export type {
|
|
3
|
+
ActiveTurn,
|
|
4
|
+
AddMessageOptions,
|
|
5
|
+
AddMessagesResult,
|
|
6
|
+
CancelFilter,
|
|
7
|
+
CancelRequest,
|
|
8
|
+
ClientTransport,
|
|
9
|
+
ClientTransportOptions,
|
|
10
|
+
CloseOptions,
|
|
11
|
+
ConversationNode,
|
|
12
|
+
ConversationTree,
|
|
13
|
+
LoadHistoryOptions,
|
|
14
|
+
MessageWithHeaders,
|
|
15
|
+
NewTurnOptions,
|
|
16
|
+
PaginatedMessages,
|
|
17
|
+
SendOptions,
|
|
18
|
+
ServerTransport,
|
|
19
|
+
ServerTransportOptions,
|
|
20
|
+
StreamResponseOptions,
|
|
21
|
+
StreamResult,
|
|
22
|
+
Turn,
|
|
23
|
+
TurnEndReason,
|
|
24
|
+
TurnLifecycleEvent,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
|
|
27
|
+
// Server transport
|
|
28
|
+
export { createServerTransport } from './server-transport.js';
|
|
29
|
+
|
|
30
|
+
// Client transport
|
|
31
|
+
export { createClientTransport } from './client-transport.js';
|
|
32
|
+
|
|
33
|
+
// Header builder
|
|
34
|
+
export { buildTransportHeaders } from './headers.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure stream piping function.
|
|
3
|
+
*
|
|
4
|
+
* Reads events from a ReadableStream, writes them to a streaming encoder,
|
|
5
|
+
* and handles abort/error. No dependencies on turn state or transport internals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Logger } from '../../logger.js';
|
|
9
|
+
import type { StreamEncoder } from '../codec/types.js';
|
|
10
|
+
import type { StreamResult } from './types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pipe an event stream through an encoder to the channel.
|
|
14
|
+
*
|
|
15
|
+
* Returns when the stream completes, is cancelled (via signal), or errors.
|
|
16
|
+
* The `reason` field of the result indicates which case occurred.
|
|
17
|
+
* @param stream - The event stream to read from.
|
|
18
|
+
* @param encoder - The streaming encoder to write events through.
|
|
19
|
+
* @param signal - Abort signal to monitor for cancellation.
|
|
20
|
+
* @param onAbort - Optional callback invoked when the stream is cancelled, before the stream ends.
|
|
21
|
+
* @param logger - Optional logger for diagnostic output.
|
|
22
|
+
* @returns The reason the pipe ended.
|
|
23
|
+
*/
|
|
24
|
+
export const pipeStream = async <TEvent, TMessage>(
|
|
25
|
+
stream: ReadableStream<TEvent>,
|
|
26
|
+
encoder: StreamEncoder<TEvent, TMessage>,
|
|
27
|
+
signal: AbortSignal | undefined,
|
|
28
|
+
onAbort?: (write: (event: TEvent) => Promise<void>) => void | Promise<void>,
|
|
29
|
+
logger?: Logger,
|
|
30
|
+
): Promise<StreamResult> => {
|
|
31
|
+
logger?.trace('pipeStream();');
|
|
32
|
+
|
|
33
|
+
const reader = stream.getReader();
|
|
34
|
+
|
|
35
|
+
let abortListener: (() => void) | undefined;
|
|
36
|
+
const abortPromise = signal
|
|
37
|
+
? new Promise<void>((resolve) => {
|
|
38
|
+
if (signal.aborted) {
|
|
39
|
+
resolve();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
abortListener = () => {
|
|
43
|
+
resolve();
|
|
44
|
+
};
|
|
45
|
+
signal.addEventListener('abort', abortListener, { once: true });
|
|
46
|
+
})
|
|
47
|
+
: // eslint-disable-next-line @typescript-eslint/no-empty-function -- never-resolving promise: no signal means no cancellation path
|
|
48
|
+
new Promise<void>(() => {});
|
|
49
|
+
|
|
50
|
+
let reason: StreamResult['reason'] = 'complete';
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop broken by return/break
|
|
54
|
+
while (true) {
|
|
55
|
+
// .then() is intentional: transforms the abort signal into a discriminant
|
|
56
|
+
// for Promise.race — no async/await equivalent for this pattern.
|
|
57
|
+
const result = await Promise.race([reader.read(), abortPromise.then(() => 'aborted' as const)]);
|
|
58
|
+
|
|
59
|
+
if (result === 'aborted') {
|
|
60
|
+
reason = 'cancelled';
|
|
61
|
+
logger?.debug('pipeStream(); stream cancelled by abort signal');
|
|
62
|
+
if (onAbort) {
|
|
63
|
+
await onAbort(async (event: TEvent) => encoder.appendEvent(event));
|
|
64
|
+
}
|
|
65
|
+
await encoder.abort('cancelled');
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { done, value } = result;
|
|
70
|
+
if (done) {
|
|
71
|
+
await encoder.close();
|
|
72
|
+
logger?.debug('pipeStream(); stream completed');
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await encoder.appendEvent(value);
|
|
77
|
+
}
|
|
78
|
+
} catch (error) {
|
|
79
|
+
reason = 'error';
|
|
80
|
+
const errorText = error instanceof Error ? error.message : String(error);
|
|
81
|
+
logger?.error('pipeStream(); stream error', { error: errorText });
|
|
82
|
+
try {
|
|
83
|
+
await encoder.close();
|
|
84
|
+
} catch {
|
|
85
|
+
// Best-effort: encoder close in the error path may also fail
|
|
86
|
+
// (e.g. channel disconnected). The original error is preserved in
|
|
87
|
+
// the StreamResult reason ("error").
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
if (abortListener) signal?.removeEventListener('abort', abortListener);
|
|
91
|
+
reader.releaseLock();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { reason };
|
|
95
|
+
};
|