-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(sdk,core,webapp,react-hooks): named side channels on a Session #4815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
946c900
25fcd1b
dde4f17
d80c490
86cf30f
9b7be3d
5b1f61b
436e455
225edfe
2f77568
5705e51
b8dd764
85912a3
d66a43b
3ba8508
b18ca54
f993d00
c2b2246
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| "@trigger.dev/react-hooks": patch | ||
| "@trigger.dev/core": patch | ||
| "@trigger.dev/sdk": patch | ||
| --- | ||
|
|
||
| Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `sessions.open(id).channel(name)` (or `chat.channel(name)` inside a `chat.agent`) to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it. | ||
|
|
||
| ```ts | ||
| // Inside a chat.agent: stream frames on a named channel, wakes nothing | ||
| const frames = chat.channel("screenshots"); | ||
| await frames.out.append(frame); | ||
| frames.in.on((control) => { /* client control, no suspend */ }); | ||
| ``` | ||
|
|
||
| Declare channel record types once with `sessions.defineChannel(...)` and infer them on both the producer and the consumer, including `useSessionStreamChannel` in React. Channels get a default retention that keeps them bounded, overridable per channel. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { tryCatch } from "@trigger.dev/core/utils"; | ||
| import { nanoid } from "nanoid"; | ||
| import { z } from "zod"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; | ||
| import { | ||
| SESSION_CHANNEL_NAME_REGEX, | ||
| sessionChannelResources, | ||
| } from "~/services/realtime/sessionChannels.server"; | ||
| import { | ||
| canonicalSessionAddressingKey, | ||
| resolveSessionWithWriterFallback, | ||
| } from "~/services/realtime/sessions.server"; | ||
| import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; | ||
| import { | ||
| claimSessionStreamPart, | ||
| releaseSessionStreamPart, | ||
| } from "~/services/sessionStreamWaitpointCache.server"; | ||
| import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
| import { ServiceValidationError } from "~/v3/services/common.server"; | ||
|
|
||
| const ParamsSchema = z.object({ | ||
| session: z.string(), | ||
| channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), | ||
| io: z.enum(["out", "in"]), | ||
| }); | ||
|
|
||
| const MAX_APPEND_BODY_BYTES = 1024 * 1024; | ||
|
|
||
| const { action, loader } = createActionApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| method: "POST", | ||
| maxContentLength: MAX_APPEND_BODY_BYTES, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| findResource: async (params, auth) => | ||
| resolveSessionWithWriterFallback(auth.environment.id, params.session), | ||
| authorization: { | ||
| action: "write", | ||
| resource: (params, _s, _h, _b, session) => { | ||
| const ids = new Set<string>([params.session]); | ||
| if (session) { | ||
| ids.add(session.friendlyId); | ||
| if (session.externalId) ids.add(session.externalId); | ||
| } | ||
| return anyResource(sessionChannelResources(params.channel, ids)); | ||
| }, | ||
| }, | ||
| }, | ||
| async ({ request, params, authentication, resource: session }) => { | ||
| if (!session) { | ||
| return new Response("Session not found", { status: 404 }); | ||
| } | ||
|
|
||
| if (session.closedAt) { | ||
| return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (session.expiresAt && session.expiresAt.getTime() < Date.now()) { | ||
| return json({ ok: false, error: "Cannot append to an expired session" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (params.io === "out" && authentication.type !== "PRIVATE") { | ||
| return json( | ||
| { ok: false, error: "Appending to the out channel requires secret key authentication" }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
|
|
||
| const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { | ||
| session, | ||
| }); | ||
|
|
||
| if (!(realtimeStream instanceof S2RealtimeStreams)) { | ||
| return json( | ||
| { ok: false, error: "Session channels require the S2 realtime backend" }, | ||
| { status: 501 } | ||
| ); | ||
| } | ||
|
|
||
| const addressingKey = canonicalSessionAddressingKey(session, params.session); | ||
| const claimKey = `${addressingKey}:channels:${params.channel}`; | ||
|
|
||
| const part = await request.text(); | ||
|
|
||
| const clientPartId = request.headers.get("X-Part-Id"); | ||
| const partId = clientPartId ?? nanoid(7); | ||
|
|
||
| const wonClaim = clientPartId | ||
| ? await claimSessionStreamPart( | ||
| authentication.environment.id, | ||
| claimKey, | ||
| params.io, | ||
| clientPartId | ||
| ) | ||
| : true; | ||
|
|
||
| let appendSeq: number | undefined; | ||
| if (wonClaim) { | ||
| const [appendError, seq] = await tryCatch( | ||
| realtimeStream.appendPartToSessionStream( | ||
| part, | ||
| partId, | ||
| addressingKey, | ||
| params.io, | ||
| params.channel | ||
| ) | ||
| ); | ||
| appendSeq = seq ?? undefined; | ||
|
|
||
| if (appendError) { | ||
| if (clientPartId) { | ||
| await releaseSessionStreamPart( | ||
| authentication.environment.id, | ||
| claimKey, | ||
| params.io, | ||
| clientPartId | ||
| ); | ||
| } | ||
| if (appendError instanceof ServiceValidationError) { | ||
| return json( | ||
| { ok: false, error: appendError.message }, | ||
| { status: appendError.status ?? 422 } | ||
| ); | ||
| } | ||
| logger.error("Failed to append to session channel stream", { | ||
| sessionId: session.id, | ||
| io: params.io, | ||
| channel: params.channel, | ||
| error: appendError, | ||
| }); | ||
| return json( | ||
| { ok: false, error: "Something went wrong, please try again." }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
|
Comment on lines
+100
to
+139
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔍 Channel .in append skips run-wake and input sanitization The channel append handler omits the Was this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| return json({ ok: true, seq: appendSeq }, { status: 200 }); | ||
| } | ||
| ); | ||
|
|
||
| export { action, loader }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { z } from "zod"; | ||
| import { $replica } from "~/db.server"; | ||
| import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; | ||
| import { | ||
| SESSION_CHANNEL_NAME_REGEX, | ||
| sessionChannelResources, | ||
| } from "~/services/realtime/sessionChannels.server"; | ||
| import { | ||
| canonicalSessionAddressingKey, | ||
| isSessionFriendlyIdForm, | ||
| resolveSessionByIdOrExternalId, | ||
| } from "~/services/realtime/sessions.server"; | ||
| import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; | ||
| import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
|
|
||
| const ParamsSchema = z.object({ | ||
| session: z.string(), | ||
| channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), | ||
| io: z.enum(["out", "in"]), | ||
| }); | ||
|
|
||
| const SearchSchema = z.object({ | ||
| afterEventId: z.string().regex(/^\d+$/).optional(), | ||
| }); | ||
|
|
||
| export const loader = createLoaderApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| searchParams: SearchSchema, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| findResource: async (params, auth) => { | ||
| const row = await resolveSessionByIdOrExternalId( | ||
| $replica, | ||
| auth.environment.id, | ||
| params.session | ||
| ); | ||
| if (!row && isSessionFriendlyIdForm(params.session)) { | ||
| return undefined; | ||
|
Comment on lines
+34
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Fresh sessions fail immediate reads
Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
| return { | ||
| row, | ||
| addressingKey: canonicalSessionAddressingKey(row, params.session), | ||
| }; | ||
| }, | ||
| authorization: { | ||
| action: "read", | ||
| resource: ({ row, addressingKey }, params) => { | ||
| const ids = new Set<string>([addressingKey]); | ||
| if (row) { | ||
| ids.add(row.friendlyId); | ||
| if (row.externalId) ids.add(row.externalId); | ||
| } | ||
| return anyResource(sessionChannelResources(params.channel, ids)); | ||
| }, | ||
| }, | ||
| }, | ||
| async ({ params, authentication, resource, searchParams }) => { | ||
| const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { | ||
| session: resource.row, | ||
| organization: resource.row ? null : authentication.environment.organization, | ||
| }); | ||
|
|
||
| if (!(realtimeStream instanceof S2RealtimeStreams)) { | ||
| return new Response("Session channels require the S2 realtime backend", { status: 501 }); | ||
| } | ||
|
|
||
| const afterSeqNum = | ||
| searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined; | ||
|
|
||
| const records = await realtimeStream.readSessionStreamRecords( | ||
| resource.addressingKey, | ||
| params.io, | ||
| afterSeqNum, | ||
| params.channel | ||
| ); | ||
|
|
||
| return json({ records }); | ||
| } | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Release note overstates retention controls
The changeset promises per-channel retention overrides, but
channelaccepts no options. Remove the claim or add the documented API before release.Was this helpful? React with 👍 or 👎 to provide feedback.