import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; import { canonicalSessionAddressingKey, isSessionFriendlyIdForm, resolveSessionWithWriterFallback, } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ session: z.string(), io: z.enum(["out", "in"]), }); // PUT: initialize the S2 channel for this (session, io) pair — returns S2 // credentials in response headers so the caller can write/read directly // against S2. GET is handled by the loader below. const { action } = createActionApiRoute( { params: ParamsSchema, method: "PUT", allowJWT: true, corsStrategy: "all", authorization: { action: "write", resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ params, authentication }) => { // Row-optional addressing. The agent calls PUT initialize as part // of `session.out.writer()`, by which time it has already created // the row at bind, so a missing row here is an unusual case // (manual init from outside chat.agent). Require a real row only // for opaque friendlyIds, and treat closedAt as a soft reject only // when a row exists. The S2 stream key is built from the row's // canonical key (externalId if set, else friendlyId) so writers // and readers converge regardless of URL form. const maybeSession = await resolveSessionWithWriterFallback( authentication.environment.id, params.session ); if (!maybeSession && isSessionFriendlyIdForm(params.session)) { return new Response("Session not found", { status: 404 }); } if (maybeSession?.closedAt) { return new Response("Cannot initialize a channel on a closed session", { status: 400, }); } // No-row form: resolve via the org so the stream initialised here // matches what later appends/subscribes will land on once the row // is created. const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { session: maybeSession, organization: maybeSession ? null : authentication.environment.organization, }); if (!(realtimeStream instanceof S2RealtimeStreams)) { return new Response("Session channels require the S2 realtime backend", { status: 501, }); } const addressingKey = canonicalSessionAddressingKey(maybeSession, params.session); const { responseHeaders } = await realtimeStream.initializeSessionStream( addressingKey, params.io ); return json({ version: "v2" }, { status: 202, headers: responseHeaders }); } ); // GET: SSE subscribe to a session channel. HEAD returns the last chunk index // for resume semantics, mirroring the existing run-stream route. // // Subscribes are row-optional: the chat.agent transport opens the SSE on // `chatId` (externalId) before the agent has booted and upserted the // Session row. The S2 stream is keyed on the row's *canonical* identity // (externalId if set, else friendlyId) so two callers addressing the // same row via different URL forms converge on the same stream. We // short-circuit to 404 only for opaque `session_*` friendlyIds (those // must come from a real mint). const loader = createLoaderApiRoute( { params: ParamsSchema, allowJWT: true, corsStrategy: "all", findResource: async (params, auth) => { const row = await resolveSessionWithWriterFallback(auth.environment.id, params.session); if (!row && isSessionFriendlyIdForm(params.session)) { return undefined; // 404 — opaque friendlyId must reference a real row } // Non-null wrapper so missing row doesn't 404 for externalId form. return { row, addressingKey: canonicalSessionAddressingKey(row, params.session), }; }, authorization: { action: "read", // Multi-key: the channel is addressable by the URL key, the row's // friendlyId, and (if set) externalId. Type-level `read:sessions` // matches any of them; `read:all` / `admin` bypass via the JWT // ability's wildcard branches. resource: ({ row, addressingKey }) => { const ids = new Set([addressingKey]); if (row) { ids.add(row.friendlyId); if (row.externalId) ids.add(row.externalId); } return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, }, }, async ({ params, request, authentication, resource }) => { // Same no-row fallback as PUT above. 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, }); } if (request.method === "HEAD") { // No last-chunk-index on the S2 backend (clients resume via Last-Event-ID // on the SSE stream directly). Return 200 with a zero index for // compatibility with the run-stream shape. return new Response(null, { status: 200, headers: { "X-Last-Chunk-Index": "0" }, }); } const lastEventId = request.headers.get("Last-Event-ID") ?? undefined; const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds"); let timeoutInSeconds: number | undefined; if (timeoutInSecondsRaw) { // `Number()` rejects `"10abc"` as NaN; `parseInt` would silently accept // the trailing garbage and bypass the bounds checks below. const parsed = Number(timeoutInSecondsRaw); if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) { return new Response("Invalid timeout seconds", { status: 400 }); } if (parsed < 1) { return new Response("Timeout seconds must be greater than 0", { status: 400 }); } if (parsed > 600) { return new Response("Timeout seconds must be less than 600", { status: 400 }); } timeoutInSeconds = parsed; } // Opt-in: only consider the settled-peek shortcut when the client // asks for it via `X-Peek-Settled: 1`. Reconnect-on-reload paths // (`TriggerChatTransport.reconnectToStream`) set this; the active // send-a-message path (`sendMessages → subscribeToSessionStream`) // does not — otherwise the peek races with the newly-triggered // turn's first chunk and the SSE closes before records land. const peekSettled = request.headers.get("X-Peek-Settled") === "1"; return realtimeStream.streamResponseFromSessionStream( request, resource.addressingKey, params.io, getRequestAbortSignal(), { lastEventId, timeoutInSeconds, peekSettled } ); } ); export { action, loader };