208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
import { json } from "@remix-run/server-runtime";
|
|
import { tryCatch } from "@trigger.dev/core/utils";
|
|
import { z } from "zod";
|
|
import { $replica } from "~/db.server";
|
|
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
|
import {
|
|
getInputStreamWaitpoint,
|
|
deleteInputStreamWaitpoint,
|
|
} from "~/services/inputStreamWaitpointCache.server";
|
|
import {
|
|
anyResource,
|
|
createActionApiRoute,
|
|
createLoaderApiRoute,
|
|
} from "~/services/routeBuilders/apiBuilder.server";
|
|
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
|
import { engine } from "~/v3/runEngine.server";
|
|
import { ServiceValidationError } from "~/v3/services/common.server";
|
|
import { runStore } from "~/v3/runStore.server";
|
|
|
|
const ParamsSchema = z.object({
|
|
runId: z.string(),
|
|
streamId: z.string(),
|
|
});
|
|
|
|
const BodySchema = z.object({
|
|
data: z.unknown(),
|
|
});
|
|
|
|
// POST: Send data to an input stream
|
|
const { action } = createActionApiRoute(
|
|
{
|
|
params: ParamsSchema,
|
|
maxContentLength: 1024 * 1024, // 1MB max
|
|
allowJWT: true,
|
|
corsStrategy: "all",
|
|
authorization: {
|
|
action: "write",
|
|
resource: (params) => ({ type: "inputStreams", id: params.runId }),
|
|
},
|
|
},
|
|
async ({ request, params, authentication }) => {
|
|
const run = await runStore.findRun(
|
|
{
|
|
friendlyId: params.runId,
|
|
runtimeEnvironmentId: authentication.environment.id,
|
|
},
|
|
{
|
|
select: {
|
|
id: true,
|
|
friendlyId: true,
|
|
completedAt: true,
|
|
realtimeStreamsVersion: true,
|
|
streamBasinName: true,
|
|
},
|
|
},
|
|
$replica
|
|
);
|
|
|
|
if (!run) {
|
|
return json({ ok: false, error: "Run not found" }, { status: 404 });
|
|
}
|
|
|
|
if (run.completedAt) {
|
|
return json(
|
|
{ ok: false, error: "Cannot send to input stream on a completed run" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const body = BodySchema.safeParse(await request.json());
|
|
|
|
if (!body.success) {
|
|
return json({ ok: false, error: "Invalid request body" }, { status: 400 });
|
|
}
|
|
|
|
const realtimeStream = getRealtimeStreamInstance(
|
|
authentication.environment,
|
|
run.realtimeStreamsVersion,
|
|
{ run }
|
|
);
|
|
|
|
// Build the input stream record (raw user data, no wrapper)
|
|
const recordId = `inp_${crypto.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
const record = JSON.stringify(body.data.data);
|
|
|
|
// Append the record to the per-stream S2 stream (auto-creates on
|
|
// first write). `appendPart` can throw `S2RecordTooLargeError` (a
|
|
// `ServiceValidationError` with status 413) when the wrapped
|
|
// record exceeds S2's per-record ceiling — surface that as 413
|
|
// rather than letting it propagate to the apiBuilder catch-all
|
|
// as a generic 500.
|
|
const [appendError] = await tryCatch(
|
|
realtimeStream.appendPart(
|
|
record,
|
|
recordId,
|
|
run.friendlyId,
|
|
`$trigger.input:${params.streamId}`
|
|
)
|
|
);
|
|
if (appendError) {
|
|
if (appendError instanceof ServiceValidationError) {
|
|
return json(
|
|
{ ok: false, error: appendError.message },
|
|
{ status: appendError.status ?? 422 }
|
|
);
|
|
}
|
|
throw appendError;
|
|
}
|
|
|
|
// Check Redis cache for a linked .wait() waitpoint (fast, no DB hit if none)
|
|
// Get first, complete, then delete — so the mapping survives if completeWaitpoint throws
|
|
const waitpointId = await getInputStreamWaitpoint(params.runId, params.streamId);
|
|
if (waitpointId) {
|
|
await engine.completeWaitpoint({
|
|
id: waitpointId,
|
|
output: {
|
|
value: JSON.stringify(body.data.data),
|
|
type: "application/json",
|
|
isError: false,
|
|
},
|
|
});
|
|
await deleteInputStreamWaitpoint(params.runId, params.streamId);
|
|
}
|
|
|
|
return json({ ok: true });
|
|
}
|
|
);
|
|
|
|
// GET: SSE stream for reading input stream data (used by the in-task SSE tail)
|
|
const loader = createLoaderApiRoute(
|
|
{
|
|
params: ParamsSchema,
|
|
allowJWT: true,
|
|
corsStrategy: "all",
|
|
findResource: async (params, auth) => {
|
|
return runStore.findRun(
|
|
{
|
|
friendlyId: params.runId,
|
|
runtimeEnvironmentId: auth.environment.id,
|
|
},
|
|
{
|
|
include: {
|
|
batch: {
|
|
select: {
|
|
friendlyId: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
$replica
|
|
);
|
|
},
|
|
authorization: {
|
|
action: "read",
|
|
resource: (run) => {
|
|
const resources = [
|
|
{ type: "runs", id: run.friendlyId },
|
|
{ type: "tasks", id: run.taskIdentifier },
|
|
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
|
];
|
|
if (run.batch?.friendlyId) {
|
|
resources.push({ type: "batch", id: run.batch.friendlyId });
|
|
}
|
|
return anyResource(resources);
|
|
},
|
|
},
|
|
},
|
|
async ({ params, request, resource: run, authentication }) => {
|
|
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
|
|
|
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
|
|
const timeoutInSeconds =
|
|
timeoutInSecondsRaw !== undefined ? parseInt(timeoutInSecondsRaw, 10) : undefined;
|
|
|
|
if (timeoutInSeconds !== undefined && isNaN(timeoutInSeconds)) {
|
|
return new Response("Invalid timeout seconds", { status: 400 });
|
|
}
|
|
|
|
if (timeoutInSeconds !== undefined && timeoutInSeconds < 1) {
|
|
return new Response("Timeout seconds must be greater than 0", { status: 400 });
|
|
}
|
|
|
|
if (timeoutInSeconds !== undefined && timeoutInSeconds > 600) {
|
|
return new Response("Timeout seconds must be less than 600", { status: 400 });
|
|
}
|
|
|
|
const realtimeStream = getRealtimeStreamInstance(
|
|
authentication.environment,
|
|
run.realtimeStreamsVersion,
|
|
{ run }
|
|
);
|
|
|
|
// Read from the internal S2 stream name (prefixed to avoid user stream collisions)
|
|
return realtimeStream.streamResponse(
|
|
request,
|
|
run.friendlyId,
|
|
`$trigger.input:${params.streamId}`,
|
|
getRequestAbortSignal(),
|
|
{
|
|
lastEventId,
|
|
timeoutInSeconds,
|
|
}
|
|
);
|
|
}
|
|
);
|
|
|
|
export { action, loader };
|