Skip to content

Commit 4df6bf9

Browse files
committed
feat(react-hooks,webapp,core): from/maxRecords/onRecords for useSessionStream
useSessionStream can start at the current tail (from: "latest"), bound the retained records (maxRecords), and report each throttled batch of records with their event ids (onRecords, replacing the per-record onRecord). The session SSE route reads the start-position header and maps it to the S2 tail start, matching the run-stream path.
1 parent fb48384 commit 4df6bf9

4 files changed

Lines changed: 72 additions & 18 deletions

File tree

.changeset/spotty-pillows-visit.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@trigger.dev/react-hooks": patch
33
---
44

5-
Added a `useSessionStream` React hook for reading a session's output or input channel in realtime, with automatic resume from the last record you received.
5+
Added a `useSessionStream` React hook for reading a session's output or input channel in realtime. It accumulates records with automatic resume from the last record you received, and supports `from: "latest"` (start at the current tail, only new records after you connect), `maxRecords` (keep a bounded number of records in memory), a `lastEventId` resume cursor, and an `onRecords` callback that delivers each throttled batch of records with their event ids.

apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { json } from "@remix-run/server-runtime";
2+
import { STREAM_START_HEADER } from "@trigger.dev/core/v3";
23
import { z } from "zod";
34
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
45
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
@@ -185,12 +186,15 @@ const loader = createLoaderApiRoute(
185186
// turn's first chunk and the SSE closes before records land.
186187
const peekSettled = request.headers.get("X-Peek-Settled") === "1";
187188

189+
const startFrom =
190+
request.headers.get(STREAM_START_HEADER)?.toLowerCase() === "latest" ? "latest" : undefined;
191+
188192
return realtimeStream.streamResponseFromSessionStream(
189193
request,
190194
resource.addressingKey,
191195
params.io,
192196
getRequestAbortSignal(),
193-
{ lastEventId, timeoutInSeconds, peekSettled }
197+
{ lastEventId, timeoutInSeconds, peekSettled, startFrom }
194198
);
195199
}
196200
);

packages/core/src/v3/apiClient/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,12 @@ export class ApiClient {
14811481
onComplete?: () => void;
14821482
onError?: (error: Error) => void;
14831483
lastEventId?: string;
1484+
/**
1485+
* Where a fresh subscription (no `lastEventId`) starts reading. `"latest"`
1486+
* starts at the current tail (only records after connect); `"beginning"`
1487+
* (default) replays history.
1488+
*/
1489+
from?: "beginning" | "latest";
14841490
onPart?: (part: SSEStreamPart<T>) => void;
14851491
/**
14861492
* Fires when a `trigger-control` record arrives on the stream (e.g.
@@ -1500,6 +1506,7 @@ export class ApiClient {
15001506
onError: options?.onError,
15011507
timeoutInSeconds: options?.timeoutInSeconds,
15021508
lastEventId: options?.lastEventId,
1509+
from: options?.from,
15031510
});
15041511

15051512
const stream = await subscription.subscribe();

packages/react-hooks/src/hooks/useSessionStream.ts

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,29 @@ export type UseSessionStreamOptions<TRecord> = UseApiClientOptions & {
6262
*/
6363
lastEventId?: string | number;
6464
/**
65-
* Callback this is called when a record is received, before throttling. This fires for
66-
* control records too, so you can track the cursor for every record on the channel.
65+
* Where a fresh subscription (no `lastEventId`) starts reading.
66+
*
67+
* - `"beginning"` (default): replay the full channel history, then live-tail.
68+
* - `"latest"`: skip history and start at the current tail — only records
69+
* appended after the hook connects are delivered (a last-value / live view).
70+
*
71+
* Ignored when `lastEventId` is set.
72+
*/
73+
from?: "beginning" | "latest";
74+
/**
75+
* Cap the number of records kept in the accumulated `records` array. When more
76+
* than `maxRecords` have been received, only the most recent `maxRecords` are
77+
* retained. Use `maxRecords: 1` with `from: "latest"` for a last-value view
78+
* with bounded memory. When unset, `records` accumulates without bound.
6779
*/
68-
onRecord?: (record: SSEStreamPart<TRecord>) => void;
80+
maxRecords?: number;
81+
/**
82+
* Callback invoked once per throttled flush with the batch of records in that
83+
* flush, each carrying its event `id`, `chunk` and `timestamp`. Fires at the
84+
* `throttleInMs` cadence (not per record) and includes control records, so it
85+
* can track the resume cursor for everything on the channel.
86+
*/
87+
onRecords?: (records: Array<SSEStreamPart<TRecord>>) => void;
6988
/**
7089
* Callback this is called when a control record is received (e.g. `turn-complete`).
7190
*/
@@ -175,14 +194,14 @@ export function useSessionStream<TRecord = unknown>(
175194
}
176195
}, []);
177196

178-
const onRecordCallback = options?.onRecord;
179-
const onRecord = useCallback(
180-
(record: SSEStreamPart<TRecord>) => {
181-
if (onRecordCallback) {
182-
onRecordCallback(record);
197+
const onRecordsCallback = options?.onRecords;
198+
const onRecords = useCallback(
199+
(recordsBatch: Array<SSEStreamPart<TRecord>>) => {
200+
if (onRecordsCallback) {
201+
onRecordsCallback(recordsBatch);
183202
}
184203
},
185-
[onRecordCallback]
204+
[onRecordsCallback]
186205
);
187206

188207
const onControlCallback = options?.onControl;
@@ -199,6 +218,8 @@ export function useSessionStream<TRecord = unknown>(
199218
const timeoutInSeconds = options?.timeoutInSeconds;
200219
const startEventId = options?.lastEventId;
201220
const throttleInMs = options?.throttleInMs;
221+
const from = options?.from;
222+
const maxRecords = options?.maxRecords;
202223

203224
const triggerRequest = useCallback(async () => {
204225
try {
@@ -218,12 +239,14 @@ export function useSessionStream<TRecord = unknown>(
218239
setLastEventId,
219240
setLastControl,
220241
setError,
221-
onRecord,
242+
onRecords,
222243
onControl,
223244
abortControllerRef,
224245
timeoutInSeconds,
225246
startEventId !== undefined ? String(startEventId) : undefined,
226-
throttleInMs ?? 16
247+
throttleInMs ?? 16,
248+
from,
249+
maxRecords
227250
);
228251
} catch (err) {
229252
if ((err as any).name === "AbortError") {
@@ -248,11 +271,13 @@ export function useSessionStream<TRecord = unknown>(
248271
setLastControl,
249272
setError,
250273
setIsComplete,
251-
onRecord,
274+
onRecords,
252275
onControl,
253276
timeoutInSeconds,
254277
startEventId,
255278
throttleInMs,
279+
from,
280+
maxRecords,
256281
]);
257282
const requestSubscription = useStableRequestCallback(triggerRequest);
258283

@@ -284,15 +309,18 @@ async function processSessionStream<TRecord>(
284309
setLastEventId: KeyedMutator<undefined | string>,
285310
setLastControl: KeyedMutator<undefined | ControlEvent>,
286311
onError: (e: Error) => void,
287-
onRecord: (record: SSEStreamPart<TRecord>) => void,
312+
onRecords: (records: Array<SSEStreamPart<TRecord>>) => void,
288313
onControl: (event: ControlEvent) => void,
289314
abortControllerRef: React.MutableRefObject<AbortController | null>,
290315
timeoutInSeconds?: number,
291316
lastEventId?: string,
292-
throttleInMs?: number
317+
throttleInMs?: number,
318+
from?: "beginning" | "latest",
319+
maxRecords?: number
293320
) {
294321
let lastSeenEventId: string | undefined;
295322
let publishedEventId: string | undefined;
323+
let partsBatch: Array<SSEStreamPart<TRecord>> = [];
296324

297325
const publishLastEventId = () => {
298326
if (lastSeenEventId !== publishedEventId) {
@@ -301,14 +329,22 @@ async function processSessionStream<TRecord>(
301329
}
302330
};
303331

332+
const flushParts = () => {
333+
if (partsBatch.length === 0) return;
334+
const batch = partsBatch;
335+
partsBatch = [];
336+
onRecords(batch);
337+
};
338+
304339
try {
305340
const stream = await apiClient.subscribeToSessionStream<TRecord>(sessionIdOrExternalId, io, {
306341
signal: abortControllerRef.current?.signal,
307342
timeoutInSeconds,
308343
lastEventId,
344+
from,
309345
onPart: (part) => {
310346
lastSeenEventId = part.id;
311-
onRecord(part);
347+
partsBatch.push(part);
312348
},
313349
onControl: (event) => {
314350
setLastControl(event);
@@ -317,8 +353,14 @@ async function processSessionStream<TRecord>(
317353
});
318354

319355
const recordsQueue = createThrottledQueue<TRecord>(async (newRecords) => {
320-
mutateRecordsData([...existingRecordsRef.current, ...newRecords]);
356+
const combined = [...existingRecordsRef.current, ...newRecords];
357+
const bounded =
358+
maxRecords != null && maxRecords >= 0 && combined.length > maxRecords
359+
? combined.slice(combined.length - maxRecords)
360+
: combined;
361+
mutateRecordsData(bounded);
321362
publishLastEventId();
363+
flushParts();
322364
}, throttleInMs);
323365

324366
for await (const record of stream) {
@@ -327,6 +369,7 @@ async function processSessionStream<TRecord>(
327369

328370
await recordsQueue.flush();
329371
publishLastEventId();
372+
flushParts();
330373
} catch (err) {
331374
if ((err as any).name === "AbortError") {
332375
return;

0 commit comments

Comments
 (0)