189 lines
7.1 KiB
TypeScript
189 lines
7.1 KiB
TypeScript
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<string>([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 };
|