Files
triggerdotdev--trigger.dev/apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts
T
2026-07-13 13:32:57 +08:00

212 lines
7.7 KiB
TypeScript

import { type PrismaClient, prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { singleton } from "~/utils/singleton";
import type { SendFunction } from "~/utils/sse";
import { ABORT_REASON_SEND_ERROR, createSSELoader } from "~/utils/sse";
import { throttle } from "~/utils/throttle";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
import { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
import { runStore } from "~/v3/runStore.server";
import { tracePubSub } from "~/v3/services/tracePubSub.server";
const PING_INTERVAL = 5_000;
const STREAM_TIMEOUT = 30_000;
export class RunStreamPresenter {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public createLoader() {
const prismaClient = this.#prismaClient;
return createSSELoader({
timeout: STREAM_TIMEOUT,
interval: PING_INTERVAL,
handler: async (context) => {
const runFriendlyId = context.params.runParam;
if (!runFriendlyId) {
throw new Response("Missing runParam", { status: 400 });
}
const userId = await requireUserId(context.request);
// Scope the lookup to organizations the requesting user is a member
// of, matching RunPresenter's run lookup. Unauthorized and missing
// runs are indistinguishable (both 404).
// Run-ops read by friendlyId only (routes to the owning DB); the project/org
// membership auth is a control-plane concern resolved separately below — joining
// it here is a cross-DB join that returns nothing once the run lives in run-ops.
const run = await runStore.findRun(
{
friendlyId: runFriendlyId,
},
{
select: {
traceId: true,
projectId: true,
},
}
);
// Authorize on the control-plane DB, keyed by the run's project. A non-member
// (or unresolvable project) is treated as no-access: traceId stays null.
let authorized = false;
if (run) {
const authorizedProject = await prismaClient.project.findFirst({
where: {
id: run.projectId,
organization: { members: { some: { userId } } },
},
select: { id: true },
});
authorized = authorizedProject !== null;
}
// Fall back to the mollifier buffer when the run isn't in PG yet.
// The buffered run has no execution events to stream, but we still
// attach a trace-pubsub subscription using the snapshot's traceId
// so that the moment the drainer materialises the row and execution
// begins, those events flow to this open SSE connection. Closing
// with 404 would force the dashboard to keep retrying.
let traceId: string | null = run && authorized ? run.traceId : null;
if (!traceId) {
const buffer = getMollifierBuffer();
if (buffer) {
try {
const entry = await buffer.getEntry(runFriendlyId);
// Same membership scoping as the PG lookup above — the buffer
// entry carries the owning org's id.
const isMember = entry
? (await prismaClient.orgMember.findFirst({
where: { organizationId: entry.orgId, userId },
select: { id: true },
})) !== null
: false;
if (entry && isMember) {
// Go through the webapp wrapper so this read-side module
// shares a single deserialisation path with readFallback —
// see the contract comment in syntheticRedirectInfo.server.ts.
const snapshot = deserialiseMollifierSnapshot(entry.payload);
if (typeof snapshot.traceId === "string") {
traceId = snapshot.traceId;
}
}
} catch (err) {
logger.warn("RunStreamPresenter buffer fallback failed", {
runFriendlyId,
err: err instanceof Error ? err.message : String(err),
});
}
}
}
if (!traceId) {
throw new Response("Not found", { status: 404 });
}
const resolvedRun = { traceId };
logger.info("RunStreamPresenter.start", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(
resolvedRun.traceId
);
// Only send max every 1 second
const throttledSend = throttle(
(args: { send: SendFunction; event?: string; data: string }) => {
try {
args.send({ event: args.event, data: args.data });
} catch (error) {
if (error instanceof Error) {
if (error.name !== "TypeError") {
logger.debug("Error sending SSE in RunStreamPresenter", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
}
}
// Abort the stream on send error. Uses a stackless string sentinel
// from sse.ts — a no-arg abort() would create a DOMException with a
// stack trace, which is unnecessary retention on the signal.reason.
context.controller.abort(ABORT_REASON_SEND_ERROR);
}
},
1000
);
let messageListener: ((event: string) => void) | undefined;
return {
initStream: ({ send }) => {
throttledSend({ send, event: "message", data: new Date().toISOString() });
messageListener = (event: string) => {
throttledSend({ send, event: "message", data: event });
};
eventEmitter.addListener("message", messageListener);
context.debug("Subscribed to trace pub/sub");
},
iterator: ({ send }) => {
try {
// Send an actual message so the client refreshes
throttledSend({ send, event: "message", data: new Date().toISOString() });
} catch (_error) {
// If we can't send a ping, the connection is likely dead
return false;
}
},
cleanup: () => {
logger.info("RunStreamPresenter.cleanup", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
if (messageListener) {
eventEmitter.removeListener("message", messageListener);
}
eventEmitter.removeAllListeners();
unsubscribe()
.then(() => {
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
runFriendlyId,
traceId: resolvedRun.traceId,
});
})
.catch((error) => {
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
runFriendlyId,
traceId: resolvedRun.traceId,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
});
},
};
},
});
}
}
export const runStreamLoader = singleton("runStreamLoader", () => {
const presenter = new RunStreamPresenter();
return presenter.createLoader();
});