Repository navigation
Expand file tree
/
Copy pathuseSessionStream.ts
More file actions
404 lines (364 loc) · 12.1 KB
/
Copy pathuseSessionStream.ts
File metadata and controls
404 lines (364 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
"use client";
import type { ApiClient, ControlEvent, SSEStreamPart } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { createThrottledQueue } from "../utils/throttle.js";
import type { KeyedMutator } from "../utils/trigger-swr.js";
import { useSWR } from "../utils/trigger-swr.js";
import { useStableRequestCallback } from "../utils/useStableRequestCallback.js";
import type { UseApiClientOptions } from "./useApiClient.js";
import { useApiClient } from "./useApiClient.js";
export type UseSessionStreamInstance<TRecord> = {
/**
* The records received so far on the channel, in arrival order. Control records are
* never included here, they are delivered to `onControl` instead.
*/
records: Array<TRecord>;
/**
* The cursor of the last record seen. Persist this and pass it back as the `lastEventId`
* option to resume the channel where you left off.
*/
lastEventId: string | undefined;
/**
* The last control record seen on the channel (e.g. `turn-complete`).
*/
lastControl: ControlEvent | undefined;
error: Error | undefined;
/**
* Abort the current request immediately, keep the records received so far.
*/
stop: () => void;
};
export type UseSessionStreamOptions<TRecord> = UseApiClientOptions & {
id?: string;
enabled?: boolean;
/**
* Which channel of the session to read.
*
* @default "out"
*/
io?: "out" | "in";
/**
* The number of milliseconds to throttle the record updates.
*
* @default 16
*/
throttleInMs?: number;
/**
* The number of seconds to wait for new data to be available,
* If no data arrives within the timeout, the stream will be closed.
*
* @default 60 seconds
*/
timeoutInSeconds?: number;
/**
* The cursor to resume from. If not provided, the channel is read from the beginning.
*/
lastEventId?: string | number;
/**
* Where a fresh subscription (no `lastEventId`) starts reading.
*
* - `"beginning"` (default): replay the full channel history, then live-tail.
* - `"latest"`: start at the current tail (the latest record, then live
* updates) instead of replaying history, for a last-value / live view.
*
* Ignored when `lastEventId` is set.
*/
from?: "beginning" | "latest";
/**
* Cap the number of records kept in the accumulated `records` array. When more
* than `maxRecords` have been received, only the most recent `maxRecords` are
* retained. Use `maxRecords: 1` with `from: "latest"` for a last-value view
* with bounded memory. When unset, `records` accumulates without bound.
*/
maxRecords?: number;
/**
* Callback invoked once per throttled flush with the batch of records in that
* flush, each carrying its event `id`, `chunk` and `timestamp`. Fires at the
* `throttleInMs` cadence (not per record) and includes control records, so it
* can track the resume cursor for everything on the channel.
*/
onRecords?: (records: Array<SSEStreamPart<TRecord>>) => void;
/**
* Callback this is called when a control record is received (e.g. `turn-complete`).
*/
onControl?: (event: ControlEvent) => void;
};
/**
* Hook to read one channel of a Session's realtime stream.
*
* This hook subscribes to one of the session's channels (`out` by default, or `in`) and
* updates the `records` array as new records arrive. It is read-only: use `useSession` for
* two-way (read and write) communication. The subscription is automatically managed: it
* starts when the component mounts (or when `enabled` becomes `true`) and stops when the
* component unmounts or when `stop()` is called.
*
* Requires a Public Access Token with the `read:sessions:{id}` scope.
*
* @template TRecord - The type of each record on the channel
* @param sessionIdOrExternalId - The id or external id of the session to subscribe to
* @param options - Optional configuration for the subscription
* @returns An object containing:
* - `records`: An array of all the records received so far (accumulates over time)
* - `lastEventId`: The cursor of the last record seen, for resuming later
* - `lastControl`: The last control record seen
* - `error`: Any error that occurred during subscription
* - `stop`: A function to manually stop the subscription
*
* @example
* ```tsx
* "use client";
* import { useSessionStream } from "@trigger.dev/react-hooks";
*
* function SessionViewer({ sessionId }: { sessionId: string }) {
* const { records, error } = useSessionStream<string>(sessionId, {
* accessToken: publicAccessToken,
* });
*
* if (error) return <div>Error: {error.message}</div>;
*
* return <div>{records.join("")}</div>;
* }
* ```
*
* @example
* ```tsx
* // Resume from a persisted cursor
* const { records, lastEventId, stop } = useSessionStream<MyRecord>(sessionId, {
* accessToken: publicAccessToken,
* lastEventId: persistedCursor,
* onControl: (event) => {
* if (event.subtype === "turn-complete") {
* console.log("The turn is complete");
* }
* },
* });
* ```
*/
export function useSessionStream<TRecord = unknown>(
sessionIdOrExternalId?: string,
options?: UseSessionStreamOptions<TRecord>
): UseSessionStreamInstance<TRecord> {
const hookId = useId();
const idKey = options?.id ?? hookId;
const io = options?.io ?? "out";
const [initialRecordsFallback] = useState([] as Array<TRecord>);
const { data: records, mutate: mutateRecords } = useSWR<Array<TRecord>>(
[idKey, sessionIdOrExternalId, io, "records"],
null,
{
fallbackData: initialRecordsFallback,
}
);
const recordsRef = useRef<Array<TRecord>>(records ?? ([] as Array<TRecord>));
useEffect(() => {
recordsRef.current = records || ([] as Array<TRecord>);
}, [records]);
const { data: lastEventId = undefined, mutate: setLastEventId } = useSWR<undefined | string>(
[idKey, sessionIdOrExternalId, io, "lastEventId"],
null
);
const lastEventIdRef = useRef<string | undefined>(lastEventId);
const channelIdentityRef = useRef(`${idKey}:${sessionIdOrExternalId}:${io}`);
useEffect(() => {
const identity = `${idKey}:${sessionIdOrExternalId}:${io}`;
if (channelIdentityRef.current !== identity) {
channelIdentityRef.current = identity;
lastEventIdRef.current = lastEventId;
}
}, [idKey, sessionIdOrExternalId, io, lastEventId]);
const { data: lastControl = undefined, mutate: setLastControl } = useSWR<
undefined | ControlEvent
>([idKey, sessionIdOrExternalId, io, "lastControl"], null);
const { data: _isComplete = false, mutate: setIsComplete } = useSWR<boolean>(
[idKey, sessionIdOrExternalId, io, "complete"],
null
);
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
[idKey, sessionIdOrExternalId, io, "error"],
null
);
const abortControllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const onRecordsCallback = options?.onRecords;
const onRecords = useCallback(
(recordsBatch: Array<SSEStreamPart<TRecord>>) => {
if (onRecordsCallback) {
onRecordsCallback(recordsBatch);
}
},
[onRecordsCallback]
);
const onControlCallback = options?.onControl;
const onControl = useCallback(
(event: ControlEvent) => {
if (onControlCallback) {
onControlCallback(event);
}
},
[onControlCallback]
);
const apiClient = useApiClient(options);
const timeoutInSeconds = options?.timeoutInSeconds;
const startEventId = options?.lastEventId;
const throttleInMs = options?.throttleInMs;
const from = options?.from;
const maxRecords = options?.maxRecords;
useEffect(() => {
if (maxRecords != null && maxRecords >= 0) {
const current = recordsRef.current;
if (current.length > maxRecords) {
mutateRecords(current.slice(current.length - maxRecords));
}
}
}, [maxRecords, mutateRecords]);
const triggerRequest = useCallback(async () => {
try {
if (!sessionIdOrExternalId || !apiClient) {
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
await processSessionStream<TRecord>(
sessionIdOrExternalId,
io,
apiClient,
mutateRecords,
recordsRef,
setLastEventId,
setLastControl,
setError,
onRecords,
onControl,
abortControllerRef,
timeoutInSeconds,
startEventId !== undefined ? String(startEventId) : lastEventIdRef.current,
throttleInMs ?? 16,
from,
maxRecords
);
} catch (err) {
if ((err as any).name === "AbortError") {
abortControllerRef.current = null;
return;
}
setError(err as Error);
} finally {
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
setIsComplete(true);
}
}, [
sessionIdOrExternalId,
io,
apiClient,
mutateRecords,
setLastEventId,
setLastControl,
setError,
setIsComplete,
onRecords,
onControl,
timeoutInSeconds,
startEventId,
throttleInMs,
from,
maxRecords,
]);
const requestSubscription = useStableRequestCallback(triggerRequest);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
if (!sessionIdOrExternalId) {
return;
}
requestSubscription().finally(() => {});
return () => {
stop();
};
}, [sessionIdOrExternalId, io, stop, options?.enabled, requestSubscription]);
return { records: records ?? initialRecordsFallback, lastEventId, lastControl, error, stop };
}
async function processSessionStream<TRecord>(
sessionIdOrExternalId: string,
io: "out" | "in",
apiClient: ApiClient,
mutateRecordsData: KeyedMutator<Array<TRecord>>,
existingRecordsRef: React.MutableRefObject<Array<TRecord>>,
setLastEventId: KeyedMutator<undefined | string>,
setLastControl: KeyedMutator<undefined | ControlEvent>,
onError: (e: Error) => void,
onRecords: (records: Array<SSEStreamPart<TRecord>>) => void,
onControl: (event: ControlEvent) => void,
abortControllerRef: React.MutableRefObject<AbortController | null>,
timeoutInSeconds?: number,
lastEventId?: string,
throttleInMs?: number,
from?: "beginning" | "latest",
maxRecords?: number
) {
let lastSeenEventId: string | undefined;
let publishedEventId: string | undefined;
let partsBatch: Array<SSEStreamPart<TRecord>> = [];
const publishLastEventId = () => {
if (lastSeenEventId !== publishedEventId) {
publishedEventId = lastSeenEventId;
setLastEventId(lastSeenEventId);
}
};
const flushParts = () => {
if (partsBatch.length === 0) return;
const batch = partsBatch;
partsBatch = [];
onRecords(batch);
};
try {
const stream = await apiClient.subscribeToSessionStream<TRecord>(sessionIdOrExternalId, io, {
signal: abortControllerRef.current?.signal,
timeoutInSeconds,
lastEventId,
from,
onPart: (part) => {
lastSeenEventId = part.id;
partsBatch.push(part);
},
onControl: (event) => {
setLastControl(event);
onControl(event);
},
});
const recordsQueue = createThrottledQueue<TRecord>(async (newRecords) => {
const combined = [...existingRecordsRef.current, ...newRecords];
const bounded =
maxRecords != null && maxRecords >= 0 && combined.length > maxRecords
? combined.slice(combined.length - maxRecords)
: combined;
existingRecordsRef.current = bounded;
mutateRecordsData(bounded);
publishLastEventId();
flushParts();
}, throttleInMs);
for await (const record of stream) {
recordsQueue.add(record);
}
await recordsQueue.flush();
publishLastEventId();
flushParts();
} catch (err) {
if ((err as any).name === "AbortError") {
return;
}
if (err instanceof Error) {
onError(err);
} else {
onError(new Error(String(err)));
}
throw err;
}
}