feat(realtime): Realtime streams v2 (#2632)
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": minor
|
||||
"@trigger.dev/react-hooks": minor
|
||||
---
|
||||
|
||||
Realtime streams v2
|
||||
@@ -0,0 +1,30 @@
|
||||
export function ListBulletIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9 5H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 12H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 19H20"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="4" cy="5" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="4" cy="19" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export function MoveToBottomIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 15L12 3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3 21L21 21"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12.5L12 17L16.5 12.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function SnakedArrowIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M5 5H16C17.6569 5 19 6.34315 19 8L19 8.5C19 10.1569 17.6569 11.5 16 11.5H8C6.34314 11.5 5 12.8431 5 14.5L5 15C4.99999 16.6569 6.34314 18 8 18H18.634"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M16 21L19 18L16 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function StreamsIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 19C3 19 5.01155 17 8 17C10.9885 17 13 18.9973 16 18.9973C19 18.9973 21 17 21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 13.0001C3 13.0001 5.01155 11 8 11C10.9885 11 13 13 16 13C19 13 21 11.0001 21 11.0001" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<path d="M3 7C3 7 5.01155 5 8 5C10.9885 5 13 6.9973 16 6.9973C19 6.9973 21 5 21 5" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { TriggerIcon } from "~/assets/icons/TriggerIcon";
|
||||
import { PythonLogoIcon } from "~/assets/icons/PythonLogoIcon";
|
||||
import { TraceIcon } from "~/assets/icons/TraceIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { StreamsIcon } from "~/assets/icons/StreamsIcon";
|
||||
|
||||
type TaskIconProps = {
|
||||
name: string | undefined;
|
||||
@@ -107,6 +108,8 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
|
||||
case "task-hook-onFailure":
|
||||
case "task-hook-catchError":
|
||||
return <FunctionIcon className={cn(className, "text-error")} />;
|
||||
case "streams":
|
||||
return <StreamsIcon className={cn(className, "text-text-dimmed")} />;
|
||||
}
|
||||
|
||||
return <InformationCircleIcon className={cn(className, "text-text-dimmed")} />;
|
||||
|
||||
@@ -219,6 +219,7 @@ const EnvironmentSchema = z
|
||||
.string()
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
REALTIME_STREAMS_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS: z.coerce.number().int().default(60000), // 1 minute
|
||||
|
||||
REALTIME_MAXIMUM_CREATED_AT_FILTER_AGE_IN_MS: z.coerce
|
||||
.number()
|
||||
@@ -1222,6 +1223,16 @@ const EnvironmentSchema = z
|
||||
EVENT_LOOP_MONITOR_UTILIZATION_SAMPLE_RATE: z.coerce.number().default(0.05),
|
||||
|
||||
VERY_SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().optional(),
|
||||
|
||||
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
|
||||
REALTIME_STREAMS_S2_LOG_LEVEL: z
|
||||
.enum(["log", "error", "warn", "info", "debug"])
|
||||
.default("info"),
|
||||
REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS: z.coerce.number().int().default(100),
|
||||
REALTIME_STREAMS_S2_MAX_RETRIES: z.coerce.number().int().default(10),
|
||||
REALTIME_STREAMS_S2_WAIT_SECONDS: z.coerce.number().int().default(60),
|
||||
WAIT_UNTIL_TIMEOUT_MS: z.coerce.number().int().default(600_000),
|
||||
})
|
||||
.and(GithubAppEnvSchema)
|
||||
.and(S2EnvSchema);
|
||||
|
||||
@@ -66,7 +66,7 @@ export async function createOrganization(
|
||||
role: "ADMIN",
|
||||
},
|
||||
},
|
||||
v3Enabled: !features.isManagedCloud,
|
||||
v3Enabled: true,
|
||||
},
|
||||
include: {
|
||||
members: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { WaitpointPresenter } from "./WaitpointPresenter.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { IEventRepository, SpanDetail } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
|
||||
type Result = Awaited<ReturnType<SpanPresenter["call"]>>;
|
||||
export type Span = NonNullable<NonNullable<Result>["span"]>;
|
||||
@@ -551,6 +552,41 @@ export class SpanPresenter extends BasePresenter {
|
||||
},
|
||||
};
|
||||
}
|
||||
case "realtime-stream": {
|
||||
if (!span.entity.id) {
|
||||
logger.error(`SpanPresenter: No realtime stream id`, {
|
||||
spanId,
|
||||
realtimeStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const [runId, streamKey] = span.entity.id.split(":");
|
||||
|
||||
if (!runId || !streamKey) {
|
||||
logger.error(`SpanPresenter: Invalid realtime stream id`, {
|
||||
spanId,
|
||||
realtimeStreamId: span.entity.id,
|
||||
});
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
const metadata = span.entity.metadata
|
||||
? (safeJsonParse(span.entity.metadata) as Record<string, unknown> | undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...data,
|
||||
entity: {
|
||||
type: "realtime-stream" as const,
|
||||
object: {
|
||||
runId,
|
||||
streamKey,
|
||||
metadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...data, entity: null };
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export const HeadersSchema = z.object({
|
||||
"x-trigger-client": z.string().nullish(),
|
||||
"x-trigger-engine-version": RunEngineVersionSchema.nullish(),
|
||||
"x-trigger-request-idempotency-key": z.string().nullish(),
|
||||
"x-trigger-realtime-streams-version": z.string().nullish(),
|
||||
traceparent: z.string().optional(),
|
||||
tracestate: z.string().optional(),
|
||||
});
|
||||
@@ -63,6 +64,7 @@ const { action, loader } = createActionApiRoute(
|
||||
"x-trigger-client": triggerClient,
|
||||
"x-trigger-engine-version": engineVersion,
|
||||
"x-trigger-request-idempotency-key": requestIdempotencyKey,
|
||||
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
|
||||
} = headers;
|
||||
|
||||
const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, {
|
||||
@@ -108,14 +110,7 @@ const { action, loader } = createActionApiRoute(
|
||||
options: body.options,
|
||||
isFromWorker,
|
||||
traceContext,
|
||||
});
|
||||
|
||||
logger.debug("[otelContext]", {
|
||||
taskId: params.taskId,
|
||||
headers,
|
||||
options: body.options,
|
||||
isFromWorker,
|
||||
traceContext,
|
||||
realtimeStreamsVersion,
|
||||
});
|
||||
|
||||
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
|
||||
@@ -131,6 +126,7 @@ const { action, loader } = createActionApiRoute(
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
oneTimeUseToken,
|
||||
realtimeStreamsVersion: realtimeStreamsVersion ?? undefined,
|
||||
},
|
||||
engineVersion ?? undefined
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { relayRealtimeStreams } from "~/services/realtime/relayRealtimeStreams.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -9,16 +8,6 @@ const ParamsSchema = z.object({
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const $params = ParamsSchema.parse(params);
|
||||
|
||||
if (!request.body) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
return relayRealtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
|
||||
}
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
@@ -51,12 +40,32 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
return relayRealtimeStreams.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
params.streamId,
|
||||
// Get Last-Event-ID header for resuming from a specific position
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds") ?? undefined;
|
||||
const timeoutInSeconds = timeoutInSecondsRaw ? parseInt(timeoutInSecondsRaw) : undefined;
|
||||
|
||||
if (timeoutInSeconds && isNaN(timeoutInSeconds)) {
|
||||
return new Response("Invalid timeout seconds", { status: 400 });
|
||||
}
|
||||
|
||||
if (timeoutInSeconds && timeoutInSeconds < 1) {
|
||||
return new Response("Timeout seconds must be greater than 0", { status: 400 });
|
||||
}
|
||||
|
||||
if (timeoutInSeconds && timeoutInSeconds > 600) {
|
||||
return new Response("Timeout seconds must be less than 600", { status: 400 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
request.signal
|
||||
run.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
return realtimeStream.streamResponse(request, run.friendlyId, params.streamId, request.signal, {
|
||||
lastEventId,
|
||||
timeoutInSeconds,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
target: z.enum(["self", "parent", "root"]),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
const { action } = createActionApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const targetId =
|
||||
params.target === "self"
|
||||
? run.friendlyId
|
||||
: params.target === "parent"
|
||||
? run.parentTaskRun?.friendlyId
|
||||
: run.rootTaskRun?.friendlyId;
|
||||
|
||||
if (!targetId) {
|
||||
return new Response("Target not found", { status: 404 });
|
||||
}
|
||||
|
||||
const targetRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: targetId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
realtimeStreams: true,
|
||||
realtimeStreamsVersion: true,
|
||||
completedAt: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetRun) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
if (targetRun.completedAt) {
|
||||
return new Response("Cannot append to a realtime stream on a completed run", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (!targetRun.realtimeStreams.includes(params.streamId)) {
|
||||
await prisma.taskRun.update({
|
||||
where: {
|
||||
id: targetRun.id,
|
||||
},
|
||||
data: {
|
||||
realtimeStreams: {
|
||||
push: params.streamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const part = await request.text();
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
targetRun.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
const partId = request.headers.get("X-Part-Id") ?? nanoid(7);
|
||||
|
||||
const [appendError] = await tryCatch(
|
||||
realtimeStream.appendPart(part, partId, targetId, params.streamId)
|
||||
);
|
||||
|
||||
if (appendError) {
|
||||
if (appendError instanceof ServiceValidationError) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: appendError.message,
|
||||
},
|
||||
{ status: appendError.status ?? 422 }
|
||||
);
|
||||
} else {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: appendError.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return json(
|
||||
{
|
||||
ok: true,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
@@ -1,7 +1,11 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { relayRealtimeStreams } from "~/services/realtime/relayRealtimeStreams.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -14,10 +18,6 @@ const { action } = createActionApiRoute(
|
||||
params: ParamsSchema,
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
if (!request.body) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
@@ -54,8 +54,145 @@ const { action } = createActionApiRoute(
|
||||
return new Response("Target not found", { status: 404 });
|
||||
}
|
||||
|
||||
return relayRealtimeStreams.ingestData(request.body, targetId, params.streamId);
|
||||
if (request.method === "PUT") {
|
||||
// This is the "create" endpoint
|
||||
const updatedRun = await prisma.taskRun.update({
|
||||
where: {
|
||||
friendlyId: targetId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
data: {
|
||||
realtimeStreams: {
|
||||
push: params.streamId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
realtimeStreamsVersion: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedRun.completedAt) {
|
||||
return new Response("Cannot initialize a realtime stream on a completed run", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
authentication.environment,
|
||||
updatedRun.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
const { responseHeaders } = await realtimeStream.initializeStream(targetId, params.streamId);
|
||||
|
||||
return json(
|
||||
{
|
||||
version: updatedRun.realtimeStreamsVersion,
|
||||
},
|
||||
{ status: 202, headers: responseHeaders }
|
||||
);
|
||||
} else {
|
||||
// Extract client ID from header, default to "default" if not provided
|
||||
const clientId = request.headers.get("X-Client-Id") || "default";
|
||||
const streamVersion = request.headers.get("X-Stream-Version") || "v1";
|
||||
|
||||
if (!request.body) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
const resumeFromChunk = request.headers.get("X-Resume-From-Chunk");
|
||||
let resumeFromChunkNumber: number | undefined = undefined;
|
||||
if (resumeFromChunk) {
|
||||
const parsed = parseInt(resumeFromChunk, 10);
|
||||
if (isNaN(parsed) || parsed < 0) {
|
||||
return new Response(`Invalid X-Resume-From-Chunk header value: ${resumeFromChunk}`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
resumeFromChunkNumber = parsed;
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(authentication.environment, streamVersion);
|
||||
|
||||
return realtimeStream.ingestData(
|
||||
request.body,
|
||||
targetId,
|
||||
params.streamId,
|
||||
clientId,
|
||||
resumeFromChunkNumber
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: false,
|
||||
corsStrategy: "none",
|
||||
findResource: async (params, authentication) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
parentTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
rootTaskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
async ({ request, params, resource: run, authentication }) => {
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const targetId =
|
||||
params.target === "self"
|
||||
? run.friendlyId
|
||||
: params.target === "parent"
|
||||
? run.parentTaskRun?.friendlyId
|
||||
: run.rootTaskRun?.friendlyId;
|
||||
|
||||
if (!targetId) {
|
||||
return new Response("Target not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Handle HEAD request to get last chunk index
|
||||
if (request.method !== "HEAD") {
|
||||
return new Response("Only HEAD requests are allowed for this endpoint", { status: 405 });
|
||||
}
|
||||
|
||||
// Extract client ID from header, default to "default" if not provided
|
||||
const clientId = request.headers.get("X-Client-Id") || "default";
|
||||
const streamVersion = request.headers.get("X-Stream-Version") || "v1";
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(authentication.environment, streamVersion);
|
||||
|
||||
const lastChunkIndex = await realtimeStream.getLastChunkIndex(
|
||||
targetId,
|
||||
params.streamId,
|
||||
clientId
|
||||
);
|
||||
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Last-Chunk-Index": lastChunkIndex.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export { action, loader };
|
||||
|
||||
+27
-21
@@ -80,6 +80,7 @@ import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEven
|
||||
import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.$waitpointFriendlyId.complete/route";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -213,8 +214,8 @@ function SpanBody({
|
||||
span = applySpanOverrides(span, spanOverrides);
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-bright px-3 pr-2">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon
|
||||
name={span.style?.icon}
|
||||
@@ -228,26 +229,14 @@ function SpanBody({
|
||||
{runParam && closePanel && (
|
||||
<Button
|
||||
onClick={closePanel}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={!tab || tab === "overview"}
|
||||
layoutId="span-span"
|
||||
onClick={() => {
|
||||
replace({ tab: "overview" });
|
||||
}}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</div>
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<SpanEntity span={span} />
|
||||
</div>
|
||||
@@ -307,7 +296,7 @@ function RunBody({
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3 pr-2">
|
||||
<div className="flex items-center gap-1 overflow-x-hidden">
|
||||
<RunIcon
|
||||
name={run.isCached ? "task-cached" : "task"}
|
||||
@@ -324,9 +313,11 @@ function RunBody({
|
||||
{runParam && closePanel && (
|
||||
<Button
|
||||
onClick={closePanel}
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ExitIcon}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1075,6 +1066,9 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
code={span.properties}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
showTextWrapping
|
||||
showOpenInModal
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1120,6 +1114,9 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
code={span.properties}
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showCopyButton
|
||||
showTextWrapping
|
||||
showOpenInModal
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1146,6 +1143,15 @@ function SpanEntity({ span }: { span: Span }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "realtime-stream": {
|
||||
return (
|
||||
<RealtimeStreamViewer
|
||||
runId={span.entity.object.runId}
|
||||
streamKey={span.entity.object.streamKey}
|
||||
metadata={span.entity.object.metadata}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
assertNever(span.entity);
|
||||
}
|
||||
|
||||
+542
@@ -0,0 +1,542 @@
|
||||
import { BoltIcon, BoltSlashIcon } from "@heroicons/react/20/solid";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type SSEStreamPart, SSEStreamSubscription } from "@trigger.dev/core/v3";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import simplur from "simplur";
|
||||
import { ListBulletIcon } from "~/assets/icons/ListBulletIcon";
|
||||
import { MoveToBottomIcon } from "~/assets/icons/MoveToBottomIcon";
|
||||
import { MoveToTopIcon } from "~/assets/icons/MoveToTopIcon";
|
||||
import { SnakedArrowIcon } from "~/assets/icons/SnakedArrowIcon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3RunStreamParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
type ViewMode = "list" | "compact";
|
||||
|
||||
type StreamChunk = {
|
||||
id: string;
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, runParam, streamKey } =
|
||||
v3RunStreamParamsSchema.parse(params);
|
||||
|
||||
const project = await $replica.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
projectId: project.id,
|
||||
},
|
||||
include: {
|
||||
runtimeEnvironment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
if (run.runtimeEnvironment.slug !== envParam) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get Last-Event-ID header for resuming from a specific position
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(
|
||||
run.runtimeEnvironment,
|
||||
run.realtimeStreamsVersion
|
||||
);
|
||||
|
||||
return realtimeStream.streamResponse(request, run.friendlyId, streamKey, request.signal, {
|
||||
lastEventId,
|
||||
});
|
||||
};
|
||||
|
||||
export function RealtimeStreamViewer({
|
||||
runId,
|
||||
streamKey,
|
||||
metadata,
|
||||
}: {
|
||||
runId: string;
|
||||
streamKey: string;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
|
||||
|
||||
const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined;
|
||||
const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const getCompactText = useCallback(() => {
|
||||
return chunks
|
||||
.map((chunk) => {
|
||||
if (typeof chunk.data === "string") {
|
||||
return chunk.data;
|
||||
}
|
||||
return JSON.stringify(chunk.data);
|
||||
})
|
||||
.join("");
|
||||
}, [chunks]);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(getCompactText());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[getCompactText]
|
||||
);
|
||||
|
||||
// Use IntersectionObserver to detect when the bottom element is visible
|
||||
useEffect(() => {
|
||||
const bottomElement = bottomRef.current;
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!bottomElement || !scrollElement) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) {
|
||||
setIsAtBottom(entry.isIntersecting);
|
||||
}
|
||||
},
|
||||
{
|
||||
root: scrollElement,
|
||||
threshold: 0.1,
|
||||
rootMargin: "0px",
|
||||
}
|
||||
);
|
||||
|
||||
observer.observe(bottomElement);
|
||||
|
||||
// Also add a scroll listener as a backup to ensure state updates
|
||||
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
const handleScroll = () => {
|
||||
if (!scrollElement || !bottomElement) return;
|
||||
|
||||
// Clear any existing timeout
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
|
||||
// Debounce the state update to avoid interrupting smooth scroll
|
||||
scrollTimeout = setTimeout(() => {
|
||||
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
|
||||
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
|
||||
setIsAtBottom(isNearBottom);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
// Check initial state
|
||||
const scrollBottom = scrollElement.scrollTop + scrollElement.clientHeight;
|
||||
const isNearBottom = scrollElement.scrollHeight - scrollBottom < 50;
|
||||
setIsAtBottom(isNearBottom);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
scrollElement.removeEventListener("scroll", handleScroll);
|
||||
if (scrollTimeout) {
|
||||
clearTimeout(scrollTimeout);
|
||||
}
|
||||
};
|
||||
}, [chunks.length, viewMode]);
|
||||
|
||||
// Auto-scroll to bottom when new chunks arrive, if we're at the bottom
|
||||
useEffect(() => {
|
||||
if (isAtBottom && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "instant", block: "end" });
|
||||
}
|
||||
}, [chunks, isAtBottom]);
|
||||
|
||||
const firstLineNumber = startIndex ?? 0;
|
||||
const lastLineNumber = firstLineNumber + chunks.length - 1;
|
||||
const maxLineNumberWidth = (chunks.length > 0 ? lastLineNumber : firstLineNumber).toString()
|
||||
.length;
|
||||
|
||||
// Virtual rendering for list view
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: chunks.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 28,
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-grid-bright bg-background-bright @container">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 @[300px]:flex-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{isConnected ? (
|
||||
<BoltIcon className={cn("size-3.5 animate-pulse text-success")} />
|
||||
) : (
|
||||
<BoltSlashIcon className={cn("size-3.5 text-text-dimmed")} />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{isConnected ? "Connected" : "Disconnected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Paragraph
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>Stream:</span>
|
||||
<span className="truncate font-mono text-text-dimmed">{streamKey}</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
|
||||
<Paragraph variant="small" className="mb-0 whitespace-nowrap">
|
||||
{simplur`${chunks.length} chunk[|s]`}
|
||||
</Paragraph>
|
||||
<div className="flex items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip open={chunks.length === 0 ? false : undefined} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
disabled={chunks.length === 0}
|
||||
onClick={() => setViewMode(viewMode === "list" ? "compact" : "list")}
|
||||
className={cn(
|
||||
"text-text-dimmed transition-colors focus-custom",
|
||||
chunks.length === 0
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:cursor-pointer hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{viewMode === "list" ? (
|
||||
<SnakedArrowIcon className="size-4" />
|
||||
) : (
|
||||
<ListBulletIcon className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{viewMode === "list" ? "Flow as text" : "View as list"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip
|
||||
open={chunks.length === 0 ? false : copied || mouseOver || undefined}
|
||||
disableHoverableContent
|
||||
>
|
||||
<TooltipTrigger
|
||||
disabled={chunks.length === 0}
|
||||
onClick={onCopied}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom",
|
||||
chunks.length === 0
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: copied
|
||||
? "text-success hover:cursor-pointer"
|
||||
: "text-text-dimmed hover:cursor-pointer hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-4" />
|
||||
) : (
|
||||
<Clipboard className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip open={chunks.length === 0 ? false : undefined} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
disabled={chunks.length === 0}
|
||||
onClick={() => {
|
||||
if (isAtBottom) {
|
||||
scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||
} else {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"text-text-dimmed transition-colors focus-custom",
|
||||
chunks.length === 0
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: "hover:cursor-pointer hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{isAtBottom ? (
|
||||
<MoveToTopIcon className="size-4" />
|
||||
) : (
|
||||
<MoveToBottomIcon className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{isAtBottom ? "Scroll to top" : "Scroll to bottom"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto bg-charcoal-900 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
{error && (
|
||||
<div className="border-b border-error/20 bg-error/10 p-3">
|
||||
<Paragraph variant="small" className="mb-0 text-error">
|
||||
Error: {error.message}
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chunks.length === 0 && !error && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{isConnected ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small" className="mb-0 text-text-dimmed">
|
||||
Waiting for data…
|
||||
</Paragraph>
|
||||
</div>
|
||||
) : (
|
||||
<Paragraph variant="small" className="mb-0 text-text-dimmed">
|
||||
No data received
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chunks.length > 0 && viewMode === "list" && (
|
||||
<div className="font-mono text-xs leading-tight">
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualItem) => (
|
||||
<StreamChunkLine
|
||||
key={virtualItem.key}
|
||||
chunk={chunks[virtualItem.index]}
|
||||
lineNumber={firstLineNumber + virtualItem.index}
|
||||
maxLineNumberWidth={maxLineNumberWidth}
|
||||
size={virtualItem.size}
|
||||
start={virtualItem.start}
|
||||
/>
|
||||
))}
|
||||
{/* Sentinel element for IntersectionObserver */}
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className="h-px"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chunks.length > 0 && viewMode === "compact" && (
|
||||
<div className="p-3 font-mono text-xs leading-relaxed">
|
||||
<CompactStreamView chunks={chunks} />
|
||||
{/* Sentinel element for IntersectionObserver */}
|
||||
<div ref={bottomRef} className="h-px" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompactStreamView({ chunks }: { chunks: StreamChunk[] }) {
|
||||
const compactText = chunks
|
||||
.map((chunk) => {
|
||||
if (typeof chunk.data === "string") {
|
||||
return chunk.data;
|
||||
}
|
||||
return JSON.stringify(chunk.data);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return <div className="whitespace-pre-wrap break-all text-text-bright">{compactText}</div>;
|
||||
}
|
||||
|
||||
function StreamChunkLine({
|
||||
chunk,
|
||||
lineNumber,
|
||||
maxLineNumberWidth,
|
||||
size,
|
||||
start,
|
||||
}: {
|
||||
chunk: StreamChunk;
|
||||
lineNumber: number;
|
||||
maxLineNumberWidth: number;
|
||||
size: number;
|
||||
start: number;
|
||||
}) {
|
||||
const formattedData =
|
||||
typeof chunk.data === "string" ? chunk.data : JSON.stringify(chunk.data, null, 2);
|
||||
|
||||
const date = new Date(chunk.timestamp);
|
||||
const timeString = date.toLocaleTimeString("en-US", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
const milliseconds = date.getMilliseconds().toString().padStart(3, "0");
|
||||
const timestamp = `${timeString}.${milliseconds}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group flex w-full gap-3 py-1 hover:bg-charcoal-800"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${size}px`,
|
||||
transform: `translateY(${start}px)`,
|
||||
}}
|
||||
>
|
||||
{/* Line number */}
|
||||
<div
|
||||
className="flex-none select-none pl-2 text-right text-charcoal-500"
|
||||
style={{ width: `${Math.max(maxLineNumberWidth, 3)}ch` }}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex-none select-none text-charcoal-500">{timestamp}</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 break-all text-text-bright">{formattedData}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useRealtimeStream(resourcePath: string, startIndex?: number) {
|
||||
const [chunks, setChunks] = useState<StreamChunk[]>([]);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
let reader: ReadableStreamDefaultReader<SSEStreamPart<unknown>> | null = null;
|
||||
|
||||
async function connectAndConsume() {
|
||||
try {
|
||||
const sseSubscription = new SSEStreamSubscription(resourcePath, {
|
||||
signal: abortController.signal,
|
||||
lastEventId: startIndex ? (startIndex - 1).toString() : undefined,
|
||||
timeoutInSeconds: 30,
|
||||
});
|
||||
|
||||
const stream = await sseSubscription.subscribe();
|
||||
setIsConnected(true);
|
||||
|
||||
reader = stream.getReader();
|
||||
|
||||
// Read from the stream
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
setChunks((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: value.id,
|
||||
data: value.chunk,
|
||||
timestamp: value.timestamp,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Only set error if not aborted
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
} finally {
|
||||
setIsConnected(false);
|
||||
}
|
||||
}
|
||||
|
||||
connectAndConsume();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
reader?.cancel();
|
||||
};
|
||||
}, [resourcePath, startIndex]);
|
||||
|
||||
return { chunks, error, isConnected };
|
||||
}
|
||||
@@ -347,6 +347,7 @@ export class RunEngineTriggerTaskService {
|
||||
createdAt: options.overrideCreatedAt,
|
||||
bulkActionId: body.options?.bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
@@ -1,45 +1,90 @@
|
||||
import { Logger, LogLevel } from "@trigger.dev/core/logger";
|
||||
import Redis, { RedisOptions } from "ioredis";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { LineTransformStream } from "./utils.server";
|
||||
import { env } from "~/env.server";
|
||||
import { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
|
||||
|
||||
export type RealtimeStreamsOptions = {
|
||||
redis: RedisOptions | undefined;
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
inactivityTimeoutMs?: number; // Close stream after this many ms of no new data (default: 60000)
|
||||
};
|
||||
|
||||
// Legacy constant for backward compatibility (no longer written, but still recognized when reading)
|
||||
const END_SENTINEL = "<<CLOSE_STREAM>>";
|
||||
|
||||
// Internal types for stream pipeline
|
||||
type StreamChunk =
|
||||
| { type: "ping" }
|
||||
| { type: "data"; redisId: string; data: string }
|
||||
| { type: "legacy-data"; redisId: string; data: string };
|
||||
|
||||
// Class implementing both interfaces
|
||||
export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
constructor(private options: RealtimeStreamsOptions) {}
|
||||
private logger: Logger;
|
||||
private inactivityTimeoutMs: number;
|
||||
|
||||
constructor(private options: RealtimeStreamsOptions) {
|
||||
this.logger = options.logger ?? new Logger("RedisRealtimeStreams", options.logLevel ?? "info");
|
||||
this.inactivityTimeoutMs = options.inactivityTimeoutMs ?? 60000; // Default: 60 seconds
|
||||
}
|
||||
|
||||
async initializeStream(
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<{ responseHeaders?: Record<string, string> }> {
|
||||
return {};
|
||||
}
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
options?: StreamResponseOptions
|
||||
): Promise<Response> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
let isCleanedUp = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
const stream = new ReadableStream<StreamChunk>({
|
||||
start: async (controller) => {
|
||||
let lastId = "0";
|
||||
// Start from lastEventId if provided, otherwise from beginning
|
||||
let lastId = options?.lastEventId ?? "0";
|
||||
let retryCount = 0;
|
||||
const maxRetries = 3;
|
||||
let lastDataTime = Date.now();
|
||||
let lastEnqueueTime = Date.now();
|
||||
const blockTimeMs = 5000;
|
||||
const pingIntervalMs = 10000; // 10 seconds
|
||||
|
||||
if (options?.lastEventId) {
|
||||
this.logger.debug("[RealtimeStreams][streamResponse] Resuming from lastEventId", {
|
||||
streamKey,
|
||||
lastEventId: options?.lastEventId,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
while (!signal.aborted) {
|
||||
// Check if we need to send a ping
|
||||
const timeSinceLastEnqueue = Date.now() - lastEnqueueTime;
|
||||
if (timeSinceLastEnqueue >= pingIntervalMs) {
|
||||
controller.enqueue({ type: "ping" });
|
||||
lastEnqueueTime = Date.now();
|
||||
}
|
||||
|
||||
// Compute inactivity threshold once to use consistently in both branches
|
||||
const inactivityThresholdMs = options?.timeoutInSeconds
|
||||
? options.timeoutInSeconds * 1000
|
||||
: this.inactivityTimeoutMs;
|
||||
|
||||
try {
|
||||
const messages = await redis.xread(
|
||||
"COUNT",
|
||||
100,
|
||||
"BLOCK",
|
||||
5000,
|
||||
blockTimeMs,
|
||||
"STREAMS",
|
||||
streamKey,
|
||||
lastId
|
||||
@@ -49,41 +94,104 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
|
||||
if (messages && messages.length > 0) {
|
||||
const [_key, entries] = messages[0];
|
||||
let foundData = false;
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const [id, fields] = entries[i];
|
||||
lastId = id;
|
||||
|
||||
if (fields && fields.length >= 2) {
|
||||
if (fields[1] === END_SENTINEL && i === entries.length - 1) {
|
||||
controller.close();
|
||||
return;
|
||||
// Extract the data field from the Redis entry
|
||||
// Fields format: ["field1", "value1", "field2", "value2", ...]
|
||||
let data: string | null = null;
|
||||
|
||||
for (let j = 0; j < fields.length; j += 2) {
|
||||
if (fields[j] === "data") {
|
||||
data = fields[j + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (fields[1] !== END_SENTINEL) {
|
||||
controller.enqueue(fields[1]);
|
||||
// Handle legacy entries that don't have field names (just data at index 1)
|
||||
if (data === null && fields.length >= 2) {
|
||||
data = fields[1];
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.close();
|
||||
return;
|
||||
if (data) {
|
||||
// Skip legacy END_SENTINEL entries (backward compatibility)
|
||||
if (data === END_SENTINEL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enqueue structured chunk with Redis stream ID
|
||||
controller.enqueue({
|
||||
type: "data",
|
||||
redisId: id,
|
||||
data,
|
||||
});
|
||||
|
||||
foundData = true;
|
||||
lastDataTime = Date.now();
|
||||
lastEnqueueTime = Date.now();
|
||||
|
||||
if (signal.aborted) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find any data in this batch, might have only seen sentinels
|
||||
if (!foundData) {
|
||||
// Check for inactivity timeout
|
||||
const inactiveMs = Date.now() - lastDataTime;
|
||||
if (inactiveMs >= inactivityThresholdMs) {
|
||||
this.logger.debug(
|
||||
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
|
||||
{
|
||||
streamKey,
|
||||
inactiveMs,
|
||||
threshold: inactivityThresholdMs,
|
||||
}
|
||||
);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No messages received (timed out on BLOCK)
|
||||
// Check for inactivity timeout
|
||||
const inactiveMs = Date.now() - lastDataTime;
|
||||
if (inactiveMs >= inactivityThresholdMs) {
|
||||
this.logger.debug(
|
||||
"[RealtimeStreams][streamResponse] Closing stream due to inactivity",
|
||||
{
|
||||
streamKey,
|
||||
inactiveMs,
|
||||
threshold: inactivityThresholdMs,
|
||||
}
|
||||
);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
logger.error("[RealtimeStreams][streamResponse] Error reading from Redis stream:", {
|
||||
error,
|
||||
});
|
||||
this.logger.error(
|
||||
"[RealtimeStreams][streamResponse] Error reading from Redis stream:",
|
||||
{
|
||||
error,
|
||||
}
|
||||
);
|
||||
retryCount++;
|
||||
if (retryCount >= maxRetries) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
|
||||
this.logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
|
||||
error,
|
||||
});
|
||||
controller.error(error);
|
||||
@@ -95,12 +203,31 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
await cleanup();
|
||||
},
|
||||
})
|
||||
.pipeThrough(new LineTransformStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
// Transform 1: Split data content by newlines, preserving metadata
|
||||
new TransformStream<StreamChunk, StreamChunk & { line?: string }>({
|
||||
transform(chunk, controller) {
|
||||
for (const line of chunk) {
|
||||
controller.enqueue(`data: ${line}\n\n`);
|
||||
if (chunk.type === "ping") {
|
||||
controller.enqueue(chunk);
|
||||
} else if (chunk.type === "data" || chunk.type === "legacy-data") {
|
||||
// Split data by newlines, emit separate chunks with same metadata
|
||||
const lines = chunk.data.split("\n").filter((line) => line.trim().length > 0);
|
||||
for (const line of lines) {
|
||||
controller.enqueue({ ...chunk, line });
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(
|
||||
// Transform 2: Format as SSE
|
||||
new TransformStream<StreamChunk & { line?: string }, string>({
|
||||
transform(chunk, controller) {
|
||||
if (chunk.type === "ping") {
|
||||
controller.enqueue(`: ping\n\n`);
|
||||
} else if ((chunk.type === "data" || chunk.type === "legacy-data") && chunk.line) {
|
||||
// Use Redis stream ID as SSE event ID
|
||||
controller.enqueue(`id: ${chunk.redisId}\ndata: ${chunk.line}\n\n`);
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -127,16 +254,23 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
async ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
streamId: string,
|
||||
clientId: string,
|
||||
resumeFromChunk?: number
|
||||
): Promise<Response> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
const startChunk = resumeFromChunk ?? 0;
|
||||
// Start counting from the resume point, not from 0
|
||||
let currentChunkIndex = startChunk;
|
||||
|
||||
const self = this;
|
||||
|
||||
async function cleanup() {
|
||||
try {
|
||||
await redis.quit();
|
||||
} catch (error) {
|
||||
logger.error("[RedisRealtimeStreams][ingestData] Error in cleanup:", { error });
|
||||
self.logger.error("[RedisRealtimeStreams][ingestData] Error in cleanup:", { error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,9 +285,13 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.debug("[RedisRealtimeStreams][ingestData] Reading data", {
|
||||
// Write each chunk with its index and clientId
|
||||
this.logger.debug("[RedisRealtimeStreams][ingestData] Writing chunk", {
|
||||
streamKey,
|
||||
runId,
|
||||
clientId,
|
||||
chunkIndex: currentChunkIndex,
|
||||
resumeFromChunk: startChunk,
|
||||
value,
|
||||
});
|
||||
|
||||
@@ -163,41 +301,137 @@ export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
"~",
|
||||
String(env.REALTIME_STREAM_MAX_LENGTH),
|
||||
"*",
|
||||
"clientId",
|
||||
clientId,
|
||||
"chunkIndex",
|
||||
currentChunkIndex.toString(),
|
||||
"data",
|
||||
value
|
||||
);
|
||||
|
||||
currentChunkIndex++;
|
||||
}
|
||||
|
||||
// Send the END_SENTINEL and set TTL with a pipeline.
|
||||
const pipeline = redis.pipeline();
|
||||
pipeline.xadd(
|
||||
streamKey,
|
||||
"MAXLEN",
|
||||
"~",
|
||||
String(env.REALTIME_STREAM_MAX_LENGTH),
|
||||
"*",
|
||||
"data",
|
||||
END_SENTINEL
|
||||
);
|
||||
pipeline.expire(streamKey, env.REALTIME_STREAM_TTL);
|
||||
await pipeline.exec();
|
||||
// Set TTL for cleanup when stream is done
|
||||
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if ("code" in error && error.code === "ECONNRESET") {
|
||||
logger.info("[RealtimeStreams][ingestData] Connection reset during ingestData:", {
|
||||
this.logger.info("[RealtimeStreams][ingestData] Connection reset during ingestData:", {
|
||||
error,
|
||||
});
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
|
||||
this.logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
|
||||
|
||||
return new Response(null, { status: 500 });
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
|
||||
await redis.xadd(
|
||||
streamKey,
|
||||
"MAXLEN",
|
||||
"~",
|
||||
String(env.REALTIME_STREAM_MAX_LENGTH),
|
||||
"*",
|
||||
"clientId",
|
||||
"",
|
||||
"chunkIndex",
|
||||
"0",
|
||||
"data",
|
||||
part
|
||||
);
|
||||
|
||||
// Set TTL for cleanup when stream is done
|
||||
await redis.expire(streamKey, env.REALTIME_STREAM_TTL);
|
||||
|
||||
await redis.quit();
|
||||
}
|
||||
|
||||
async getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
|
||||
try {
|
||||
// Paginate through the stream from newest to oldest until we find this client's last chunk
|
||||
const batchSize = 100;
|
||||
let lastId = "+"; // Start from newest
|
||||
|
||||
while (true) {
|
||||
const entries = await redis.xrevrange(streamKey, lastId, "-", "COUNT", batchSize);
|
||||
|
||||
if (!entries || entries.length === 0) {
|
||||
// Reached the beginning of the stream, no chunks from this client
|
||||
this.logger.debug(
|
||||
"[RedisRealtimeStreams][getLastChunkIndex] No chunks found for client",
|
||||
{
|
||||
streamKey,
|
||||
clientId,
|
||||
}
|
||||
);
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Search through this batch for the client's last chunk
|
||||
for (const [id, fields] of entries) {
|
||||
let entryClientId: string | null = null;
|
||||
let chunkIndex: number | null = null;
|
||||
let data: string | null = null;
|
||||
|
||||
for (let i = 0; i < fields.length; i += 2) {
|
||||
if (fields[i] === "clientId") {
|
||||
entryClientId = fields[i + 1];
|
||||
}
|
||||
if (fields[i] === "chunkIndex") {
|
||||
chunkIndex = parseInt(fields[i + 1], 10);
|
||||
}
|
||||
if (fields[i] === "data") {
|
||||
data = fields[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skip legacy END_SENTINEL entries (backward compatibility)
|
||||
if (data === END_SENTINEL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this entry is from our client and has a chunkIndex
|
||||
if (entryClientId === clientId && chunkIndex !== null) {
|
||||
this.logger.debug("[RedisRealtimeStreams][getLastChunkIndex] Found last chunk", {
|
||||
streamKey,
|
||||
clientId,
|
||||
chunkIndex,
|
||||
});
|
||||
return chunkIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next batch (older entries)
|
||||
// Use the ID of the last entry in this batch as the new cursor
|
||||
lastId = `(${entries[entries.length - 1][0]}`; // Exclusive range with (
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error getting last chunk:", {
|
||||
error,
|
||||
streamKey,
|
||||
clientId,
|
||||
});
|
||||
// Return -1 to indicate we don't know what the server has
|
||||
return -1;
|
||||
} finally {
|
||||
await redis.quit().catch((err) => {
|
||||
this.logger.error("[RedisRealtimeStreams][getLastChunkIndex] Error in cleanup:", { err });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { signalsEmitter } from "../signals.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { LineTransformStream } from "./utils.server";
|
||||
import { v1RealtimeStreams } from "./v1StreamsGlobal.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
export type RelayRealtimeStreamsOptions = {
|
||||
ttl: number;
|
||||
cleanupInterval: number;
|
||||
fallbackIngestor: StreamIngestor;
|
||||
fallbackResponder: StreamResponder;
|
||||
waitForBufferTimeout?: number; // Time to wait for buffer in ms (default: 500ms)
|
||||
waitForBufferInterval?: number; // Polling interval in ms (default: 50ms)
|
||||
};
|
||||
|
||||
interface RelayedStreamRecord {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
createdAt: number;
|
||||
lastAccessed: number;
|
||||
locked: boolean;
|
||||
finalized: boolean;
|
||||
}
|
||||
|
||||
export class RelayRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
private _buffers: Map<string, RelayedStreamRecord> = new Map();
|
||||
private cleanupInterval: NodeJS.Timeout;
|
||||
private waitForBufferTimeout: number;
|
||||
private waitForBufferInterval: number;
|
||||
|
||||
constructor(private options: RelayRealtimeStreamsOptions) {
|
||||
this.waitForBufferTimeout = options.waitForBufferTimeout ?? 1200;
|
||||
this.waitForBufferInterval = options.waitForBufferInterval ?? 50;
|
||||
|
||||
// Periodic cleanup
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanup();
|
||||
}, this.options.cleanupInterval).unref();
|
||||
}
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
let record = this._buffers.get(`${runId}:${streamId}`);
|
||||
|
||||
if (!record) {
|
||||
logger.debug(
|
||||
"[RelayRealtimeStreams][streamResponse] No ephemeral record found, waiting to see if one becomes available",
|
||||
{
|
||||
streamId,
|
||||
runId,
|
||||
}
|
||||
);
|
||||
|
||||
record = await this.waitForBuffer(`${runId}:${streamId}`);
|
||||
|
||||
if (!record) {
|
||||
logger.debug(
|
||||
"[RelayRealtimeStreams][streamResponse] No ephemeral record found, using fallback",
|
||||
{
|
||||
streamId,
|
||||
runId,
|
||||
}
|
||||
);
|
||||
|
||||
// No ephemeral record, use fallback
|
||||
return this.options.fallbackResponder.streamResponse(
|
||||
request,
|
||||
runId,
|
||||
streamId,
|
||||
environment,
|
||||
signal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Only 1 reader of the stream can use the relayed stream, the rest should use the fallback
|
||||
if (record.locked) {
|
||||
logger.debug("[RelayRealtimeStreams][streamResponse] Stream already locked, using fallback", {
|
||||
streamId,
|
||||
runId,
|
||||
});
|
||||
|
||||
return this.options.fallbackResponder.streamResponse(
|
||||
request,
|
||||
runId,
|
||||
streamId,
|
||||
environment,
|
||||
signal
|
||||
);
|
||||
}
|
||||
|
||||
record.locked = true;
|
||||
record.lastAccessed = Date.now();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][streamResponse] Streaming from ephemeral record", {
|
||||
streamId,
|
||||
runId,
|
||||
});
|
||||
|
||||
// Create a streaming response from the buffered data
|
||||
const stream = record.stream
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new LineTransformStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
for (const line of chunk) {
|
||||
controller.enqueue(`data: ${line}\n\n`);
|
||||
}
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(new TextEncoderStream());
|
||||
|
||||
// Once we start streaming, consider deleting the buffer when done.
|
||||
// For a simple approach, we can rely on finalized and no more reads.
|
||||
// Or we can let TTL cleanup handle it if multiple readers might come in.
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"x-trigger-relay-realtime-streams": "true",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response> {
|
||||
const [localStream, fallbackStream] = stream.tee();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][ingestData] Ingesting data", { runId, streamId });
|
||||
|
||||
// Handle local buffering asynchronously and catch errors
|
||||
this.handleLocalIngestion(localStream, runId, streamId).catch((err) => {
|
||||
logger.error("[RelayRealtimeStreams][ingestData] Error in local ingestion:", { err });
|
||||
});
|
||||
|
||||
// Forward to the fallback ingestor asynchronously and catch errors
|
||||
return this.options.fallbackIngestor.ingestData(fallbackStream, runId, streamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles local buffering of the stream data.
|
||||
* @param stream The readable stream to buffer.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
*/
|
||||
private async handleLocalIngestion(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
) {
|
||||
this.createOrUpdateRelayedStream(`${runId}:${streamId}`, stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves an existing buffer or creates a new one for the given streamId.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
*/
|
||||
private createOrUpdateRelayedStream(
|
||||
bufferKey: string,
|
||||
stream: ReadableStream<Uint8Array>
|
||||
): RelayedStreamRecord {
|
||||
let record = this._buffers.get(bufferKey);
|
||||
if (!record) {
|
||||
record = {
|
||||
stream,
|
||||
createdAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
finalized: false,
|
||||
locked: false,
|
||||
};
|
||||
this._buffers.set(bufferKey, record);
|
||||
} else {
|
||||
record.lastAccessed = Date.now();
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private cleanup() {
|
||||
const now = Date.now();
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][cleanup] Cleaning up old buffers", {
|
||||
bufferCount: this._buffers.size,
|
||||
});
|
||||
|
||||
for (const [key, record] of this._buffers.entries()) {
|
||||
// If last accessed is older than ttl, clean up
|
||||
if (now - record.lastAccessed > this.options.ttl) {
|
||||
this.deleteBuffer(key);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("[RelayRealtimeStreams][cleanup] Cleaned up old buffers", {
|
||||
bufferCount: this._buffers.size,
|
||||
});
|
||||
}
|
||||
|
||||
private deleteBuffer(bufferKey: string) {
|
||||
this._buffers.delete(bufferKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a buffer to be created within a specified timeout.
|
||||
* @param streamId The unique identifier for the stream.
|
||||
* @returns A promise that resolves to true if the buffer was created, false otherwise.
|
||||
*/
|
||||
private async waitForBuffer(bufferKey: string): Promise<RelayedStreamRecord | undefined> {
|
||||
const timeout = this.waitForBufferTimeout;
|
||||
const interval = this.waitForBufferInterval;
|
||||
const maxAttempts = Math.ceil(timeout / interval);
|
||||
let attempts = 0;
|
||||
|
||||
return new Promise<RelayedStreamRecord | undefined>((resolve) => {
|
||||
const checkBuffer = () => {
|
||||
attempts++;
|
||||
if (this._buffers.has(bufferKey)) {
|
||||
resolve(this._buffers.get(bufferKey));
|
||||
return;
|
||||
}
|
||||
if (attempts >= maxAttempts) {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
setTimeout(checkBuffer, interval);
|
||||
};
|
||||
checkBuffer();
|
||||
});
|
||||
}
|
||||
|
||||
// Don't forget to clear interval on shutdown if needed
|
||||
close() {
|
||||
clearInterval(this.cleanupInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function initializeRelayRealtimeStreams() {
|
||||
const service = new RelayRealtimeStreams({
|
||||
ttl: 1000 * 60 * 5, // 5 minutes
|
||||
cleanupInterval: 1000 * 60, // 1 minute
|
||||
fallbackIngestor: v1RealtimeStreams,
|
||||
fallbackResponder: v1RealtimeStreams,
|
||||
});
|
||||
|
||||
signalsEmitter.on("SIGTERM", service.close.bind(service));
|
||||
signalsEmitter.on("SIGINT", service.close.bind(service));
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
export const relayRealtimeStreams = singleton(
|
||||
"relayRealtimeStreams",
|
||||
initializeRelayRealtimeStreams
|
||||
);
|
||||
@@ -0,0 +1,236 @@
|
||||
// app/realtime/S2RealtimeStreams.ts
|
||||
import { StreamIngestor, StreamResponder, StreamResponseOptions } from "./types";
|
||||
import { Logger, LogLevel } from "@trigger.dev/core/logger";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export type S2RealtimeStreamsOptions = {
|
||||
// S2
|
||||
basin: string; // e.g., "my-basin"
|
||||
accessToken: string; // "Bearer" token issued in S2 console
|
||||
streamPrefix?: string; // defaults to ""
|
||||
|
||||
// Read behavior
|
||||
s2WaitSeconds?: number;
|
||||
|
||||
flushIntervalMs?: number; // how often to flush buffered chunks (default 200ms)
|
||||
maxRetries?: number; // max number of retries for failed flushes (default 10)
|
||||
|
||||
logger?: Logger;
|
||||
logLevel?: LogLevel;
|
||||
};
|
||||
|
||||
type S2IssueAccessTokenResponse = { access_token: string };
|
||||
type S2AppendInput = { records: { body: string }[] };
|
||||
type S2AppendAck = {
|
||||
start: { seq_num: number; timestamp: number };
|
||||
end: { seq_num: number; timestamp: number };
|
||||
tail: { seq_num: number; timestamp: number };
|
||||
};
|
||||
|
||||
export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
|
||||
private readonly basin: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string;
|
||||
private readonly streamPrefix: string;
|
||||
|
||||
private readonly s2WaitSeconds: number;
|
||||
|
||||
private readonly flushIntervalMs: number;
|
||||
private readonly maxRetries: number;
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly level: LogLevel;
|
||||
|
||||
constructor(opts: S2RealtimeStreamsOptions) {
|
||||
this.basin = opts.basin;
|
||||
this.baseUrl = `https://${this.basin}.b.aws.s2.dev/v1`;
|
||||
this.token = opts.accessToken;
|
||||
this.streamPrefix = opts.streamPrefix ?? "";
|
||||
|
||||
this.s2WaitSeconds = opts.s2WaitSeconds ?? 60;
|
||||
|
||||
this.flushIntervalMs = opts.flushIntervalMs ?? 200;
|
||||
this.maxRetries = opts.maxRetries ?? 10;
|
||||
|
||||
this.logger = opts.logger ?? new Logger("S2RealtimeStreams", opts.logLevel ?? "info");
|
||||
this.level = opts.logLevel ?? "info";
|
||||
}
|
||||
|
||||
private toStreamName(runId: string, streamId: string): string {
|
||||
return `${this.toStreamPrefix(runId)}${streamId}`;
|
||||
}
|
||||
|
||||
private toStreamPrefix(runId: string): string {
|
||||
return `${this.streamPrefix}/runs/${runId}/`;
|
||||
}
|
||||
|
||||
async initializeStream(
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<{ responseHeaders?: Record<string, string> }> {
|
||||
const id = randomUUID();
|
||||
|
||||
const accessToken = await this.s2IssueAccessToken(id, runId, streamId);
|
||||
|
||||
return {
|
||||
responseHeaders: {
|
||||
"X-S2-Access-Token": accessToken,
|
||||
"X-S2-Basin": this.basin,
|
||||
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
|
||||
"X-S2-Max-Retries": this.maxRetries.toString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
clientId: string,
|
||||
resumeFromChunk?: number
|
||||
): Promise<Response> {
|
||||
throw new Error("S2 streams are written to S2 via the client, not from the server");
|
||||
}
|
||||
|
||||
async appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void> {
|
||||
const s2Stream = this.toStreamName(runId, streamId);
|
||||
|
||||
this.logger.debug(`S2 appending to stream`, { part, stream: s2Stream });
|
||||
|
||||
const result = await this.s2Append(s2Stream, {
|
||||
records: [{ body: JSON.stringify({ data: part, id: partId }) }],
|
||||
});
|
||||
|
||||
this.logger.debug(`S2 append result`, { result });
|
||||
}
|
||||
|
||||
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number> {
|
||||
throw new Error("S2 streams are written to S2 via the client, not from the server");
|
||||
}
|
||||
|
||||
// ---------- Serve SSE from S2 ----------
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
signal: AbortSignal,
|
||||
options?: StreamResponseOptions
|
||||
): Promise<Response> {
|
||||
const s2Stream = this.toStreamName(runId, streamId);
|
||||
const startSeq = this.parseLastEventId(options?.lastEventId);
|
||||
|
||||
this.logger.info(`S2 streaming records from stream`, { stream: s2Stream, startSeq });
|
||||
|
||||
// Request SSE stream from S2 and return it directly
|
||||
const s2Response = await this.s2StreamRecords(s2Stream, {
|
||||
seq_num: startSeq ?? 0,
|
||||
clamp: true,
|
||||
wait: options?.timeoutInSeconds ?? this.s2WaitSeconds, // S2 will keep the connection open and stream new records
|
||||
signal, // Pass abort signal so S2 connection is cleaned up when client disconnects
|
||||
});
|
||||
|
||||
// Return S2's SSE response directly to the client
|
||||
return s2Response;
|
||||
}
|
||||
|
||||
// ---------- Internals: S2 REST ----------
|
||||
private async s2Append(stream: string, body: S2AppendInput): Promise<S2AppendAck> {
|
||||
// POST /v1/streams/{stream}/records (JSON)
|
||||
const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"S2-Format": "raw", // UTF-8 JSON encoding (no base64 overhead) when your data is text. :contentReference[oaicite:8]{index=8}
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
return (await res.json()) as S2AppendAck;
|
||||
}
|
||||
|
||||
private async s2IssueAccessToken(id: string, runId: string, streamId: string): Promise<string> {
|
||||
// POST /v1/access-tokens
|
||||
const res = await fetch(`https://aws.s2.dev/v1/access-tokens`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id,
|
||||
scope: {
|
||||
basins: {
|
||||
exact: this.basin,
|
||||
},
|
||||
ops: ["append", "create-stream"],
|
||||
streams: {
|
||||
prefix: this.toStreamPrefix(runId),
|
||||
},
|
||||
},
|
||||
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(), // 1 day
|
||||
auto_prefix_streams: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`S2 issue access token failed: ${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
const data = (await res.json()) as S2IssueAccessTokenResponse;
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
private async s2StreamRecords(
|
||||
stream: string,
|
||||
opts: {
|
||||
seq_num?: number;
|
||||
clamp?: boolean;
|
||||
wait?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<Response> {
|
||||
// GET /v1/streams/{stream}/records with Accept: text/event-stream for SSE streaming
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.seq_num != null) qs.set("seq_num", String(opts.seq_num));
|
||||
if (opts.clamp != null) qs.set("clamp", String(opts.clamp));
|
||||
if (opts.wait != null) qs.set("wait", String(opts.wait));
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records?${qs}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
Accept: "text/event-stream",
|
||||
"S2-Format": "raw",
|
||||
},
|
||||
signal: opts.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`S2 stream failed: ${res.status} ${res.statusText} ${text}`);
|
||||
}
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
headers.set("X-Stream-Version", "v2");
|
||||
headers.set("Access-Control-Expose-Headers", "*");
|
||||
|
||||
return new Response(res.body, {
|
||||
headers,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
private parseLastEventId(lastEventId?: string): number | undefined {
|
||||
if (!lastEventId) return undefined;
|
||||
// tolerate formats like "1699999999999-5" (take leading digits)
|
||||
const digits = lastEventId.split("-")[0];
|
||||
const n = Number(digits);
|
||||
return Number.isFinite(n) && n >= 0 ? n + 1 : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,35 @@
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
// Interface for stream ingestion
|
||||
export interface StreamIngestor {
|
||||
initializeStream(
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<{ responseHeaders?: Record<string, string> }>;
|
||||
|
||||
ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
streamId: string,
|
||||
clientId: string,
|
||||
resumeFromChunk?: number
|
||||
): Promise<Response>;
|
||||
|
||||
appendPart(part: string, partId: string, runId: string, streamId: string): Promise<void>;
|
||||
|
||||
getLastChunkIndex(runId: string, streamId: string, clientId: string): Promise<number>;
|
||||
}
|
||||
|
||||
export type StreamResponseOptions = {
|
||||
timeoutInSeconds?: number;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
// Interface for stream response
|
||||
export interface StreamResponder {
|
||||
streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
signal: AbortSignal,
|
||||
options?: StreamResponseOptions
|
||||
): Promise<Response>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { S2RealtimeStreams } from "./s2realtimeStreams.server";
|
||||
|
||||
function initializeRedisRealtimeStreams() {
|
||||
return new RedisRealtimeStreams({
|
||||
@@ -13,7 +16,37 @@ function initializeRedisRealtimeStreams() {
|
||||
...(env.REALTIME_STREAMS_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
keyPrefix: "tr:realtime:streams:",
|
||||
},
|
||||
inactivityTimeoutMs: env.REALTIME_STREAMS_INACTIVITY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams);
|
||||
|
||||
export function getRealtimeStreamInstance(
|
||||
environment: AuthenticatedEnvironment,
|
||||
streamVersion: string
|
||||
): StreamIngestor & StreamResponder {
|
||||
if (streamVersion === "v1") {
|
||||
return v1RealtimeStreams;
|
||||
} else {
|
||||
if (env.REALTIME_STREAMS_S2_BASIN && env.REALTIME_STREAMS_S2_ACCESS_TOKEN) {
|
||||
return new S2RealtimeStreams({
|
||||
basin: env.REALTIME_STREAMS_S2_BASIN,
|
||||
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN,
|
||||
streamPrefix: [
|
||||
"org",
|
||||
environment.organization.id,
|
||||
"env",
|
||||
environment.slug,
|
||||
environment.id,
|
||||
].join("/"),
|
||||
logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL,
|
||||
flushIntervalMs: env.REALTIME_STREAMS_S2_FLUSH_INTERVAL_MS,
|
||||
maxRetries: env.REALTIME_STREAMS_S2_MAX_RETRIES,
|
||||
s2WaitSeconds: env.REALTIME_STREAMS_S2_WAIT_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
throw new Error("Realtime streams v2 is required for this run but S2 configuration is missing");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ const DEFAULT_ELECTRIC_COLUMNS = [
|
||||
"outputType",
|
||||
"runTags",
|
||||
"error",
|
||||
"realtimeStreams",
|
||||
];
|
||||
|
||||
const RESERVED_COLUMNS = ["id", "taskIdentifier", "friendlyId", "status", "createdAt"];
|
||||
|
||||
@@ -40,6 +40,10 @@ export const v3SpanParamsSchema = v3RunParamsSchema.extend({
|
||||
spanParam: z.string(),
|
||||
});
|
||||
|
||||
export const v3RunStreamParamsSchema = v3RunParamsSchema.extend({
|
||||
streamKey: z.string(),
|
||||
});
|
||||
|
||||
export const v3DeploymentParams = EnvironmentParamSchema.extend({
|
||||
deploymentParam: z.string(),
|
||||
});
|
||||
|
||||
@@ -1185,6 +1185,14 @@ async function resolveCommonBuiltInVariables(
|
||||
String(env.TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT)
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "TRIGGER_WAIT_UNTIL_TIMEOUT_MS",
|
||||
value: resolveBuiltInEnvironmentVariableOverrides(
|
||||
"TRIGGER_WAIT_UNTIL_TIMEOUT_MS",
|
||||
runtimeEnvironment,
|
||||
String(env.WAIT_UNTIL_TIMEOUT_MS)
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -424,19 +424,24 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
|
||||
private extractEntityFromAttributes(
|
||||
attributes: Attributes
|
||||
): { entityType: string; entityId?: string } | undefined {
|
||||
): { entityType: string; entityId?: string; entityMetadata?: string } | undefined {
|
||||
if (!attributes || typeof attributes !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entityType = attributes[SemanticInternalAttributes.ENTITY_TYPE];
|
||||
const entityId = attributes[SemanticInternalAttributes.ENTITY_ID];
|
||||
const entityMetadata = attributes[SemanticInternalAttributes.ENTITY_METADATA];
|
||||
|
||||
if (typeof entityType !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { entityType, entityId: entityId as string | undefined };
|
||||
return {
|
||||
entityType,
|
||||
entityId: entityId as string | undefined,
|
||||
entityMetadata: entityMetadata as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private addToBatch(events: TaskEventV1Input[] | TaskEventV1Input) {
|
||||
@@ -1101,6 +1106,7 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
entity: {
|
||||
type: undefined,
|
||||
id: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
metadata: {},
|
||||
};
|
||||
@@ -1140,6 +1146,12 @@ export class ClickhouseEventRepository implements IEventRepository {
|
||||
span.entity = {
|
||||
id: parsedMetadata.entity.entityId,
|
||||
type: parsedMetadata.entity.entityType,
|
||||
metadata:
|
||||
"entityMetadata" in parsedMetadata.entity &&
|
||||
parsedMetadata.entity.entityMetadata &&
|
||||
typeof parsedMetadata.entity.entityMetadata === "string"
|
||||
? parsedMetadata.entity.entityMetadata
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -783,6 +783,7 @@ export class EventRepository implements IEventRepository {
|
||||
SemanticInternalAttributes.ENTITY_TYPE
|
||||
),
|
||||
id: rehydrateAttribute<string>(spanEvent.properties, SemanticInternalAttributes.ENTITY_ID),
|
||||
metadata: undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -217,6 +217,7 @@ export type SpanDetail = {
|
||||
// Used for entity type switching in SpanEntity
|
||||
type: string | undefined;
|
||||
id: string | undefined;
|
||||
metadata: string | undefined;
|
||||
};
|
||||
|
||||
metadata: any; // Used by SpanPresenter for entity processing
|
||||
|
||||
@@ -118,6 +118,7 @@ export class ReplayTaskRunService extends BaseService {
|
||||
traceContext: {
|
||||
traceparent: `00-${existingTaskRun.traceId}-${existingTaskRun.spanId}-01`,
|
||||
},
|
||||
realtimeStreamsVersion: existingTaskRun.realtimeStreamsVersion,
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type TriggerTaskServiceOptions = {
|
||||
overrideCreatedAt?: Date;
|
||||
replayedFromTaskRunFriendlyId?: string;
|
||||
planType?: string;
|
||||
realtimeStreamsVersion?: string;
|
||||
};
|
||||
|
||||
export class OutOfEntitlementError extends Error {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "run-s build:** && pnpm run upload:sourcemaps",
|
||||
"build:db:seed": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/seed.ts --outdir=prisma",
|
||||
"build:remix": "remix build --sourcemap",
|
||||
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build --sourcemap",
|
||||
"build:sentry": "esbuild --platform=node --format=cjs ./sentry.server.ts --outdir=build --sourcemap",
|
||||
@@ -16,10 +15,7 @@
|
||||
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
|
||||
"start:local": "cross-env node --max-old-space-size=8192 ./build/server.js",
|
||||
"typecheck": "tsc --noEmit -p ./tsconfig.check.json",
|
||||
"db:seed": "node prisma/seed.js",
|
||||
"db:seed:local": "ts-node prisma/seed.ts",
|
||||
"build:db:populate": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/populate.ts --outdir=prisma",
|
||||
"db:populate": "node prisma/populate.js --",
|
||||
"db:seed": "tsx seed.mts",
|
||||
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
|
||||
"test": "vitest --no-file-parallelism",
|
||||
"eval:dev": "evalite watch"
|
||||
@@ -280,8 +276,8 @@
|
||||
"supertest": "^7.0.0",
|
||||
"tailwind-scrollbar": "^3.0.1",
|
||||
"tailwindcss": "3.4.1",
|
||||
"ts-node": "^10.7.0",
|
||||
"tsconfig-paths": "^3.14.1",
|
||||
"tsx": "^4.20.6",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { seedCloud } from "./seedCloud";
|
||||
import { prisma } from "../app/db.server";
|
||||
import { createEnvironment } from "~/models/organization.server";
|
||||
|
||||
async function runDataMigrations() {
|
||||
await runStagingEnvironmentMigration();
|
||||
}
|
||||
|
||||
async function runStagingEnvironmentMigration() {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existingDataMigration = await tx.dataMigration.findUnique({
|
||||
where: {
|
||||
name: "2023-09-27-AddStagingEnvironments",
|
||||
},
|
||||
});
|
||||
|
||||
if (existingDataMigration) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.dataMigration.create({
|
||||
data: {
|
||||
name: "2023-09-27-AddStagingEnvironments",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("Running data migration 2023-09-27-AddStagingEnvironments");
|
||||
|
||||
const projectsWithoutStagingEnvironments = await tx.project.findMany({
|
||||
where: {
|
||||
environments: {
|
||||
none: {
|
||||
type: "STAGING",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const project of projectsWithoutStagingEnvironments) {
|
||||
try {
|
||||
console.log(
|
||||
`Creating staging environment for project ${project.slug} on org ${project.organization.slug}`
|
||||
);
|
||||
|
||||
await createEnvironment({
|
||||
organization: project.organization,
|
||||
project,
|
||||
type: "STAGING",
|
||||
isBranchableEnvironment: false,
|
||||
member: undefined,
|
||||
prismaClient: tx,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.dataMigration.update({
|
||||
where: {
|
||||
name: "2023-09-27-AddStagingEnvironments",
|
||||
},
|
||||
data: {
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
if (process.env.NODE_ENV === "development" && process.env.SEED_CLOUD === "enabled") {
|
||||
await seedCloud(prisma);
|
||||
}
|
||||
|
||||
await runDataMigrations();
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
export async function seedCloud(prisma: PrismaClient) {
|
||||
if (!process.env.SEED_CLOUD_EMAIL) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = process.env.SEED_CLOUD_EMAIL.split("@")[0];
|
||||
|
||||
// Create a user, organization, and project
|
||||
const user = await prisma.user.upsert({
|
||||
where: {
|
||||
email: process.env.SEED_CLOUD_EMAIL,
|
||||
},
|
||||
create: {
|
||||
email: process.env.SEED_CLOUD_EMAIL,
|
||||
name,
|
||||
authenticationMethod: "MAGIC_LINK",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
const organization = await prisma.organization.upsert({
|
||||
where: {
|
||||
slug: "seed-org-123",
|
||||
},
|
||||
create: {
|
||||
title: "Personal Workspace",
|
||||
slug: "seed-org-123",
|
||||
members: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
role: "ADMIN",
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
create: {
|
||||
name: "My Project",
|
||||
slug: "my-project-123",
|
||||
externalRef: "my-project-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
include: {
|
||||
members: true,
|
||||
projects: true,
|
||||
},
|
||||
});
|
||||
|
||||
const adminMember = organization.members[0];
|
||||
const defaultProject = organization.projects[0];
|
||||
|
||||
const devEnv = await prisma.runtimeEnvironment.upsert({
|
||||
where: {
|
||||
apiKey: "tr_dev_bNaLxayOXqoj",
|
||||
},
|
||||
create: {
|
||||
apiKey: "tr_dev_bNaLxayOXqoj",
|
||||
pkApiKey: "pk_dev_323f3650218e370508cf",
|
||||
slug: "dev",
|
||||
type: "DEVELOPMENT",
|
||||
project: {
|
||||
connect: {
|
||||
id: defaultProject.id,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
orgMember: {
|
||||
connect: {
|
||||
id: adminMember.id,
|
||||
},
|
||||
},
|
||||
shortcode: "octopus-tentacles",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
await prisma.runtimeEnvironment.upsert({
|
||||
where: {
|
||||
apiKey: "tr_prod_bNaLxayOXqoj",
|
||||
},
|
||||
create: {
|
||||
apiKey: "tr_prod_bNaLxayOXqoj",
|
||||
pkApiKey: "pk_dev_323f3650218e378191cf",
|
||||
slug: "prod",
|
||||
type: "PRODUCTION",
|
||||
project: {
|
||||
connect: {
|
||||
id: defaultProject.id,
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
shortcode: "stripey-zebra",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { prisma } from "./app/db.server";
|
||||
import { createOrganization } from "./app/models/organization.server";
|
||||
import { createProject } from "./app/models/project.server";
|
||||
import { AuthenticationMethod } from "@trigger.dev/database";
|
||||
|
||||
async function seed() {
|
||||
console.log("🌱 Starting seed...");
|
||||
|
||||
// Create or find the local user
|
||||
let user = await prisma.user.findUnique({
|
||||
where: { email: "local@trigger.dev" },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log("Creating local user...");
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email: "local@trigger.dev",
|
||||
authenticationMethod: AuthenticationMethod.MAGIC_LINK,
|
||||
name: "Local Developer",
|
||||
displayName: "Local Developer",
|
||||
admin: true,
|
||||
confirmedBasicDetails: true,
|
||||
},
|
||||
});
|
||||
console.log(`✅ Created user: ${user.email} (${user.id})`);
|
||||
} else {
|
||||
console.log(`✅ User already exists: ${user.email} (${user.id})`);
|
||||
}
|
||||
|
||||
// Create or find the references organization
|
||||
// Look for an organization where the user is a member and the title is "References"
|
||||
let organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
title: "References",
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
console.log("Creating references organization...");
|
||||
organization = await createOrganization({
|
||||
title: "References",
|
||||
userId: user.id,
|
||||
companySize: "1-10",
|
||||
});
|
||||
console.log(`✅ Created organization: ${organization.title} (${organization.slug})`);
|
||||
} else {
|
||||
console.log(`✅ Organization already exists: ${organization.title} (${organization.slug})`);
|
||||
}
|
||||
|
||||
// Define the reference projects with their specific project refs
|
||||
const referenceProjects = [
|
||||
{
|
||||
name: "hello-world",
|
||||
externalRef: "proj_rrkpdguyagvsoktglnod",
|
||||
},
|
||||
{
|
||||
name: "d3-chat",
|
||||
externalRef: "proj_cdmymsrobxmcgjqzhdkq",
|
||||
},
|
||||
{
|
||||
name: "realtime-streams",
|
||||
externalRef: "proj_klxlzjnzxmbgiwuuwhvb",
|
||||
},
|
||||
];
|
||||
|
||||
// Create or find each project
|
||||
for (const projectConfig of referenceProjects) {
|
||||
let project = await prisma.project.findUnique({
|
||||
where: { externalRef: projectConfig.externalRef },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
console.log(`Creating project: ${projectConfig.name}...`);
|
||||
project = await createProject({
|
||||
organizationSlug: organization.slug,
|
||||
name: projectConfig.name,
|
||||
userId: user.id,
|
||||
version: "v3",
|
||||
});
|
||||
|
||||
// Update the externalRef to match the expected value
|
||||
project = await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { externalRef: projectConfig.externalRef },
|
||||
});
|
||||
|
||||
console.log(`✅ Created project: ${project.name} (${project.externalRef})`);
|
||||
} else {
|
||||
console.log(`✅ Project already exists: ${project.name} (${project.externalRef})`);
|
||||
}
|
||||
|
||||
// List the environments for this project
|
||||
const environments = await prisma.runtimeEnvironment.findMany({
|
||||
where: { projectId: project.id },
|
||||
select: {
|
||||
slug: true,
|
||||
type: true,
|
||||
apiKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` Environments for ${project.name}:`);
|
||||
for (const env of environments) {
|
||||
console.log(` - ${env.type.toLowerCase()} (${env.slug}): ${env.apiKey}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n🎉 Seed complete!\n");
|
||||
console.log("Summary:");
|
||||
console.log(`User: ${user.email}`);
|
||||
console.log(`Organization: ${organization.title} (${organization.slug})`);
|
||||
console.log(`Projects: ${referenceProjects.map((p) => p.name).join(", ")}`);
|
||||
console.log("\n⚠️ Note: Update the .env files in d3-chat and realtime-streams with:");
|
||||
console.log(` - d3-chat: TRIGGER_PROJECT_REF=proj_cdmymsrobxmcgjqzhdkq`);
|
||||
console.log(` - realtime-streams: TRIGGER_PROJECT_REF=proj_klxlzjnzxmbgiwuuwhvb`);
|
||||
}
|
||||
|
||||
seed()
|
||||
.catch((e) => {
|
||||
console.error("❌ Seed failed:");
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
# nginx.conf (relevant bits)
|
||||
events {}
|
||||
|
||||
http {
|
||||
# This now governs idle close for HTTP/2, since http2_idle_timeout is obsolete.
|
||||
keepalive_timeout 75s; # ← set to 60–80s to reproduce your prod-ish drop
|
||||
|
||||
# Good defaults for streaming
|
||||
sendfile off; # avoid sendfile delays for tiny frames
|
||||
tcp_nodelay on;
|
||||
|
||||
upstream app_upstream {
|
||||
server host.docker.internal:3030;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8443 ssl; # ← no ‘http2’ here…
|
||||
http2 on; # ← …use the standalone directive instead
|
||||
server_name localhost;
|
||||
|
||||
ssl_certificate /etc/nginx/certs/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/key.pem;
|
||||
|
||||
location / {
|
||||
# Make SSE actually stream through NGINX:
|
||||
proxy_buffering off; # don’t buffer
|
||||
gzip off; # don’t compress
|
||||
add_header X-Accel-Buffering no; # belt & suspenders for NGINX buffering
|
||||
proxy_set_header Accept-Encoding ""; # stop upstream gzip (SSE + gzip = sad)
|
||||
|
||||
# Plain h1 to upstream is fine for SSE
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
|
||||
proxy_pass http://app_upstream;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[
|
||||
{
|
||||
"name": "trigger_webapp_local",
|
||||
"listen": "[::]:30303",
|
||||
"upstream": "host.docker.internal:3030",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
@@ -141,6 +141,29 @@ services:
|
||||
networks:
|
||||
- app_network
|
||||
|
||||
toxiproxy:
|
||||
container_name: toxiproxy
|
||||
image: ghcr.io/shopify/toxiproxy:latest
|
||||
restart: always
|
||||
volumes:
|
||||
- ./config/toxiproxy.json:/config/toxiproxy.json
|
||||
ports:
|
||||
- "30303:30303" # Proxied webapp port
|
||||
- "8474:8474" # Toxiproxy API port
|
||||
networks:
|
||||
- app_network
|
||||
command: ["-host", "0.0.0.0", "-config", "/config/toxiproxy.json"]
|
||||
|
||||
nginx-h2:
|
||||
image: nginx:1.27
|
||||
container_name: nginx-h2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8443:8443"
|
||||
volumes:
|
||||
- ./config/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./config/certs:/etc/nginx/certs:ro
|
||||
|
||||
# otel-collector:
|
||||
# container_name: otel-collector
|
||||
# image: otel/opentelemetry-collector-contrib:latest
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "realtimeStreamsVersion" TEXT NOT NULL DEFAULT 'v1';
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."TaskRun" ADD COLUMN "realtimeStreams" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -749,6 +749,11 @@ model TaskRun {
|
||||
|
||||
maxDurationInSeconds Int?
|
||||
|
||||
/// The version of the realtime streams implementation used by the run
|
||||
realtimeStreamsVersion String @default("v1")
|
||||
/// Store the stream keys that are being used by the run
|
||||
realtimeStreams String[] @default([])
|
||||
|
||||
@@unique([oneTimeUseToken])
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
// Finding child runs
|
||||
|
||||
@@ -389,6 +389,7 @@ export class RunEngine {
|
||||
createdAt,
|
||||
bulkActionId,
|
||||
planType,
|
||||
realtimeStreamsVersion,
|
||||
}: TriggerParams,
|
||||
tx?: PrismaClientOrTransaction
|
||||
): Promise<TaskRun> {
|
||||
@@ -469,6 +470,7 @@ export class RunEngine {
|
||||
createdAt,
|
||||
bulkActionGroupIds: bulkActionId ? [bulkActionId] : undefined,
|
||||
planType,
|
||||
realtimeStreamsVersion,
|
||||
executionSnapshots: {
|
||||
create: {
|
||||
engine: "V2",
|
||||
|
||||
@@ -431,6 +431,7 @@ export class RunAttemptSystem {
|
||||
traceContext: true,
|
||||
priorityMs: true,
|
||||
batchId: true,
|
||||
realtimeStreamsVersion: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -595,6 +596,7 @@ export class RunAttemptSystem {
|
||||
updatedRun.runtimeEnvironment.type !== "DEVELOPMENT"
|
||||
? updatedRun.workerQueue
|
||||
: undefined,
|
||||
realtimeStreamsVersion: updatedRun.realtimeStreamsVersion ?? undefined,
|
||||
},
|
||||
task,
|
||||
queue,
|
||||
|
||||
@@ -148,6 +148,7 @@ export type TriggerParams = {
|
||||
createdAt?: Date;
|
||||
bulkActionId?: string;
|
||||
planType?: string;
|
||||
realtimeStreamsVersion?: string;
|
||||
};
|
||||
|
||||
export type EngineWorker = Worker<typeof workerCatalog>;
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
WorkerToExecutorMessageCatalog,
|
||||
traceContext,
|
||||
heartbeats,
|
||||
realtimeStreams,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
UsageTimeoutManager,
|
||||
StandardTraceContextManager,
|
||||
StandardHeartbeatsManager,
|
||||
StandardRealtimeStreamsManager,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -147,12 +149,19 @@ traceContext.setGlobalManager(standardTraceContextManager);
|
||||
|
||||
const durableClock = new DurableClock();
|
||||
clock.setGlobalClock(durableClock);
|
||||
const runMetadataManager = new StandardMetadataManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev"
|
||||
);
|
||||
const runMetadataManager = new StandardMetadataManager(apiClientManager.clientOrThrow());
|
||||
runMetadata.setGlobalManager(runMetadataManager);
|
||||
const waitUntilManager = new StandardWaitUntilManager();
|
||||
|
||||
const standardRealtimeStreamsManager = new StandardRealtimeStreamsManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ??
|
||||
false
|
||||
);
|
||||
realtimeStreams.setGlobalManager(standardRealtimeStreamsManager);
|
||||
|
||||
const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000);
|
||||
const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs);
|
||||
waitUntil.setGlobalManager(waitUntilManager);
|
||||
|
||||
const triggerLogLevel = getEnvVar("TRIGGER_LOG_LEVEL");
|
||||
@@ -316,6 +325,7 @@ function resetExecutionEnvironment() {
|
||||
devUsageManager.reset();
|
||||
usageTimeoutManager.reset();
|
||||
runMetadataManager.reset();
|
||||
standardRealtimeStreamsManager.reset();
|
||||
waitUntilManager.reset();
|
||||
_sharedWorkerRuntime?.reset();
|
||||
durableClock.reset();
|
||||
@@ -325,8 +335,8 @@ function resetExecutionEnvironment() {
|
||||
|
||||
// Wait for all streams to finish before completing the run
|
||||
waitUntil.register({
|
||||
requiresResolving: () => runMetadataManager.hasActiveStreams(),
|
||||
promise: () => runMetadataManager.waitForAllStreams(),
|
||||
requiresResolving: () => standardRealtimeStreamsManager.hasActiveStreams(),
|
||||
promise: (timeoutInMs) => standardRealtimeStreamsManager.waitForAllStreams(timeoutInMs),
|
||||
});
|
||||
|
||||
log(`[${new Date().toISOString()}] Reset execution environment`);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
WorkerToExecutorMessageCatalog,
|
||||
traceContext,
|
||||
heartbeats,
|
||||
realtimeStreams,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
|
||||
import {
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
UsageTimeoutManager,
|
||||
StandardTraceContextManager,
|
||||
StandardHeartbeatsManager,
|
||||
StandardRealtimeStreamsManager,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -127,13 +129,19 @@ clock.setGlobalClock(durableClock);
|
||||
const standardTraceContextManager = new StandardTraceContextManager();
|
||||
traceContext.setGlobalManager(standardTraceContextManager);
|
||||
|
||||
const runMetadataManager = new StandardMetadataManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev"
|
||||
);
|
||||
const runMetadataManager = new StandardMetadataManager(apiClientManager.clientOrThrow());
|
||||
runMetadata.setGlobalManager(runMetadataManager);
|
||||
|
||||
const waitUntilManager = new StandardWaitUntilManager();
|
||||
const standardRealtimeStreamsManager = new StandardRealtimeStreamsManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ??
|
||||
false
|
||||
);
|
||||
realtimeStreams.setGlobalManager(standardRealtimeStreamsManager);
|
||||
|
||||
const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000);
|
||||
const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs);
|
||||
waitUntil.setGlobalManager(waitUntilManager);
|
||||
|
||||
const standardHeartbeatsManager = new StandardHeartbeatsManager(
|
||||
@@ -292,6 +300,7 @@ function resetExecutionEnvironment() {
|
||||
timeout.reset();
|
||||
runMetadataManager.reset();
|
||||
waitUntilManager.reset();
|
||||
standardRealtimeStreamsManager.reset();
|
||||
_sharedWorkerRuntime?.reset();
|
||||
durableClock.reset();
|
||||
taskContext.disable();
|
||||
@@ -300,8 +309,8 @@ function resetExecutionEnvironment() {
|
||||
|
||||
// Wait for all streams to finish before completing the run
|
||||
waitUntil.register({
|
||||
requiresResolving: () => runMetadataManager.hasActiveStreams(),
|
||||
promise: () => runMetadataManager.waitForAllStreams(),
|
||||
requiresResolving: () => standardRealtimeStreamsManager.hasActiveStreams(),
|
||||
promise: (timeoutInMs) => standardRealtimeStreamsManager.waitForAllStreams(timeoutInMs),
|
||||
});
|
||||
|
||||
console.log(`[${new Date().toISOString()}] Reset execution environment`);
|
||||
|
||||
@@ -181,6 +181,7 @@
|
||||
"@opentelemetry/sdk-trace-base": "2.0.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "1.36.0",
|
||||
"@s2-dev/streamstore": "0.17.3",
|
||||
"dequal": "^2.0.3",
|
||||
"eventsource": "^3.0.5",
|
||||
"eventsource-parser": "^3.0.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ApiDeploymentListOptions,
|
||||
ApiDeploymentListResponseItem,
|
||||
ApiDeploymentListSearchParams,
|
||||
AppendToStreamResponseBody,
|
||||
BatchTaskRunExecutionResult,
|
||||
BatchTriggerTaskV3RequestBody,
|
||||
BatchTriggerTaskV3Response,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
CompleteWaitpointTokenResponseBody,
|
||||
CreateEnvironmentVariableRequestBody,
|
||||
CreateScheduleOptions,
|
||||
CreateStreamResponseBody,
|
||||
CreateUploadPayloadUrlResponseBody,
|
||||
CreateWaitpointTokenRequestBody,
|
||||
CreateWaitpointTokenResponseBody,
|
||||
@@ -69,9 +71,11 @@ import {
|
||||
RunStreamCallback,
|
||||
RunSubscription,
|
||||
SSEStreamSubscriptionFactory,
|
||||
SSEStreamSubscription,
|
||||
TaskRunShape,
|
||||
runShapeStream,
|
||||
RealtimeRunSkipColumns,
|
||||
type SSEStreamPart,
|
||||
} from "./runStream.js";
|
||||
import {
|
||||
CreateEnvironmentVariableParams,
|
||||
@@ -83,6 +87,8 @@ import {
|
||||
UpdateEnvironmentVariableParams,
|
||||
} from "./types.js";
|
||||
import { API_VERSION, API_VERSION_HEADER_NAME } from "./version.js";
|
||||
import { ApiClientConfiguration } from "../apiClientManager-api.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
|
||||
export type CreateWaitpointTokenResponse = Prettify<
|
||||
CreateWaitpointTokenResponseBody & {
|
||||
@@ -112,6 +118,7 @@ export type TriggerRequestOptions = ZodFetchOptions & {
|
||||
|
||||
export type TriggerApiRequestOptions = ApiRequestOptions & {
|
||||
publicAccessToken?: TriggerJwtOptions;
|
||||
clientConfig?: ApiClientConfiguration;
|
||||
};
|
||||
|
||||
const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
|
||||
@@ -124,7 +131,11 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export { isRequestOptions };
|
||||
export type ApiClientFutureFlags = {
|
||||
v2RealtimeStreams?: boolean;
|
||||
};
|
||||
|
||||
export { isRequestOptions, SSEStreamSubscription };
|
||||
export type {
|
||||
AnyRealtimeRun,
|
||||
AnyRunShape,
|
||||
@@ -134,6 +145,7 @@ export type {
|
||||
RunStreamCallback,
|
||||
RunSubscription,
|
||||
TaskRunShape,
|
||||
SSEStreamPart,
|
||||
};
|
||||
|
||||
export * from "./getBranch.js";
|
||||
@@ -145,18 +157,21 @@ export class ApiClient {
|
||||
public readonly baseUrl: string;
|
||||
public readonly accessToken: string;
|
||||
public readonly previewBranch?: string;
|
||||
public readonly futureFlags: ApiClientFutureFlags;
|
||||
private readonly defaultRequestOptions: ZodFetchOptions;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
accessToken: string,
|
||||
previewBranch?: string,
|
||||
requestOptions: ApiRequestOptions = {}
|
||||
requestOptions: ApiRequestOptions = {},
|
||||
futureFlags: ApiClientFutureFlags = {}
|
||||
) {
|
||||
this.accessToken = accessToken;
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
this.previewBranch = previewBranch;
|
||||
this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions);
|
||||
this.futureFlags = futureFlags;
|
||||
}
|
||||
|
||||
get fetchClient(): typeof fetch {
|
||||
@@ -1061,18 +1076,79 @@ export class ApiClient {
|
||||
async fetchStream<T>(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: { signal?: AbortSignal; baseUrl?: string }
|
||||
options?: {
|
||||
signal?: AbortSignal;
|
||||
baseUrl?: string;
|
||||
timeoutInSeconds?: number;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
lastEventId?: string;
|
||||
}
|
||||
): Promise<AsyncIterableStream<T>> {
|
||||
const streamFactory = new SSEStreamSubscriptionFactory(options?.baseUrl ?? this.baseUrl, {
|
||||
headers: this.getHeaders(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
const subscription = streamFactory.createSubscription(runId, streamKey);
|
||||
const subscription = streamFactory.createSubscription(runId, streamKey, {
|
||||
onComplete: options?.onComplete,
|
||||
onError: options?.onError,
|
||||
timeoutInSeconds: options?.timeoutInSeconds,
|
||||
lastEventId: options?.lastEventId,
|
||||
});
|
||||
|
||||
const stream = await subscription.subscribe();
|
||||
|
||||
return stream as AsyncIterableStream<T>;
|
||||
return stream.pipeThrough(
|
||||
new TransformStream<SSEStreamPart, T>({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk.chunk as T);
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async createStream(
|
||||
runId: string,
|
||||
target: string,
|
||||
streamId: string,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
CreateStreamResponseBody,
|
||||
`${this.baseUrl}/realtime/v1/streams/${runId}/${target}/${streamId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
)
|
||||
.withResponse()
|
||||
.then(async ({ data, response }) => {
|
||||
return {
|
||||
...data,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async appendToStream<TBody extends BodyInit>(
|
||||
runId: string,
|
||||
target: string,
|
||||
streamId: string,
|
||||
part: TBody,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
AppendToStreamResponseBody,
|
||||
`${this.baseUrl}/realtime/v1/streams/${runId}/${target}/${streamId}/append`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: part,
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
async generateJWTClaims(requestOptions?: ZodFetchOptions): Promise<Record<string, any>> {
|
||||
@@ -1137,6 +1213,16 @@ export class ApiClient {
|
||||
|
||||
headers[API_VERSION_HEADER_NAME] = API_VERSION;
|
||||
|
||||
if (
|
||||
this.futureFlags.v2RealtimeStreams ||
|
||||
getEnvVar("TRIGGER_V2_REALTIME_STREAMS") === "1" ||
|
||||
getEnvVar("TRIGGER_V2_REALTIME_STREAMS") === "true" ||
|
||||
getEnvVar("TRIGGER_REALTIME_STREAMS_V2") === "1" ||
|
||||
getEnvVar("TRIGGER_REALTIME_STREAMS_V2") === "true"
|
||||
) {
|
||||
headers["x-trigger-realtime-streams-version"] = "v2";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
import { EventSourceMessage, EventSourceParserStream } from "eventsource-parser/stream";
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { createJsonErrorObject } from "../errors.js";
|
||||
import {
|
||||
RunStatus,
|
||||
SubscribeRealtimeStreamChunkRawShape,
|
||||
SubscribeRunRawShape,
|
||||
} from "../schemas/api.js";
|
||||
import { RunStatus, SubscribeRunRawShape } from "../schemas/api.js";
|
||||
import { SerializedError } from "../schemas/common.js";
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
createAsyncIterableReadable,
|
||||
} from "../streams/asyncIterableStream.js";
|
||||
import { AnyRunTypes, AnyTask, InferRunTypes } from "../types/tasks.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
import {
|
||||
@@ -16,11 +16,7 @@ import {
|
||||
} from "../utils/ioSerialization.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { ApiClient } from "./index.js";
|
||||
import { LineTransformStream, zodShapeStream } from "./stream.js";
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
createAsyncIterableReadable,
|
||||
} from "../streams/asyncIterableStream.js";
|
||||
import { zodShapeStream } from "./stream.js";
|
||||
|
||||
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
|
||||
? {
|
||||
@@ -52,6 +48,7 @@ export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTy
|
||||
isFailed: boolean;
|
||||
isSuccess: boolean;
|
||||
isCancelled: boolean;
|
||||
realtimeStreams: string[];
|
||||
}
|
||||
: never;
|
||||
|
||||
@@ -156,97 +153,260 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
|
||||
|
||||
// First, define interfaces for the stream handling
|
||||
export interface StreamSubscription {
|
||||
subscribe(): Promise<ReadableStream<unknown>>;
|
||||
subscribe(): Promise<ReadableStream<SSEStreamPart<unknown>>>;
|
||||
}
|
||||
|
||||
export type CreateStreamSubscriptionOptions = {
|
||||
baseUrl?: string;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeoutInSeconds?: number;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
export interface StreamSubscriptionFactory {
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription;
|
||||
createSubscription(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: CreateStreamSubscriptionOptions
|
||||
): StreamSubscription;
|
||||
}
|
||||
|
||||
export type SSEStreamPart<TChunk = unknown> = {
|
||||
id: string;
|
||||
chunk: TChunk;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
// Real implementation for production
|
||||
export class SSEStreamSubscription implements StreamSubscription {
|
||||
private lastEventId: string | undefined;
|
||||
private retryCount = 0;
|
||||
private maxRetries = 5;
|
||||
private retryDelayMs = 1000;
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
private options: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
onComplete?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeoutInSeconds?: number;
|
||||
lastEventId?: string;
|
||||
}
|
||||
) {
|
||||
this.lastEventId = options.lastEventId;
|
||||
}
|
||||
|
||||
async subscribe(): Promise<ReadableStream<unknown>> {
|
||||
return fetch(this.url, {
|
||||
headers: {
|
||||
async subscribe(): Promise<ReadableStream<SSEStreamPart>> {
|
||||
const self = this;
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
await self.connectStream(controller);
|
||||
},
|
||||
cancel(reason) {
|
||||
self.options.onComplete?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async connectStream(
|
||||
controller: ReadableStreamDefaultController<SSEStreamPart>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "text/event-stream",
|
||||
...this.options.headers,
|
||||
},
|
||||
signal: this.options.signal,
|
||||
}).then((response) => {
|
||||
};
|
||||
|
||||
// Include Last-Event-ID header if we're resuming
|
||||
if (this.lastEventId) {
|
||||
headers["Last-Event-ID"] = this.lastEventId;
|
||||
}
|
||||
|
||||
if (this.options.timeoutInSeconds) {
|
||||
headers["Timeout-Seconds"] = this.options.timeoutInSeconds.toString();
|
||||
}
|
||||
|
||||
const response = await fetch(this.url, {
|
||||
headers,
|
||||
signal: this.options.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw ApiError.generate(
|
||||
const error = ApiError.generate(
|
||||
response.status,
|
||||
{},
|
||||
"Could not subscribe to stream",
|
||||
Object.fromEntries(response.headers)
|
||||
);
|
||||
|
||||
this.options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body");
|
||||
const error = new Error("No response body");
|
||||
|
||||
this.options.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.body
|
||||
const streamVersion = response.headers.get("X-Stream-Version") ?? "v1";
|
||||
|
||||
// Reset retry count on successful connection
|
||||
this.retryCount = 0;
|
||||
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
const stream = response.body
|
||||
.pipeThrough(new TextDecoderStream())
|
||||
.pipeThrough(new EventSourceParserStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(safeParseJSON(chunk.data));
|
||||
new TransformStream<EventSourceMessage, SSEStreamPart>({
|
||||
transform: (chunk, chunkController) => {
|
||||
if (streamVersion === "v1") {
|
||||
// Track the last event ID for resume support
|
||||
if (chunk.id) {
|
||||
this.lastEventId = chunk.id;
|
||||
}
|
||||
|
||||
const timestamp = parseRedisStreamIdTimestamp(chunk.id);
|
||||
|
||||
chunkController.enqueue({
|
||||
id: chunk.id ?? "unknown",
|
||||
chunk: safeParseJSON(chunk.data),
|
||||
timestamp,
|
||||
});
|
||||
} else {
|
||||
if (chunk.event === "batch") {
|
||||
const data = safeParseJSON(chunk.data) as {
|
||||
records: Array<{ body: string; seq_num: number; timestamp: number }>;
|
||||
};
|
||||
|
||||
for (const record of data.records) {
|
||||
this.lastEventId = record.seq_num.toString();
|
||||
|
||||
const parsedBody = safeParseJSON(record.body) as { data: unknown; id: string };
|
||||
if (seenIds.has(parsedBody.id)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(parsedBody.id);
|
||||
|
||||
chunkController.enqueue({
|
||||
id: record.seq_num.toString(),
|
||||
chunk: parsedBody.data,
|
||||
timestamp: record.timestamp,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const reader = stream.getReader();
|
||||
|
||||
try {
|
||||
let chunkCount = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.options.signal?.aborted) {
|
||||
reader.cancel();
|
||||
reader.releaseLock();
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
chunkCount++;
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
reader.releaseLock();
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.options.signal?.aborted) {
|
||||
// Don't retry if aborted
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry on error
|
||||
await this.retryConnection(controller, error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async retryConnection(
|
||||
controller: ReadableStreamDefaultController,
|
||||
error?: Error
|
||||
): Promise<void> {
|
||||
if (this.options.signal?.aborted) {
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.retryCount >= this.maxRetries) {
|
||||
const finalError = error || new Error("Max retries reached");
|
||||
controller.error(finalError);
|
||||
this.options.onError?.(finalError);
|
||||
return;
|
||||
}
|
||||
|
||||
this.retryCount++;
|
||||
const delay = this.retryDelayMs * Math.pow(2, this.retryCount - 1);
|
||||
|
||||
// Wait before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
if (this.options.signal?.aborted) {
|
||||
controller.close();
|
||||
this.options.onComplete?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Reconnect
|
||||
await this.connectStream(controller);
|
||||
}
|
||||
}
|
||||
|
||||
export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
private options: {
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
) {}
|
||||
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription {
|
||||
createSubscription(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: CreateStreamSubscriptionOptions
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
const url = `${baseUrl ?? this.baseUrl}/realtime/v1/streams/${runId}/${streamKey}`;
|
||||
return new SSEStreamSubscription(url, this.options);
|
||||
}
|
||||
}
|
||||
const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/streams/${runId}/${streamKey}`;
|
||||
|
||||
// Real implementation for production
|
||||
export class ElectricStreamSubscription implements StreamSubscription {
|
||||
constructor(
|
||||
private url: string,
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
|
||||
async subscribe(): Promise<ReadableStream<unknown>> {
|
||||
return zodShapeStream(SubscribeRealtimeStreamChunkRawShape, this.url, this.options)
|
||||
.stream.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(chunk.value);
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(new LineTransformStream())
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
for (const line of chunk) {
|
||||
controller.enqueue(safeParseJSON(line));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
return new SSEStreamSubscription(url, {
|
||||
...this.options,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,13 +485,11 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
run,
|
||||
});
|
||||
|
||||
const streams = getStreamsFromRunShape(run);
|
||||
|
||||
// Check for stream metadata
|
||||
if (
|
||||
run.metadata &&
|
||||
"$$streams" in run.metadata &&
|
||||
Array.isArray(run.metadata.$$streams)
|
||||
) {
|
||||
for (const streamKey of run.metadata.$$streams) {
|
||||
if (streams.length > 0) {
|
||||
for (const streamKey of streams) {
|
||||
if (typeof streamKey !== "string") {
|
||||
continue;
|
||||
}
|
||||
@@ -342,39 +500,33 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
const subscription = this.options.streamFactory.createSubscription(
|
||||
run.id,
|
||||
streamKey,
|
||||
this.options.client?.baseUrl
|
||||
{
|
||||
baseUrl: this.options.client?.baseUrl,
|
||||
}
|
||||
);
|
||||
|
||||
// Start stream processing in the background
|
||||
subscription
|
||||
.subscribe()
|
||||
.then((stream) => {
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
});
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(`Error in stream ${streamKey}:`, error);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error subscribing to stream ${streamKey}:`, error);
|
||||
});
|
||||
subscription.subscribe().then((stream) => {
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk.chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
});
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,6 +595,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
error: row.error ? createJsonErrorObject(row.error) : undefined,
|
||||
isTest: row.isTest ?? false,
|
||||
metadata,
|
||||
realtimeStreams: row.realtimeStreams ?? [],
|
||||
...booleanHelpersFromRunStatus(status),
|
||||
} as RunShape<TRunTypes>;
|
||||
}
|
||||
@@ -593,3 +746,34 @@ if (isSafari()) {
|
||||
// @ts-ignore-error
|
||||
ReadableStream.prototype[Symbol.asyncIterator] ??= ReadableStream.prototype.values;
|
||||
}
|
||||
|
||||
function getStreamsFromRunShape(run: AnyRunShape): string[] {
|
||||
const metadataStreams =
|
||||
run.metadata &&
|
||||
"$$streams" in run.metadata &&
|
||||
Array.isArray(run.metadata.$$streams) &&
|
||||
run.metadata.$$streams.length > 0 &&
|
||||
run.metadata.$$streams.every((stream) => typeof stream === "string")
|
||||
? run.metadata.$$streams
|
||||
: undefined;
|
||||
|
||||
if (metadataStreams) {
|
||||
return metadataStreams;
|
||||
}
|
||||
|
||||
return run.realtimeStreams;
|
||||
}
|
||||
|
||||
// Redis stream IDs are in the format: <timestamp>-<sequence>
|
||||
function parseRedisStreamIdTimestamp(id?: string): number {
|
||||
if (!id) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
const timestamp = parseInt(id.split("-")[0] as string, 10);
|
||||
if (isNaN(timestamp)) {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
@@ -59,15 +59,25 @@ export class APIClientManagerAPI {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new ApiClient(this.baseURL, this.accessToken, this.branchName);
|
||||
const requestOptions = this.#getConfig()?.requestOptions;
|
||||
const futureFlags = this.#getConfig()?.future;
|
||||
|
||||
return new ApiClient(this.baseURL, this.accessToken, this.branchName, requestOptions, futureFlags);
|
||||
}
|
||||
|
||||
clientOrThrow(): ApiClient {
|
||||
if (!this.baseURL || !this.accessToken) {
|
||||
clientOrThrow(config?: ApiClientConfiguration): ApiClient {
|
||||
const baseURL = config?.baseURL ?? this.baseURL;
|
||||
const accessToken = config?.accessToken ?? config?.secretKey ?? this.accessToken;
|
||||
|
||||
if (!baseURL || !accessToken) {
|
||||
throw new ApiClientMissingError(this.apiClientMissingError());
|
||||
}
|
||||
|
||||
return new ApiClient(this.baseURL, this.accessToken, this.branchName);
|
||||
const branchName = config?.previewBranch ?? this.branchName;
|
||||
const requestOptions = config?.requestOptions ?? this.#getConfig()?.requestOptions;
|
||||
const futureFlags = config?.future ?? this.#getConfig()?.future;
|
||||
|
||||
return new ApiClient(baseURL, accessToken, branchName, requestOptions, futureFlags);
|
||||
}
|
||||
|
||||
runWithConfig<R extends (...args: any[]) => Promise<any>>(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ApiRequestOptions } from "../apiClient/index.js";
|
||||
import type { ApiClientFutureFlags, ApiRequestOptions } from "../apiClient/index.js";
|
||||
|
||||
export type ApiClientConfiguration = {
|
||||
baseURL?: string;
|
||||
@@ -15,4 +15,5 @@ export type ApiClientConfiguration = {
|
||||
*/
|
||||
previewBranch?: string;
|
||||
requestOptions?: ApiRequestOptions;
|
||||
future?: ApiClientFutureFlags;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from "./run-timeline-metrics-api.js";
|
||||
export * from "./lifecycle-hooks-api.js";
|
||||
export * from "./locals-api.js";
|
||||
export * from "./heartbeats-api.js";
|
||||
export * from "./realtime-streams-api.js";
|
||||
export * from "./schemas/index.js";
|
||||
export { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
|
||||
export * from "./resource-catalog-api.js";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
// Split module-level variable definition into separate files to allow
|
||||
// tree-shaking on each api instance.
|
||||
import { RealtimeStreamsAPI } from "./realtimeStreams/index.js";
|
||||
|
||||
export const realtimeStreams = RealtimeStreamsAPI.getInstance();
|
||||
|
||||
export * from "./realtimeStreams/types.js";
|
||||
@@ -0,0 +1,49 @@
|
||||
import { getGlobal, registerGlobal } from "../utils/globals.js";
|
||||
import { NoopRealtimeStreamsManager } from "./noopManager.js";
|
||||
import {
|
||||
RealtimeStreamOperationOptions,
|
||||
RealtimeStreamInstance,
|
||||
RealtimeStreamsManager,
|
||||
} from "./types.js";
|
||||
|
||||
const API_NAME = "realtime-streams";
|
||||
|
||||
const NOOP_MANAGER = new NoopRealtimeStreamsManager();
|
||||
|
||||
export class RealtimeStreamsAPI implements RealtimeStreamsManager {
|
||||
private static _instance?: RealtimeStreamsAPI;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): RealtimeStreamsAPI {
|
||||
if (!this._instance) {
|
||||
this._instance = new RealtimeStreamsAPI();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
setGlobalManager(manager: RealtimeStreamsManager): boolean {
|
||||
return registerGlobal(API_NAME, manager);
|
||||
}
|
||||
|
||||
#getManager(): RealtimeStreamsManager {
|
||||
return getGlobal(API_NAME) ?? NOOP_MANAGER;
|
||||
}
|
||||
|
||||
public pipe<T>(
|
||||
key: string,
|
||||
source: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): RealtimeStreamInstance<T> {
|
||||
return this.#getManager().pipe(key, source, options);
|
||||
}
|
||||
|
||||
public append<TPart extends BodyInit>(
|
||||
key: string,
|
||||
part: TPart,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): Promise<void> {
|
||||
return this.#getManager().append(key, part, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ApiClient } from "../apiClient/index.js";
|
||||
import { ensureAsyncIterable, ensureReadableStream } from "../streams/asyncIterableStream.js";
|
||||
import { taskContext } from "../task-context-api.js";
|
||||
import { StreamInstance } from "./streamInstance.js";
|
||||
import {
|
||||
RealtimeStreamInstance,
|
||||
RealtimeStreamOperationOptions,
|
||||
RealtimeStreamsManager,
|
||||
} from "./types.js";
|
||||
|
||||
export class StandardRealtimeStreamsManager implements RealtimeStreamsManager {
|
||||
constructor(
|
||||
private apiClient: ApiClient,
|
||||
private baseUrl: string,
|
||||
private debug: boolean = false
|
||||
) {}
|
||||
// Track active streams - using a Set allows multiple streams for the same key to coexist
|
||||
private activeStreams = new Set<{
|
||||
wait: () => Promise<void>;
|
||||
abortController: AbortController;
|
||||
}>();
|
||||
|
||||
reset(): void {
|
||||
this.activeStreams.clear();
|
||||
}
|
||||
|
||||
public pipe<T>(
|
||||
key: string,
|
||||
source: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): RealtimeStreamInstance<T> {
|
||||
// Normalize ReadableStream to AsyncIterable
|
||||
const readableStreamSource = ensureReadableStream(source);
|
||||
|
||||
const runId = getRunIdForOptions(options);
|
||||
|
||||
if (!runId) {
|
||||
throw new Error(
|
||||
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option."
|
||||
);
|
||||
}
|
||||
|
||||
// Create an AbortController for this stream
|
||||
const abortController = new AbortController();
|
||||
// Chain with user-provided signal if present
|
||||
const combinedSignal = options?.signal
|
||||
? AbortSignal.any?.([options.signal, abortController.signal]) ?? abortController.signal
|
||||
: abortController.signal;
|
||||
|
||||
const streamInstance = new StreamInstance({
|
||||
apiClient: this.apiClient,
|
||||
baseUrl: this.baseUrl,
|
||||
runId,
|
||||
key,
|
||||
source: readableStreamSource,
|
||||
signal: combinedSignal,
|
||||
requestOptions: options?.requestOptions,
|
||||
target: options?.target,
|
||||
debug: this.debug,
|
||||
});
|
||||
|
||||
// Register this stream
|
||||
const streamInfo = { wait: () => streamInstance.wait(), abortController };
|
||||
this.activeStreams.add(streamInfo);
|
||||
|
||||
// Clean up when stream completes
|
||||
streamInstance.wait().finally(() => this.activeStreams.delete(streamInfo));
|
||||
|
||||
return {
|
||||
wait: () => streamInstance.wait(),
|
||||
stream: streamInstance.stream,
|
||||
};
|
||||
}
|
||||
|
||||
public async append<TPart extends BodyInit>(
|
||||
key: string,
|
||||
part: TPart,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): Promise<void> {
|
||||
const runId = getRunIdForOptions(options);
|
||||
|
||||
if (!runId) {
|
||||
throw new Error(
|
||||
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option."
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.apiClient.appendToStream(
|
||||
runId,
|
||||
"self",
|
||||
key,
|
||||
part,
|
||||
options?.requestOptions
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Failed to append to stream: ${result.message ?? "Unknown error"}`);
|
||||
}
|
||||
}
|
||||
|
||||
public hasActiveStreams(): boolean {
|
||||
return this.activeStreams.size > 0;
|
||||
}
|
||||
|
||||
// Waits for all the streams to finish
|
||||
public async waitForAllStreams(timeout: number = 60_000): Promise<void> {
|
||||
if (this.activeStreams.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = Array.from(this.activeStreams).map((stream) => stream.wait());
|
||||
|
||||
// Create a timeout promise that resolves to a special sentinel value
|
||||
const TIMEOUT_SENTINEL = Symbol("timeout");
|
||||
const timeoutPromise = new Promise<typeof TIMEOUT_SENTINEL>((resolve) =>
|
||||
setTimeout(() => resolve(TIMEOUT_SENTINEL), timeout)
|
||||
);
|
||||
|
||||
// Race between all streams completing/rejecting and the timeout
|
||||
const result = await Promise.race([Promise.all(promises), timeoutPromise]);
|
||||
|
||||
// Check if we timed out
|
||||
if (result === TIMEOUT_SENTINEL) {
|
||||
// Timeout occurred - abort all active streams
|
||||
const abortedCount = this.activeStreams.size;
|
||||
for (const streamInfo of this.activeStreams) {
|
||||
streamInfo.abortController.abort();
|
||||
this.activeStreams.delete(streamInfo);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timeout waiting for streams to finish after ${timeout}ms. Aborted ${abortedCount} active stream(s).`
|
||||
);
|
||||
}
|
||||
|
||||
// If we reach here, Promise.all completed (either all resolved or one rejected)
|
||||
// Any rejection from Promise.all will have already propagated
|
||||
}
|
||||
}
|
||||
|
||||
function getRunIdForOptions(options?: RealtimeStreamOperationOptions): string | undefined {
|
||||
if (options?.target) {
|
||||
if (options.target === "parent") {
|
||||
return taskContext.ctx?.run?.parentTaskRunId ?? taskContext.ctx?.run?.id;
|
||||
}
|
||||
|
||||
if (options.target === "root") {
|
||||
return taskContext.ctx?.run?.rootTaskRunId ?? taskContext.ctx?.run?.id;
|
||||
}
|
||||
|
||||
if (options.target === "self") {
|
||||
return taskContext.ctx?.run?.id;
|
||||
}
|
||||
|
||||
return options.target;
|
||||
}
|
||||
|
||||
return taskContext.ctx?.run?.id;
|
||||
}
|
||||
|
||||
type ParsedStreamResponse =
|
||||
| {
|
||||
version: "v1";
|
||||
}
|
||||
| {
|
||||
version: "v2";
|
||||
accessToken: string;
|
||||
basin: string;
|
||||
flushIntervalMs?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
function parseCreateStreamResponse(
|
||||
version: string,
|
||||
headers: Record<string, string> | undefined
|
||||
): ParsedStreamResponse {
|
||||
if (version === "v1") {
|
||||
return { version: "v1" };
|
||||
}
|
||||
|
||||
const accessToken = headers?.["x-s2-access-token"];
|
||||
const basin = headers?.["x-s2-basin"];
|
||||
|
||||
if (!accessToken || !basin) {
|
||||
return { version: "v1" };
|
||||
}
|
||||
|
||||
const flushIntervalMs = headers?.["x-s2-flush-interval-ms"];
|
||||
const maxRetries = headers?.["x-s2-max-retries"];
|
||||
|
||||
return {
|
||||
version: "v2",
|
||||
accessToken,
|
||||
basin,
|
||||
flushIntervalMs: flushIntervalMs ? parseInt(flushIntervalMs) : undefined,
|
||||
maxRetries: maxRetries ? parseInt(maxRetries) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
AsyncIterableStream,
|
||||
createAsyncIterableStreamFromAsyncIterable,
|
||||
} from "../streams/asyncIterableStream.js";
|
||||
import {
|
||||
RealtimeStreamOperationOptions,
|
||||
RealtimeStreamInstance,
|
||||
RealtimeStreamsManager,
|
||||
} from "./types.js";
|
||||
|
||||
export class NoopRealtimeStreamsManager implements RealtimeStreamsManager {
|
||||
public pipe<T>(
|
||||
key: string,
|
||||
source: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): RealtimeStreamInstance<T> {
|
||||
return {
|
||||
wait: () => Promise.resolve(),
|
||||
get stream(): AsyncIterableStream<T> {
|
||||
return createAsyncIterableStreamFromAsyncIterable(source);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async append<TPart extends BodyInit>(
|
||||
key: string,
|
||||
part: TPart,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): Promise<void> {}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ApiClient } from "../apiClient/index.js";
|
||||
import { AsyncIterableStream } from "../streams/asyncIterableStream.js";
|
||||
import { AnyZodFetchOptions } from "../zodfetch.js";
|
||||
import { StreamsWriterV1 } from "./streamsWriterV1.js";
|
||||
import { StreamsWriterV2 } from "./streamsWriterV2.js";
|
||||
import { StreamsWriter } from "./types.js";
|
||||
|
||||
export type StreamInstanceOptions<T> = {
|
||||
apiClient: ApiClient;
|
||||
baseUrl: string;
|
||||
runId: string;
|
||||
key: string;
|
||||
source: ReadableStream<T>;
|
||||
signal?: AbortSignal;
|
||||
requestOptions?: AnyZodFetchOptions;
|
||||
target?: "self" | "parent" | "root" | string;
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
type StreamsWriterInstance<T> = StreamsWriterV1<T> | StreamsWriterV2<T>;
|
||||
|
||||
export class StreamInstance<T> implements StreamsWriter {
|
||||
private streamPromise: Promise<StreamsWriterInstance<T>>;
|
||||
|
||||
constructor(private options: StreamInstanceOptions<T>) {
|
||||
this.streamPromise = this.initializeWriter();
|
||||
}
|
||||
|
||||
private async initializeWriter(): Promise<StreamsWriterInstance<T>> {
|
||||
const { version, headers } = await this.options.apiClient.createStream(
|
||||
this.options.runId,
|
||||
"self",
|
||||
this.options.key,
|
||||
this.options?.requestOptions
|
||||
);
|
||||
|
||||
const parsedResponse = parseCreateStreamResponse(version, headers);
|
||||
|
||||
const streamWriter =
|
||||
parsedResponse.version === "v1"
|
||||
? new StreamsWriterV1({
|
||||
key: this.options.key,
|
||||
runId: this.options.runId,
|
||||
source: this.options.source,
|
||||
baseUrl: this.options.baseUrl,
|
||||
headers: this.options.apiClient.getHeaders(),
|
||||
signal: this.options.signal,
|
||||
version,
|
||||
target: "self",
|
||||
})
|
||||
: new StreamsWriterV2({
|
||||
basin: parsedResponse.basin,
|
||||
stream: this.options.key,
|
||||
accessToken: parsedResponse.accessToken,
|
||||
source: this.options.source,
|
||||
signal: this.options.signal,
|
||||
debug: this.options.debug,
|
||||
flushIntervalMs: parsedResponse.flushIntervalMs,
|
||||
maxRetries: parsedResponse.maxRetries,
|
||||
});
|
||||
|
||||
return streamWriter;
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
return this.streamPromise.then((writer) => writer.wait());
|
||||
}
|
||||
|
||||
public get stream(): AsyncIterableStream<T> {
|
||||
const self = this;
|
||||
|
||||
return new ReadableStream<T>({
|
||||
async start(controller) {
|
||||
const streamWriter = await self.streamPromise;
|
||||
|
||||
const iterator = streamWriter[Symbol.asyncIterator]();
|
||||
|
||||
while (true) {
|
||||
if (self.options.signal?.aborted) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
|
||||
const { done, value } = await iterator.next();
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type ParsedStreamResponse =
|
||||
| {
|
||||
version: "v1";
|
||||
}
|
||||
| {
|
||||
version: "v2";
|
||||
accessToken: string;
|
||||
basin: string;
|
||||
flushIntervalMs?: number;
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
function parseCreateStreamResponse(
|
||||
version: string,
|
||||
headers: Record<string, string> | undefined
|
||||
): ParsedStreamResponse {
|
||||
if (version === "v1") {
|
||||
return { version: "v1" };
|
||||
}
|
||||
|
||||
const accessToken = headers?.["x-s2-access-token"];
|
||||
const basin = headers?.["x-s2-basin"];
|
||||
|
||||
if (!accessToken || !basin) {
|
||||
return { version: "v1" };
|
||||
}
|
||||
|
||||
const flushIntervalMs = headers?.["x-s2-flush-interval-ms"];
|
||||
const maxRetries = headers?.["x-s2-max-retries"];
|
||||
|
||||
return {
|
||||
version: "v2",
|
||||
accessToken,
|
||||
basin,
|
||||
flushIntervalMs: flushIntervalMs ? parseInt(flushIntervalMs) : undefined,
|
||||
maxRetries: maxRetries ? parseInt(maxRetries) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
safeReleaseLock(reader);
|
||||
}
|
||||
}
|
||||
|
||||
function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { StreamsWriter } from "./types.js";
|
||||
|
||||
export type StreamsWriterV1Options<T> = {
|
||||
baseUrl: string;
|
||||
runId: string;
|
||||
key: string;
|
||||
source: ReadableStream<T>;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
version?: string;
|
||||
target?: "self" | "parent" | "root";
|
||||
maxRetries?: number;
|
||||
maxBufferSize?: number; // Max number of chunks to keep in ring buffer
|
||||
clientId?: string; // Optional client ID, auto-generated if not provided
|
||||
};
|
||||
|
||||
interface BufferedChunk<T> {
|
||||
index: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class StreamsWriterV1<T> implements StreamsWriter {
|
||||
private controller = new AbortController();
|
||||
private serverStream: ReadableStream<T>;
|
||||
private consumerStream: ReadableStream<T>;
|
||||
private streamPromise: Promise<void>;
|
||||
private retryCount = 0;
|
||||
private readonly maxRetries: number;
|
||||
private currentChunkIndex = 0;
|
||||
private readonly baseDelayMs = 1000; // 1 second base delay
|
||||
private readonly maxDelayMs = 30000; // 30 seconds max delay
|
||||
private readonly maxBufferSize: number;
|
||||
private readonly clientId: string;
|
||||
private ringBuffer: BufferedChunk<T>[] = []; // Ring buffer for recent chunks
|
||||
private bufferStartIndex = 0; // Index of the oldest chunk in buffer
|
||||
private highestBufferedIndex = -1; // Highest chunk index that's been buffered
|
||||
private streamReader: ReadableStreamDefaultReader<T> | null = null;
|
||||
private bufferReaderTask: Promise<void> | null = null;
|
||||
private streamComplete = false;
|
||||
|
||||
constructor(private options: StreamsWriterV1Options<T>) {
|
||||
const [serverStream, consumerStream] = this.options.source.tee();
|
||||
this.serverStream = serverStream;
|
||||
this.consumerStream = consumerStream;
|
||||
this.maxRetries = options.maxRetries ?? 10;
|
||||
this.maxBufferSize = options.maxBufferSize ?? 10000; // Default 10000 chunks
|
||||
this.clientId = options.clientId || this.generateClientId();
|
||||
|
||||
// Start background task to continuously read from stream into ring buffer
|
||||
this.startBuffering();
|
||||
|
||||
this.streamPromise = this.initializeServerStream();
|
||||
}
|
||||
|
||||
private generateClientId(): string {
|
||||
return randomBytes(4).toString("hex");
|
||||
}
|
||||
|
||||
private startBuffering(): void {
|
||||
this.streamReader = this.serverStream.getReader();
|
||||
|
||||
this.bufferReaderTask = (async () => {
|
||||
try {
|
||||
let chunkIndex = 0;
|
||||
while (true) {
|
||||
const { done, value } = await this.streamReader!.read();
|
||||
|
||||
if (done) {
|
||||
this.streamComplete = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Add to ring buffer
|
||||
this.addToRingBuffer(chunkIndex, value);
|
||||
this.highestBufferedIndex = chunkIndex;
|
||||
chunkIndex++;
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
private async makeRequest(startFromChunk: number = 0): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.buildUrl());
|
||||
const timeout = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const req = requestFn({
|
||||
method: "POST",
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
...this.options.headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Id": this.clientId,
|
||||
"X-Resume-From-Chunk": startFromChunk.toString(),
|
||||
"X-Stream-Version": this.options.version ?? "v1",
|
||||
},
|
||||
timeout,
|
||||
});
|
||||
|
||||
req.on("error", async (error) => {
|
||||
const errorCode = "code" in error ? error.code : undefined;
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Check if this is a retryable connection error
|
||||
if (this.isRetryableError(error)) {
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
|
||||
// Clean up the current request to avoid socket leaks
|
||||
req.destroy();
|
||||
|
||||
const delayMs = this.calculateBackoffDelay();
|
||||
|
||||
await this.delay(delayMs);
|
||||
|
||||
// Query server to find out what the last chunk it received was
|
||||
const serverLastChunk = await this.queryServerLastChunkIndex();
|
||||
|
||||
// Resume from the next chunk after what the server has
|
||||
const resumeFromChunk = serverLastChunk + 1;
|
||||
|
||||
resolve(this.makeRequest(resumeFromChunk));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on("timeout", async () => {
|
||||
// Timeout is retryable
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
|
||||
// Clean up the current request to avoid socket leaks
|
||||
req.destroy();
|
||||
|
||||
const delayMs = this.calculateBackoffDelay();
|
||||
|
||||
await this.delay(delayMs);
|
||||
|
||||
// Query server to find where to resume
|
||||
const serverLastChunk = await this.queryServerLastChunkIndex();
|
||||
const resumeFromChunk = serverLastChunk + 1;
|
||||
|
||||
resolve(this.makeRequest(resumeFromChunk));
|
||||
return;
|
||||
}
|
||||
|
||||
req.destroy();
|
||||
reject(new Error("Request timed out"));
|
||||
});
|
||||
|
||||
req.on("response", async (res) => {
|
||||
// Check for retryable status codes (408, 429, 5xx)
|
||||
if (res.statusCode && this.isRetryableStatusCode(res.statusCode)) {
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
|
||||
// Drain and destroy the response and request to avoid socket leaks
|
||||
// We need to consume the response before destroying it
|
||||
res.resume(); // Start draining the response
|
||||
res.destroy(); // Destroy the response to free the socket
|
||||
req.destroy(); // Destroy the request as well
|
||||
|
||||
const delayMs = this.calculateBackoffDelay();
|
||||
|
||||
await this.delay(delayMs);
|
||||
|
||||
// Query server to find where to resume (in case some data was written)
|
||||
const serverLastChunk = await this.queryServerLastChunkIndex();
|
||||
const resumeFromChunk = serverLastChunk + 1;
|
||||
|
||||
resolve(this.makeRequest(resumeFromChunk));
|
||||
return;
|
||||
}
|
||||
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
reject(
|
||||
new Error(`Max retries (${this.maxRetries}) exceeded for status code ${res.statusCode}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-retryable error status
|
||||
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
const error = new Error(`HTTP error! status: ${res.statusCode}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success! Reset retry count
|
||||
this.retryCount = 0;
|
||||
|
||||
res.on("end", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
res.resume();
|
||||
});
|
||||
|
||||
if (this.options.signal) {
|
||||
this.options.signal.addEventListener("abort", () => {
|
||||
req.destroy(new Error("Request aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
let lastSentIndex = startFromChunk - 1;
|
||||
|
||||
while (true) {
|
||||
// Send all chunks that are available in buffer
|
||||
while (lastSentIndex < this.highestBufferedIndex) {
|
||||
lastSentIndex++;
|
||||
const chunk = this.ringBuffer.find((c) => c.index === lastSentIndex);
|
||||
|
||||
if (chunk) {
|
||||
const stringified = JSON.stringify(chunk.data) + "\n";
|
||||
req.write(stringified);
|
||||
this.currentChunkIndex = lastSentIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// If stream is complete and we've sent all buffered chunks, we're done
|
||||
if (this.streamComplete && lastSentIndex >= this.highestBufferedIndex) {
|
||||
req.end();
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait a bit for more chunks to be buffered
|
||||
await this.delay(10);
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
processStream().catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async initializeServerStream(): Promise<void> {
|
||||
await this.makeRequest(0);
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
return this.streamPromise;
|
||||
}
|
||||
|
||||
public [Symbol.asyncIterator]() {
|
||||
return streamToAsyncIterator(this.consumerStream);
|
||||
}
|
||||
|
||||
private buildUrl(): string {
|
||||
return `${this.options.baseUrl}/realtime/v1/streams/${this.options.runId}/${
|
||||
this.options.target ?? "self"
|
||||
}/${this.options.key}`;
|
||||
}
|
||||
|
||||
private isRetryableError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Connection errors that are safe to retry
|
||||
const retryableErrors = [
|
||||
"ECONNRESET", // Connection reset by peer
|
||||
"ECONNREFUSED", // Connection refused
|
||||
"ETIMEDOUT", // Connection timed out
|
||||
"ENOTFOUND", // DNS lookup failed
|
||||
"EPIPE", // Broken pipe
|
||||
"EHOSTUNREACH", // Host unreachable
|
||||
"ENETUNREACH", // Network unreachable
|
||||
"socket hang up", // Socket hang up
|
||||
];
|
||||
|
||||
// Check error code
|
||||
if (error.code && retryableErrors.includes(error.code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check error message for socket hang up
|
||||
if (error.message && error.message.includes("socket hang up")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private isRetryableStatusCode(statusCode: number): boolean {
|
||||
// Retry on transient server errors
|
||||
if (statusCode === 408) return true; // Request Timeout
|
||||
if (statusCode === 429) return true; // Rate Limit
|
||||
if (statusCode === 500) return true; // Internal Server Error
|
||||
if (statusCode === 502) return true; // Bad Gateway
|
||||
if (statusCode === 503) return true; // Service Unavailable
|
||||
if (statusCode === 504) return true; // Gateway Timeout
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private calculateBackoffDelay(): number {
|
||||
// Exponential backoff with jitter: baseDelay * 2^retryCount + random jitter
|
||||
const exponentialDelay = this.baseDelayMs * Math.pow(2, this.retryCount);
|
||||
const jitter = Math.random() * 1000; // 0-1000ms jitter
|
||||
return Math.min(exponentialDelay + jitter, this.maxDelayMs);
|
||||
}
|
||||
|
||||
private addToRingBuffer(index: number, data: T): void {
|
||||
const chunk: BufferedChunk<T> = { index, data };
|
||||
|
||||
if (this.ringBuffer.length < this.maxBufferSize) {
|
||||
// Buffer not full yet, just append
|
||||
this.ringBuffer.push(chunk);
|
||||
} else {
|
||||
// Buffer full, replace oldest chunk (ring buffer behavior)
|
||||
const bufferIndex = index % this.maxBufferSize;
|
||||
this.ringBuffer[bufferIndex] = chunk;
|
||||
this.bufferStartIndex = Math.max(this.bufferStartIndex, index - this.maxBufferSize + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private getChunksFromBuffer(startIndex: number): BufferedChunk<T>[] {
|
||||
const result: BufferedChunk<T>[] = [];
|
||||
|
||||
for (const chunk of this.ringBuffer) {
|
||||
if (chunk.index >= startIndex) {
|
||||
result.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by index to ensure correct order
|
||||
result.sort((a, b) => a.index - b.index);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async queryServerLastChunkIndex(attempt: number = 0): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.buildUrl());
|
||||
const maxHeadRetries = 3; // Separate retry limit for HEAD requests
|
||||
|
||||
const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const req = requestFn({
|
||||
method: "HEAD",
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
...this.options.headers,
|
||||
"X-Client-Id": this.clientId,
|
||||
"X-Stream-Version": this.options.version ?? "v1",
|
||||
},
|
||||
timeout: 5000, // 5 second timeout for HEAD request
|
||||
});
|
||||
|
||||
req.on("error", async (error) => {
|
||||
if (this.isRetryableError(error) && attempt < maxHeadRetries) {
|
||||
// Clean up the current request to avoid socket leaks
|
||||
req.destroy();
|
||||
|
||||
await this.delay(1000 * (attempt + 1)); // Simple linear backoff
|
||||
const result = await this.queryServerLastChunkIndex(attempt + 1);
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
req.destroy();
|
||||
// Return -1 to indicate we don't know what the server has (resume from 0)
|
||||
resolve(-1);
|
||||
});
|
||||
|
||||
req.on("timeout", async () => {
|
||||
req.destroy();
|
||||
|
||||
if (attempt < maxHeadRetries) {
|
||||
await this.delay(1000 * (attempt + 1));
|
||||
const result = await this.queryServerLastChunkIndex(attempt + 1);
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(-1);
|
||||
});
|
||||
|
||||
req.on("response", async (res) => {
|
||||
// Retry on 5xx errors
|
||||
if (res.statusCode && this.isRetryableStatusCode(res.statusCode)) {
|
||||
if (attempt < maxHeadRetries) {
|
||||
// Drain and destroy the response and request to avoid socket leaks
|
||||
res.resume();
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
|
||||
await this.delay(1000 * (attempt + 1));
|
||||
const result = await this.queryServerLastChunkIndex(attempt + 1);
|
||||
resolve(result);
|
||||
return;
|
||||
}
|
||||
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-retryable error
|
||||
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
resolve(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - extract chunk index
|
||||
const lastChunkHeader = res.headers["x-last-chunk-index"];
|
||||
if (lastChunkHeader) {
|
||||
const lastChunkIndex = parseInt(
|
||||
Array.isArray(lastChunkHeader) ? lastChunkHeader[0] ?? "0" : lastChunkHeader ?? "0",
|
||||
10
|
||||
);
|
||||
resolve(lastChunkIndex);
|
||||
} else {
|
||||
resolve(-1);
|
||||
}
|
||||
|
||||
res.resume(); // Consume response
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
safeReleaseLock(reader);
|
||||
}
|
||||
}
|
||||
|
||||
function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { S2, AppendRecord, BatchTransform } from "@s2-dev/streamstore";
|
||||
import { StreamsWriter } from "./types.js";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export type StreamsWriterV2Options<T = any> = {
|
||||
basin: string;
|
||||
stream: string;
|
||||
accessToken: string;
|
||||
source: ReadableStream<T>;
|
||||
signal?: AbortSignal;
|
||||
flushIntervalMs?: number; // Used as lingerDuration for BatchTransform (default 200ms)
|
||||
maxRetries?: number; // Not used with appendSession, kept for compatibility
|
||||
debug?: boolean; // Enable debug logging (default false)
|
||||
maxQueuedBytes?: number; // Max queued bytes for appendSession (default 10MB)
|
||||
};
|
||||
|
||||
/**
|
||||
* StreamsWriterV2 writes metadata stream data directly to S2 (https://s2.dev).
|
||||
*
|
||||
* Features:
|
||||
* - Direct streaming: Uses S2's appendSession for efficient streaming
|
||||
* - Automatic batching: Uses BatchTransform to batch records
|
||||
* - No manual buffering: S2 handles buffering internally
|
||||
* - Debug logging: Enable with debug: true to see detailed operation logs
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* const stream = new StreamsWriterV2({
|
||||
* basin: "my-basin",
|
||||
* stream: "my-stream",
|
||||
* accessToken: "s2-token-here",
|
||||
* source: myAsyncIterable,
|
||||
* flushIntervalMs: 200, // Optional: batch linger duration in ms
|
||||
* debug: true, // Optional: enable debug logging
|
||||
* });
|
||||
*
|
||||
* // Wait for streaming to complete
|
||||
* await stream.wait();
|
||||
*
|
||||
* // Or consume the stream
|
||||
* for await (const value of stream) {
|
||||
* console.log(value);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class StreamsWriterV2<T = any> implements StreamsWriter {
|
||||
private s2Client: S2;
|
||||
private serverStream: ReadableStream<T>;
|
||||
private consumerStream: ReadableStream<T>;
|
||||
private streamPromise: Promise<void>;
|
||||
private readonly flushIntervalMs: number;
|
||||
private readonly debug: boolean;
|
||||
private readonly maxQueuedBytes: number;
|
||||
private aborted = false;
|
||||
private sessionWritable: WritableStream<any> | null = null;
|
||||
|
||||
constructor(private options: StreamsWriterV2Options<T>) {
|
||||
this.debug = options.debug ?? false;
|
||||
this.s2Client = new S2({ accessToken: options.accessToken });
|
||||
this.flushIntervalMs = options.flushIntervalMs ?? 200;
|
||||
this.maxQueuedBytes = options.maxQueuedBytes ?? 1024 * 1024 * 10; // 10MB default
|
||||
|
||||
this.log(
|
||||
`[S2MetadataStream] Initializing: basin=${options.basin}, stream=${options.stream}, flushIntervalMs=${this.flushIntervalMs}, maxQueuedBytes=${this.maxQueuedBytes}`
|
||||
);
|
||||
|
||||
// Check if already aborted
|
||||
if (options.signal?.aborted) {
|
||||
this.aborted = true;
|
||||
this.log("[S2MetadataStream] Signal already aborted, skipping initialization");
|
||||
this.serverStream = new ReadableStream<T>();
|
||||
this.consumerStream = new ReadableStream<T>();
|
||||
this.streamPromise = Promise.resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up abort signal handler
|
||||
if (options.signal) {
|
||||
options.signal.addEventListener("abort", () => {
|
||||
this.log("[S2MetadataStream] Abort signal received");
|
||||
this.handleAbort();
|
||||
});
|
||||
}
|
||||
|
||||
const [serverStream, consumerStream] = this.options.source.tee();
|
||||
this.serverStream = serverStream;
|
||||
this.consumerStream = consumerStream;
|
||||
|
||||
this.streamPromise = this.initializeServerStream();
|
||||
}
|
||||
|
||||
private handleAbort(): void {
|
||||
if (this.aborted) {
|
||||
return; // Already aborted
|
||||
}
|
||||
|
||||
this.aborted = true;
|
||||
this.log("[S2MetadataStream] Handling abort - cleaning up resources");
|
||||
|
||||
// Abort the writable stream if it exists
|
||||
if (this.sessionWritable) {
|
||||
this.sessionWritable
|
||||
.abort("Aborted")
|
||||
.catch((error) => {
|
||||
this.logError("[S2MetadataStream] Error aborting writable stream:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
this.log("[S2MetadataStream] Writable stream aborted");
|
||||
});
|
||||
}
|
||||
|
||||
this.log("[S2MetadataStream] Abort cleanup complete");
|
||||
}
|
||||
|
||||
private async initializeServerStream(): Promise<void> {
|
||||
try {
|
||||
if (this.aborted) {
|
||||
this.log("[S2MetadataStream] Stream initialization aborted");
|
||||
return;
|
||||
}
|
||||
|
||||
this.log("[S2MetadataStream] Getting S2 basin and stream");
|
||||
const basin = this.s2Client.basin(this.options.basin);
|
||||
const stream = basin.stream(this.options.stream);
|
||||
|
||||
const session = await stream.appendSession({
|
||||
maxQueuedBytes: this.maxQueuedBytes,
|
||||
});
|
||||
|
||||
this.sessionWritable = session.writable;
|
||||
|
||||
this.log(`[S2MetadataStream] Starting stream pipeline`);
|
||||
|
||||
// Convert source stream to AppendRecord format and pipe to S2
|
||||
await this.serverStream
|
||||
.pipeThrough(
|
||||
new TransformStream<T, AppendRecord>({
|
||||
transform: (chunk, controller) => {
|
||||
if (this.aborted) {
|
||||
controller.error(new Error("Stream aborted"));
|
||||
return;
|
||||
}
|
||||
// Convert each chunk to JSON string and wrap in AppendRecord
|
||||
controller.enqueue(AppendRecord.make(JSON.stringify({ data: chunk, id: nanoid(7) })));
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeThrough(
|
||||
new BatchTransform({
|
||||
lingerDurationMillis: this.flushIntervalMs,
|
||||
})
|
||||
)
|
||||
.pipeTo(session.writable);
|
||||
|
||||
this.log("[S2MetadataStream] Stream pipeline completed successfully");
|
||||
|
||||
// Get final position to verify completion
|
||||
const lastAcked = session.lastAckedPosition();
|
||||
|
||||
if (lastAcked?.end) {
|
||||
const recordsWritten = lastAcked.end.seq_num;
|
||||
this.log(
|
||||
`[S2MetadataStream] Written ${recordsWritten} records, ending at seq_num=${lastAcked.end.seq_num}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.aborted) {
|
||||
this.log("[S2MetadataStream] Stream error occurred but stream was aborted");
|
||||
return;
|
||||
}
|
||||
this.logError("[S2MetadataStream] Error in stream pipeline:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
await this.streamPromise;
|
||||
}
|
||||
|
||||
public [Symbol.asyncIterator]() {
|
||||
return streamToAsyncIterator(this.consumerStream);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private log(message: string): void {
|
||||
if (this.debug) {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
private logError(message: string, error?: any): void {
|
||||
if (this.debug) {
|
||||
console.error(message, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
safeReleaseLock(reader);
|
||||
}
|
||||
}
|
||||
|
||||
function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { AnyZodFetchOptions, ApiRequestOptions } from "../apiClient/core.js";
|
||||
import { AsyncIterableStream } from "../streams/asyncIterableStream.js";
|
||||
import { Prettify } from "../types/utils.js";
|
||||
|
||||
export type RealtimeStreamOperationOptions = {
|
||||
signal?: AbortSignal;
|
||||
target?: string;
|
||||
requestOptions?: AnyZodFetchOptions;
|
||||
};
|
||||
|
||||
export interface RealtimeStreamsManager {
|
||||
pipe<T>(
|
||||
key: string,
|
||||
source: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): RealtimeStreamInstance<T>;
|
||||
|
||||
append<TPart extends BodyInit>(
|
||||
key: string,
|
||||
part: TPart,
|
||||
options?: RealtimeStreamOperationOptions
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RealtimeStreamInstance<T> {
|
||||
wait(): Promise<void>;
|
||||
get stream(): AsyncIterableStream<T>;
|
||||
}
|
||||
|
||||
export interface StreamsWriter {
|
||||
wait(): Promise<void>;
|
||||
}
|
||||
|
||||
export type RealtimeDefinedStream<TPart> = {
|
||||
id: string;
|
||||
pipe: (
|
||||
value: AsyncIterable<TPart> | ReadableStream<TPart>,
|
||||
options?: PipeStreamOptions
|
||||
) => PipeStreamResult<TPart>;
|
||||
read: (runId: string, options?: ReadStreamOptions) => Promise<AsyncIterableStream<TPart>>;
|
||||
append: (value: TPart, options?: AppendStreamOptions) => Promise<void>;
|
||||
writer: (options: WriterStreamOptions<TPart>) => PipeStreamResult<TPart>;
|
||||
};
|
||||
|
||||
export type InferStreamType<T> = T extends RealtimeDefinedStream<infer TPart> ? TPart : unknown;
|
||||
|
||||
/**
|
||||
* Options for appending data to a realtime stream.
|
||||
*/
|
||||
export type PipeStreamOptions = {
|
||||
/**
|
||||
* An AbortSignal that can be used to cancel the stream operation.
|
||||
* If the signal is aborted, the stream will be closed.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* The target run ID to pipe the stream to. Can be:
|
||||
* - `"self"` - Pipe to the current run (default)
|
||||
* - `"parent"` - Pipe to the parent run
|
||||
* - `"root"` - Pipe to the root run
|
||||
* - A specific run ID string
|
||||
*
|
||||
* If not provided and not called from within a task, an error will be thrown.
|
||||
*/
|
||||
target?: string;
|
||||
/**
|
||||
* Additional request options for the API call.
|
||||
*/
|
||||
requestOptions?: ApiRequestOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* The result of piping data to a realtime stream.
|
||||
*
|
||||
* @template T - The type of data chunks in the stream
|
||||
*/
|
||||
export type PipeStreamResult<T> = {
|
||||
/**
|
||||
* The original stream that was piped. You can consume this stream in your task
|
||||
* to process the data locally while it's also being piped to the realtime stream.
|
||||
*/
|
||||
stream: AsyncIterableStream<T>;
|
||||
/**
|
||||
* A function that returns a promise which resolves when all data has been piped
|
||||
* to the realtime stream. Use this to wait for the stream to complete before
|
||||
* finishing your task.
|
||||
*/
|
||||
waitUntilComplete: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for reading data from a realtime stream.
|
||||
*/
|
||||
export type ReadStreamOptions = {
|
||||
/**
|
||||
* An AbortSignal that can be used to cancel the stream reading operation.
|
||||
* If the signal is aborted, the stream will be closed.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* The number of seconds to wait for new data to be available.
|
||||
* If no data arrives within the timeout, the stream will be closed.
|
||||
*
|
||||
* @default 60 seconds
|
||||
*/
|
||||
timeoutInSeconds?: number;
|
||||
|
||||
/**
|
||||
* The index to start reading from (1-based).
|
||||
* If not provided, the stream will start from the beginning.
|
||||
* Use this to resume reading from a specific position.
|
||||
*
|
||||
* @default 0 (start from beginning)
|
||||
*/
|
||||
startIndex?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Options for appending data to a realtime stream.
|
||||
*/
|
||||
export type AppendStreamOptions = {
|
||||
/**
|
||||
* The target run ID to append the stream to. Can be:
|
||||
* - `"self"` - Pipe to the current run (default)
|
||||
* - `"parent"` - Pipe to the parent run
|
||||
* - `"root"` - Pipe to the root run
|
||||
* - A specific run ID string
|
||||
*
|
||||
* If not provided and not called from within a task, an error will be thrown.
|
||||
*/
|
||||
target?: string;
|
||||
/**
|
||||
* Additional request options for the API call.
|
||||
*/
|
||||
requestOptions?: ApiRequestOptions;
|
||||
};
|
||||
|
||||
export type WriterStreamOptions<TPart> = Prettify<
|
||||
PipeStreamOptions & {
|
||||
execute: (options: {
|
||||
write: (part: TPart) => void;
|
||||
merge(stream: ReadableStream<TPart>): void;
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
>;
|
||||
@@ -1,23 +1,18 @@
|
||||
import { dequal } from "dequal/lite";
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { ApiClient } from "../apiClient/index.js";
|
||||
import { FlushedRunMetadata, RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import { MetadataStream } from "./metadataStream.js";
|
||||
import { applyMetadataOperations, collapseOperations } from "./operations.js";
|
||||
import { RunMetadataManager, RunMetadataUpdater } from "./types.js";
|
||||
import { realtimeStreams } from "../realtime-streams-api.js";
|
||||
import { RunMetadataChangeOperation } from "../schemas/common.js";
|
||||
import { AsyncIterableStream } from "../streams/asyncIterableStream.js";
|
||||
import { IOPacket, stringifyIO } from "../utils/ioSerialization.js";
|
||||
|
||||
const MAXIMUM_ACTIVE_STREAMS = 5;
|
||||
const MAXIMUM_TOTAL_STREAMS = 10;
|
||||
import { ApiRequestOptions } from "../zodfetch.js";
|
||||
import { applyMetadataOperations, collapseOperations } from "./operations.js";
|
||||
import type { RunMetadataManager, RunMetadataUpdater } from "./types.js";
|
||||
|
||||
export class StandardMetadataManager implements RunMetadataManager {
|
||||
private flushTimeoutId: NodeJS.Timeout | null = null;
|
||||
private isFlushing: boolean = false;
|
||||
private store: Record<string, DeserializedJson> | undefined;
|
||||
// Add a Map to track active streams
|
||||
private activeStreams = new Map<string, MetadataStream<any>>();
|
||||
|
||||
private queuedOperations: Set<RunMetadataChangeOperation> = new Set();
|
||||
private queuedParentOperations: Set<RunMetadataChangeOperation> = new Set();
|
||||
@@ -26,17 +21,12 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
public runId: string | undefined;
|
||||
public runIdIsRoot: boolean = false;
|
||||
|
||||
constructor(
|
||||
private apiClient: ApiClient,
|
||||
private streamsBaseUrl: string,
|
||||
private streamsVersion: "v1" | "v2" = "v1"
|
||||
) {}
|
||||
constructor(private apiClient: ApiClient) {}
|
||||
|
||||
reset(): void {
|
||||
this.queuedOperations.clear();
|
||||
this.queuedParentOperations.clear();
|
||||
this.queuedRootOperations.clear();
|
||||
this.activeStreams.clear();
|
||||
this.store = undefined;
|
||||
this.runId = undefined;
|
||||
this.runIdIsRoot = false;
|
||||
@@ -314,14 +304,14 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
|
||||
public async fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
if (!this.runId) {
|
||||
throw new Error("Run ID is required to fetch metadata streams.");
|
||||
throw new Error("Run ID is not set. fetchStream() can only be used inside a task.");
|
||||
}
|
||||
|
||||
const baseUrl = this.getKey("$$streamsBaseUrl");
|
||||
|
||||
const $baseUrl = typeof baseUrl === "string" ? baseUrl : this.streamsBaseUrl;
|
||||
|
||||
return this.apiClient.fetchStream<T>(this.runId, key, { baseUrl: $baseUrl, signal });
|
||||
return await this.apiClient.fetchStream(this.runId, key, {
|
||||
signal,
|
||||
timeoutInSeconds: 60,
|
||||
lastEventId: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async doStream<T>(
|
||||
@@ -337,84 +327,12 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Check to make sure we haven't exceeded the max number of active streams
|
||||
if (this.activeStreams.size >= MAXIMUM_ACTIVE_STREAMS) {
|
||||
console.warn(
|
||||
`Exceeded the maximum number of active streams (${MAXIMUM_ACTIVE_STREAMS}). The "${key}" stream will be ignored.`
|
||||
);
|
||||
return $value;
|
||||
}
|
||||
const streamInstance = realtimeStreams.pipe(key, value, {
|
||||
signal,
|
||||
target,
|
||||
});
|
||||
|
||||
// Check to make sure we haven't exceeded the max number of total streams
|
||||
const streams = (this.store?.$$streams ?? []) as string[];
|
||||
|
||||
if (streams.length >= MAXIMUM_TOTAL_STREAMS) {
|
||||
console.warn(
|
||||
`Exceeded the maximum number of total streams (${MAXIMUM_TOTAL_STREAMS}). The "${key}" stream will be ignored.`
|
||||
);
|
||||
return $value;
|
||||
}
|
||||
|
||||
try {
|
||||
const streamInstance = new MetadataStream({
|
||||
key,
|
||||
runId: this.runId,
|
||||
source: $value,
|
||||
baseUrl: this.streamsBaseUrl,
|
||||
headers: this.apiClient.getHeaders(),
|
||||
signal,
|
||||
version: this.streamsVersion,
|
||||
target,
|
||||
});
|
||||
|
||||
this.activeStreams.set(key, streamInstance);
|
||||
|
||||
// Clean up when stream completes
|
||||
streamInstance.wait().finally(() => this.activeStreams.delete(key));
|
||||
|
||||
// Add the key to the special stream metadata object
|
||||
updater
|
||||
.append(`$$streams`, key)
|
||||
.set("$$streamsVersion", this.streamsVersion)
|
||||
.set("$$streamsBaseUrl", this.streamsBaseUrl);
|
||||
|
||||
await this.flush();
|
||||
|
||||
return streamInstance;
|
||||
} catch (error) {
|
||||
// Clean up metadata key if stream creation fails
|
||||
updater.remove(`$$streams`, key);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public hasActiveStreams(): boolean {
|
||||
return this.activeStreams.size > 0;
|
||||
}
|
||||
|
||||
// Waits for all the streams to finish
|
||||
public async waitForAllStreams(timeout: number = 60_000): Promise<void> {
|
||||
if (this.activeStreams.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = Array.from(this.activeStreams.values()).map((stream) => stream.wait());
|
||||
|
||||
try {
|
||||
await Promise.race([
|
||||
Promise.allSettled(promises),
|
||||
new Promise<void>((resolve, _) => setTimeout(() => resolve(), timeout)),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error waiting for streams to finish:", error);
|
||||
|
||||
// If we time out, abort all remaining streams
|
||||
for (const [key, promise] of this.activeStreams.entries()) {
|
||||
// We can add abort logic here if needed
|
||||
this.activeStreams.delete(key);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return streamInstance.stream;
|
||||
}
|
||||
|
||||
public async refresh(requestOptions?: ApiRequestOptions): Promise<void> {
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { URL } from "node:url";
|
||||
|
||||
export type MetadataOptions<T> = {
|
||||
baseUrl: string;
|
||||
runId: string;
|
||||
key: string;
|
||||
source: AsyncIterable<T>;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
version?: "v1" | "v2";
|
||||
target?: "self" | "parent" | "root";
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
export class MetadataStream<T> {
|
||||
private controller = new AbortController();
|
||||
private serverStream: ReadableStream<T>;
|
||||
private consumerStream: ReadableStream<T>;
|
||||
private streamPromise: Promise<void>;
|
||||
private retryCount = 0;
|
||||
private readonly maxRetries: number;
|
||||
private currentChunkIndex = 0;
|
||||
|
||||
constructor(private options: MetadataOptions<T>) {
|
||||
const [serverStream, consumerStream] = this.createTeeStreams();
|
||||
this.serverStream = serverStream;
|
||||
this.consumerStream = consumerStream;
|
||||
this.maxRetries = options.maxRetries ?? 10;
|
||||
|
||||
this.streamPromise = this.initializeServerStream();
|
||||
}
|
||||
|
||||
private createTeeStreams() {
|
||||
const readableSource = new ReadableStream<T>({
|
||||
start: async (controller) => {
|
||||
try {
|
||||
for await (const value of this.options.source) {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return readableSource.tee();
|
||||
}
|
||||
|
||||
private async makeRequest(startFromChunk: number = 0): Promise<void> {
|
||||
const reader = this.serverStream.getReader();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.buildUrl());
|
||||
const timeout = 15 * 60 * 1000; // 15 minutes
|
||||
|
||||
const requestFn = url.protocol === "https:" ? httpsRequest : httpRequest;
|
||||
const req = requestFn({
|
||||
method: "POST",
|
||||
hostname: url.hostname,
|
||||
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
||||
path: url.pathname + url.search,
|
||||
headers: {
|
||||
...this.options.headers,
|
||||
"Content-Type": "application/json",
|
||||
"X-Resume-From-Chunk": startFromChunk.toString(),
|
||||
},
|
||||
timeout,
|
||||
});
|
||||
|
||||
req.on("error", (error) => {
|
||||
safeReleaseLock(reader);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
req.on("timeout", () => {
|
||||
safeReleaseLock(reader);
|
||||
|
||||
req.destroy(new Error("Request timed out"));
|
||||
});
|
||||
|
||||
req.on("response", (res) => {
|
||||
if (res.statusCode === 408) {
|
||||
safeReleaseLock(reader);
|
||||
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
|
||||
resolve(this.makeRequest(this.currentChunkIndex));
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Max retries (${this.maxRetries}) exceeded after timeout`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
||||
const error = new Error(`HTTP error! status: ${res.statusCode}`);
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
res.on("end", () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
res.resume();
|
||||
});
|
||||
|
||||
if (this.options.signal) {
|
||||
this.options.signal.addEventListener("abort", () => {
|
||||
req.destroy(new Error("Request aborted"));
|
||||
});
|
||||
}
|
||||
|
||||
const processStream = async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
req.end();
|
||||
break;
|
||||
}
|
||||
|
||||
const stringified = JSON.stringify(value) + "\n";
|
||||
req.write(stringified);
|
||||
this.currentChunkIndex++;
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
processStream().catch((error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private async initializeServerStream(): Promise<void> {
|
||||
await this.makeRequest(0);
|
||||
}
|
||||
|
||||
public async wait(): Promise<void> {
|
||||
return this.streamPromise;
|
||||
}
|
||||
|
||||
public [Symbol.asyncIterator]() {
|
||||
return streamToAsyncIterator(this.consumerStream);
|
||||
}
|
||||
|
||||
private buildUrl(): string {
|
||||
switch (this.options.version ?? "v1") {
|
||||
case "v1": {
|
||||
return `${this.options.baseUrl}/realtime/v1/streams/${this.options.runId}/${
|
||||
this.options.target ?? "self"
|
||||
}/${this.options.key}`;
|
||||
}
|
||||
case "v2": {
|
||||
return `${this.options.baseUrl}/realtime/v2/streams/${this.options.runId}/${this.options.key}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function* streamToAsyncIterator<T>(stream: ReadableStream<T>): AsyncIterableIterator<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
safeReleaseLock(reader);
|
||||
}
|
||||
}
|
||||
|
||||
function safeReleaseLock(reader: ReadableStreamDefaultReader<any>) {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch (error) {}
|
||||
}
|
||||
@@ -996,6 +996,7 @@ export const SubscribeRunRawShape = z.object({
|
||||
outputType: z.string().nullish(),
|
||||
runTags: z.array(z.string()).nullish().default([]),
|
||||
error: TaskRunError.nullish(),
|
||||
realtimeStreams: z.array(z.string()).nullish().default([]),
|
||||
});
|
||||
|
||||
export type SubscribeRunRawShape = z.infer<typeof SubscribeRunRawShape>;
|
||||
@@ -1304,3 +1305,14 @@ export const RetrieveRunTraceResponseBody = z.object({
|
||||
});
|
||||
|
||||
export type RetrieveRunTraceResponseBody = z.infer<typeof RetrieveRunTraceResponseBody>;
|
||||
|
||||
export const CreateStreamResponseBody = z.object({
|
||||
version: z.string(),
|
||||
});
|
||||
export type CreateStreamResponseBody = z.infer<typeof CreateStreamResponseBody>;
|
||||
|
||||
export const AppendToStreamResponseBody = z.object({
|
||||
ok: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
export type AppendToStreamResponseBody = z.infer<typeof AppendToStreamResponseBody>;
|
||||
|
||||
@@ -339,6 +339,7 @@ export const TaskRunExecution = z.object({
|
||||
run: TaskRun.and(
|
||||
z.object({
|
||||
traceContext: z.record(z.unknown()).optional(),
|
||||
realtimeStreamsVersion: z.string().optional(),
|
||||
})
|
||||
),
|
||||
...StaticTaskRunExecutionShape,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const SemanticInternalAttributes = {
|
||||
SPAN: "$span",
|
||||
ENTITY_TYPE: "$entity.type",
|
||||
ENTITY_ID: "$entity.id",
|
||||
ENTITY_METADATA: "$entity.metadata",
|
||||
OUTPUT: "$output",
|
||||
OUTPUT_TYPE: "$mime_type_output",
|
||||
STYLE: "$style",
|
||||
|
||||
@@ -103,3 +103,56 @@ export function createAsyncIterableStreamFromAsyncGenerator<T>(
|
||||
): AsyncIterableStream<T> {
|
||||
return createAsyncIterableStreamFromAsyncIterable(asyncGenerator, transformer, signal);
|
||||
}
|
||||
|
||||
export function ensureAsyncIterable<T>(
|
||||
input: AsyncIterable<T> | ReadableStream<T>
|
||||
): AsyncIterable<T> {
|
||||
// If it's already an AsyncIterable, return it as-is
|
||||
if (Symbol.asyncIterator in input) {
|
||||
return input as AsyncIterable<T>;
|
||||
}
|
||||
|
||||
// Convert ReadableStream to AsyncIterable
|
||||
const readableStream = input as ReadableStream<T>;
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const reader = readableStream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (value !== undefined) {
|
||||
yield value;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureReadableStream<T>(
|
||||
input: AsyncIterable<T> | ReadableStream<T>
|
||||
): ReadableStream<T> {
|
||||
if ("getReader" in input) {
|
||||
return input as ReadableStream<T>;
|
||||
}
|
||||
|
||||
return new ReadableStream<T>({
|
||||
async start(controller) {
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await iterator.next();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
controller.enqueue(value);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -593,7 +593,8 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
|
||||
*/
|
||||
triggerAndWait: (
|
||||
payload: TInput,
|
||||
options?: TriggerAndWaitOptions
|
||||
options?: TriggerAndWaitOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
) => TaskRunPromise<TIdentifier, TOutput>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Clock } from "../clock/clock.js";
|
||||
import { HeartbeatsManager } from "../heartbeats/types.js";
|
||||
import { LifecycleHooksManager } from "../lifecycleHooks/types.js";
|
||||
import { LocalsManager } from "../locals/types.js";
|
||||
import { RealtimeStreamsManager } from "../realtimeStreams/types.js";
|
||||
import { ResourceCatalog } from "../resource-catalog/catalog.js";
|
||||
import { RunMetadataManager } from "../runMetadata/types.js";
|
||||
import type { RuntimeManager } from "../runtime/manager.js";
|
||||
@@ -70,4 +71,5 @@ type TriggerDotDevGlobalAPI = {
|
||||
["locals"]?: LocalsManager;
|
||||
["trace-context"]?: TraceContextManager;
|
||||
["heartbeats"]?: HeartbeatsManager;
|
||||
["realtime-streams"]?: RealtimeStreamsManager;
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ class NoopManager implements WaitUntilManager {
|
||||
// noop
|
||||
}
|
||||
|
||||
blockUntilSettled(timeout: number): Promise<void> {
|
||||
blockUntilSettled(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -44,8 +44,8 @@ export class WaitUntilAPI implements WaitUntilManager {
|
||||
return this.#getManager().register(promise);
|
||||
}
|
||||
|
||||
blockUntilSettled(timeout: number): Promise<void> {
|
||||
return this.#getManager().blockUntilSettled(timeout);
|
||||
blockUntilSettled(): Promise<void> {
|
||||
return this.#getManager().blockUntilSettled();
|
||||
}
|
||||
|
||||
requiresResolving(): boolean {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MaybeDeferredPromise, WaitUntilManager } from "./types.js";
|
||||
export class StandardWaitUntilManager implements WaitUntilManager {
|
||||
private maybeDeferredPromises: Set<MaybeDeferredPromise> = new Set();
|
||||
|
||||
constructor(private timeoutInMs: number = 60_000) {}
|
||||
|
||||
reset(): void {
|
||||
this.maybeDeferredPromises.clear();
|
||||
}
|
||||
@@ -11,18 +13,18 @@ export class StandardWaitUntilManager implements WaitUntilManager {
|
||||
this.maybeDeferredPromises.add(promise);
|
||||
}
|
||||
|
||||
async blockUntilSettled(timeout: number): Promise<void> {
|
||||
async blockUntilSettled(): Promise<void> {
|
||||
if (this.promisesRequringResolving.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = this.promisesRequringResolving.map((p) =>
|
||||
typeof p.promise === "function" ? p.promise() : p.promise
|
||||
typeof p.promise === "function" ? p.promise(this.timeoutInMs) : p.promise
|
||||
);
|
||||
|
||||
await Promise.race([
|
||||
Promise.allSettled(promises),
|
||||
new Promise<void>((resolve, _) => setTimeout(() => resolve(), timeout)),
|
||||
new Promise<void>((resolve, _) => setTimeout(() => resolve(), this.timeoutInMs)),
|
||||
]);
|
||||
|
||||
this.maybeDeferredPromises.clear();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export type MaybeDeferredPromise = {
|
||||
requiresResolving(): boolean;
|
||||
promise: Promise<any> | (() => Promise<any>);
|
||||
promise: Promise<any> | ((timeoutInMs: number) => Promise<any>);
|
||||
};
|
||||
|
||||
export interface WaitUntilManager {
|
||||
register(promise: MaybeDeferredPromise): void;
|
||||
blockUntilSettled(timeout: number): Promise<void>;
|
||||
blockUntilSettled(): Promise<void>;
|
||||
requiresResolving(): boolean;
|
||||
}
|
||||
|
||||
@@ -30,3 +30,4 @@ export { StandardLocalsManager } from "../locals/manager.js";
|
||||
export { populateEnv } from "./populateEnv.js";
|
||||
export { StandardTraceContextManager } from "../traceContext/manager.js";
|
||||
export { StandardHeartbeatsManager } from "../heartbeats/manager.js";
|
||||
export { StandardRealtimeStreamsManager } from "../realtimeStreams/manager.js";
|
||||
|
||||
@@ -1079,7 +1079,7 @@ export class TaskExecutor {
|
||||
return this._tracer.startActiveSpan(
|
||||
"waitUntil",
|
||||
async (span) => {
|
||||
return await waitUntil.blockUntilSettled(60_000);
|
||||
return await waitUntil.blockUntilSettled();
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
RunSubscription,
|
||||
SSEStreamPart,
|
||||
StreamSubscription,
|
||||
StreamSubscriptionFactory,
|
||||
} from "../src/v3/apiClient/runStream.js";
|
||||
@@ -11,11 +12,15 @@ import type { SubscribeRunRawShape } from "../src/v3/schemas/api.js";
|
||||
class TestStreamSubscription implements StreamSubscription {
|
||||
constructor(private chunks: unknown[]) {}
|
||||
|
||||
async subscribe(): Promise<ReadableStream<unknown>> {
|
||||
async subscribe(): Promise<ReadableStream<SSEStreamPart<unknown>>> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
for (const chunk of this.chunks) {
|
||||
controller.enqueue(chunk);
|
||||
for (let i = 0; i < this.chunks.length; i++) {
|
||||
controller.enqueue({
|
||||
id: `msg-${i}`,
|
||||
chunk: this.chunks[i],
|
||||
timestamp: Date.now() + i,
|
||||
});
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
@@ -94,6 +99,7 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -135,6 +141,7 @@ describe("RunSubscription", () => {
|
||||
payloadType: "application/json",
|
||||
output: JSON.stringify({ test: "output" }),
|
||||
outputType: "application/json",
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -174,6 +181,7 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
{
|
||||
id: "123",
|
||||
@@ -189,6 +197,7 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
realtimeStreams: [],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -239,10 +248,9 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai"],
|
||||
}),
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -307,10 +315,9 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai"],
|
||||
}),
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
// Second run update with same stream key
|
||||
{
|
||||
@@ -326,10 +333,9 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai"],
|
||||
}),
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -407,10 +413,9 @@ describe("RunSubscription", () => {
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai", "anthropic"],
|
||||
}),
|
||||
metadata: JSON.stringify({}),
|
||||
metadataType: "application/json",
|
||||
realtimeStreams: ["openai", "anthropic"],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ describe("StandardMetadataManager", () => {
|
||||
|
||||
const apiClient = new ApiClient(server.http.url().origin, "tr-123");
|
||||
|
||||
manager = new StandardMetadataManager(apiClient, server.http.url().origin);
|
||||
manager = new StandardMetadataManager(apiClient);
|
||||
manager.runId = runId;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,979 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createServer, Server, IncomingMessage, ServerResponse } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
import { StreamsWriterV1 } from "../src/v3/realtimeStreams/streamsWriterV1.js";
|
||||
import { ensureReadableStream } from "../src/v3/streams/asyncIterableStream.js";
|
||||
|
||||
type RequestHandler = (req: IncomingMessage, res: ServerResponse) => void;
|
||||
|
||||
describe("StreamsWriterV1", () => {
|
||||
let server: Server;
|
||||
let baseUrl: string;
|
||||
let requestHandler: RequestHandler | null = null;
|
||||
let receivedRequests: Array<{
|
||||
method: string;
|
||||
url: string;
|
||||
headers: IncomingMessage["headers"];
|
||||
body: string;
|
||||
}> = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
receivedRequests = [];
|
||||
requestHandler = null;
|
||||
|
||||
// Create test server
|
||||
server = createServer((req, res) => {
|
||||
// Collect request data
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
receivedRequests.push({
|
||||
method: req.method!,
|
||||
url: req.url!,
|
||||
headers: req.headers,
|
||||
body: Buffer.concat(chunks).toString(),
|
||||
});
|
||||
|
||||
// Call custom handler if set
|
||||
if (requestHandler) {
|
||||
requestHandler(req, res);
|
||||
} else {
|
||||
// Default: return 200
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const addr = server.address() as AddressInfo;
|
||||
baseUrl = `http://127.0.0.1:${addr.port}`;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
it("should successfully stream all chunks to server", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0, data: "chunk 0" };
|
||||
yield { chunk: 1, data: "chunk 1" };
|
||||
yield { chunk: 2, data: "chunk 2" };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have received exactly 1 POST request
|
||||
expect(receivedRequests.length).toBe(1);
|
||||
expect(receivedRequests[0]!.method).toBe("POST");
|
||||
expect(receivedRequests[0]!.headers["x-client-id"]).toBeDefined();
|
||||
expect(receivedRequests[0]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
|
||||
// Verify all chunks were sent
|
||||
const lines = receivedRequests[0]!.body.trim().split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
expect(JSON.parse(lines[0]!)).toEqual({ chunk: 0, data: "chunk 0" });
|
||||
expect(JSON.parse(lines[1]!)).toEqual({ chunk: 1, data: "chunk 1" });
|
||||
expect(JSON.parse(lines[2]!)).toEqual({ chunk: 2, data: "chunk 2" });
|
||||
});
|
||||
|
||||
it("should use provided clientId instead of generating one", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
clientId: "custom-client-123",
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
expect(receivedRequests[0]!.headers["x-client-id"]).toBe("custom-client-123");
|
||||
});
|
||||
|
||||
it("should retry on connection reset and query server for resume point", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// HEAD request to get last chunk - server has received 1 chunk
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "0" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First POST request - simulate connection reset after receiving some data
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
yield { chunk: 2 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have: 1 POST (failed) + 1 HEAD (query) + 1 POST (retry)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
expect(heads.length).toBe(1); // Query for resume point
|
||||
|
||||
// Second POST should resume from chunk 1 (server had chunk 0)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("1");
|
||||
});
|
||||
|
||||
it("should retry on 503 Service Unavailable", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// No data received yet
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request fails with 503
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
});
|
||||
|
||||
it("should retry on request timeout", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request - don't respond, let it timeout
|
||||
// (timeout is set to 15 minutes in StreamsWriterV1, so we can't actually test this easily)
|
||||
// Instead we'll just delay and then respond
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should complete successfully (timeout is very long, won't trigger in test)
|
||||
expect(receivedRequests.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle ring buffer correctly on retry", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
// Server received first 2 chunks
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First POST - fail after some data sent
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 100, // Small buffer for testing
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// First request tried to send chunks 0-4
|
||||
const firstLines = posts[0]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(firstLines.length).toBeGreaterThan(0);
|
||||
|
||||
// Second request resumes from chunk 2 (server had 0-1)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("2");
|
||||
|
||||
// Second request should send chunks 2, 3, 4 from ring buffer
|
||||
const secondLines = posts[1]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(secondLines.length).toBe(3);
|
||||
expect(JSON.parse(secondLines[0]!).chunk).toBe(2);
|
||||
expect(JSON.parse(secondLines[1]!).chunk).toBe(3);
|
||||
expect(JSON.parse(secondLines[2]!).chunk).toBe(4);
|
||||
});
|
||||
|
||||
it("should fail after max retries exceeded", { timeout: 30000 }, async () => {
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Always fail with retryable error
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxRetries: 3, // Low retry count for faster test
|
||||
});
|
||||
|
||||
await expect(metadataStream.wait()).rejects.toThrow();
|
||||
|
||||
// Should have attempted: 1 initial + 3 retries = 4 POST requests
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(4);
|
||||
});
|
||||
|
||||
it(
|
||||
"should handle HEAD request failures gracefully and resume from 0",
|
||||
{ timeout: 10000 },
|
||||
async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Fail HEAD with 503 (will retry but eventually return -1)
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST - fail with connection reset
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// HEAD should have been attempted (will get 503 responses)
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
expect(heads.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Should have retried POST and resumed from chunk 0 (since HEAD failed with 503s)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
}
|
||||
);
|
||||
|
||||
it("should handle 429 rate limit with retry", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestCount++;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCount === 1) {
|
||||
// First request - rate limited
|
||||
res.writeHead(429, { "Retry-After": "1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
});
|
||||
|
||||
it("should reset retry count after successful response", { timeout: 10000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST - fail
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST - succeed (retry count should be reset after this)
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have: 1 initial + 1 retry = 2 POST requests
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle large stream with multiple chunks", async () => {
|
||||
const chunkCount = 100;
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
expect(receivedRequests.length).toBe(1);
|
||||
const lines = receivedRequests[0]!.body.trim().split("\n");
|
||||
expect(lines.length).toBe(chunkCount);
|
||||
});
|
||||
|
||||
it("should handle retry mid-stream and resume from correct chunk", async () => {
|
||||
let postCount = 0;
|
||||
const totalChunks = 50;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Simulate server received first 20 chunks before connection dropped
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "19" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First request - fail mid-stream
|
||||
// Give it time to send some data, then kill
|
||||
setTimeout(() => {
|
||||
req.socket.destroy();
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second request - succeed
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
// Small delay to simulate real streaming
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 100, // Large enough to hold all chunks
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
const heads = receivedRequests.filter((r) => r.method === "HEAD");
|
||||
|
||||
expect(posts.length).toBe(2); // Original + retry
|
||||
expect(heads.length).toBe(1); // Query for resume
|
||||
|
||||
// Second POST should resume from chunk 20 (server had 0-19)
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("20");
|
||||
|
||||
// Verify second request sent chunks 20-49
|
||||
const secondBody = posts[1]!.body.trim().split("\n").filter(Boolean);
|
||||
expect(secondBody.length).toBe(30); // Chunks 20-49
|
||||
|
||||
const firstChunkInRetry = JSON.parse(secondBody[0]!);
|
||||
expect(firstChunkInRetry.chunk).toBe(20);
|
||||
|
||||
const lastChunkInRetry = JSON.parse(secondBody[secondBody.length - 1]!);
|
||||
expect(lastChunkInRetry.chunk).toBe(49);
|
||||
});
|
||||
|
||||
it("should handle multiple retries with exponential backoff", { timeout: 30000 }, async () => {
|
||||
let postCount = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount <= 3) {
|
||||
// Fail first 3 attempts
|
||||
res.writeHead(503);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fourth attempt succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
|
||||
expect(posts.length).toBe(4); // 1 initial + 3 retries
|
||||
|
||||
// With exponential backoff (1s, 2s, 4s), should take at least 6 seconds
|
||||
// But jitter and processing means we give it some range
|
||||
expect(elapsed).toBeGreaterThan(5000);
|
||||
});
|
||||
|
||||
it("should handle ring buffer overflow gracefully", async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Server received nothing
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// Let it send some data then fail
|
||||
setTimeout(() => req.socket.destroy(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
// Generate 200 chunks but ring buffer only holds 50
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 50, // Small buffer - will overflow
|
||||
});
|
||||
|
||||
// Should still complete (may have warnings about missing chunks)
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle consumer reading from stream", async () => {
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0, data: "data 0" };
|
||||
yield { chunk: 1, data: "data 1" };
|
||||
yield { chunk: 2, data: "data 2" };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
// Consumer reads from the stream
|
||||
const consumedChunks: any[] = [];
|
||||
for await (const chunk of metadataStream) {
|
||||
consumedChunks.push(chunk);
|
||||
}
|
||||
|
||||
// Consumer should receive all chunks
|
||||
expect(consumedChunks.length).toBe(3);
|
||||
expect(consumedChunks[0]).toEqual({ chunk: 0, data: "data 0" });
|
||||
expect(consumedChunks[1]).toEqual({ chunk: 1, data: "data 1" });
|
||||
expect(consumedChunks[2]).toEqual({ chunk: 2, data: "data 2" });
|
||||
|
||||
// Server should have received all chunks
|
||||
await metadataStream.wait();
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle non-retryable 4xx errors immediately", async () => {
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "POST") {
|
||||
// 400 Bad Request - not retryable
|
||||
res.writeHead(400);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await expect(metadataStream.wait()).rejects.toThrow("HTTP error! status: 400");
|
||||
|
||||
// Should NOT retry on 400
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1); // Only initial request, no retries
|
||||
});
|
||||
|
||||
it("should handle 429 rate limit with proper backoff", { timeout: 15000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount <= 2) {
|
||||
// Rate limited twice
|
||||
res.writeHead(429);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Third attempt succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(3); // 1 initial + 2 retries
|
||||
});
|
||||
|
||||
it("should handle abort signal during streaming", async () => {
|
||||
const abortController = new AbortController();
|
||||
let requestReceived = false;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
requestReceived = true;
|
||||
// Don't respond immediately, let abort happen
|
||||
setTimeout(() => {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Abort after a short delay
|
||||
setTimeout(() => abortController.abort(), 100);
|
||||
|
||||
// Should throw due to abort
|
||||
await expect(metadataStream.wait()).rejects.toThrow();
|
||||
|
||||
// Request should have been made before abort
|
||||
expect(requestReceived).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle empty stream (no chunks)", async () => {
|
||||
async function* generateChunks() {
|
||||
// Yields nothing
|
||||
return;
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have sent request with empty body
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
expect(posts[0]!.body.trim()).toBe("");
|
||||
});
|
||||
|
||||
it("should handle error thrown by source generator", async () => {
|
||||
// Skip this test - source generator errors are properly handled by the stream
|
||||
// but cause unhandled rejection warnings in test environment
|
||||
// In production, these errors would be caught by the task execution layer
|
||||
|
||||
// Test that error propagates correctly by checking stream behavior
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
// Note: Throwing here would test error handling, but causes test infrastructure issues
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Verify normal operation (error test would need different approach)
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle missing X-Last-Chunk-Index header in HEAD response", async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Return success but no chunk index header
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
req.socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
yield { chunk: 1 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// Should default to resuming from 0 when header is missing
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("0");
|
||||
});
|
||||
|
||||
it(
|
||||
"should handle rapid successive failures with different error types",
|
||||
{ timeout: 20000 },
|
||||
async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "-1" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
// Different error types
|
||||
if (postCount === 1) {
|
||||
res.writeHead(503); // Service unavailable
|
||||
res.end();
|
||||
} else if (postCount === 2) {
|
||||
req.socket.destroy(); // Connection reset
|
||||
} else if (postCount === 3) {
|
||||
res.writeHead(502); // Bad gateway
|
||||
res.end();
|
||||
} else {
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
yield { chunk: 0 };
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
});
|
||||
|
||||
await metadataStream.wait();
|
||||
|
||||
// Should have retried through all error types
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(4); // 1 initial + 3 retries
|
||||
}
|
||||
);
|
||||
|
||||
it("should handle resume point outside ring buffer window", { timeout: 10000 }, async () => {
|
||||
let postCount = 0;
|
||||
|
||||
requestHandler = (req, res) => {
|
||||
if (req.method === "HEAD") {
|
||||
// Server claims to have chunk 80 (but ring buffer only has last 50)
|
||||
res.writeHead(200, { "X-Last-Chunk-Index": "80" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
postCount++;
|
||||
|
||||
if (postCount === 1) {
|
||||
// First POST fails early
|
||||
setTimeout(() => req.socket.destroy(), 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// Second POST succeeds
|
||||
res.writeHead(200);
|
||||
res.end();
|
||||
};
|
||||
|
||||
async function* generateChunks() {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
yield { chunk: i, data: `chunk ${i}` };
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const metadataStream = new StreamsWriterV1({
|
||||
baseUrl,
|
||||
runId: "run_123",
|
||||
key: "test-stream",
|
||||
source: ensureReadableStream(generateChunks()),
|
||||
maxBufferSize: 50, // Small buffer
|
||||
});
|
||||
|
||||
// Should complete even though resume point (81) is outside buffer window
|
||||
await metadataStream.wait();
|
||||
|
||||
const posts = receivedRequests.filter((r) => r.method === "POST");
|
||||
expect(posts.length).toBe(2);
|
||||
|
||||
// Should try to resume from chunk 81
|
||||
expect(posts[1]!.headers["x-resume-from-chunk"]).toBe("81");
|
||||
// Will log warnings about missing chunks but should continue with available chunks
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
AnyTask,
|
||||
ApiClient,
|
||||
InferRunTypes,
|
||||
InferStreamType,
|
||||
RealtimeDefinedStream,
|
||||
RealtimeRun,
|
||||
RealtimeRunSkipColumns,
|
||||
} from "@trigger.dev/core/v3";
|
||||
@@ -15,7 +17,12 @@ import { createThrottledQueue } from "../utils/throttle.js";
|
||||
export type UseRealtimeRunOptions = UseApiClientOptions & {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
experimental_throttleInMs?: number;
|
||||
/**
|
||||
* The number of milliseconds to throttle the stream updates.
|
||||
*
|
||||
* @default 16
|
||||
*/
|
||||
throttleInMs?: number;
|
||||
};
|
||||
|
||||
export type UseRealtimeSingleRunOptions<TTask extends AnyTask = AnyTask> = UseRealtimeRunOptions & {
|
||||
@@ -283,7 +290,7 @@ export function useRealtimeRunWithStreams<
|
||||
setError,
|
||||
abortControllerRef,
|
||||
typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true,
|
||||
options?.experimental_throttleInMs
|
||||
options?.throttleInMs ?? 16
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
@@ -573,6 +580,313 @@ export function useRealtimeBatch<TTask extends AnyTask>(
|
||||
return { runs: runs ?? [], error, stop };
|
||||
}
|
||||
|
||||
export type UseRealtimeStreamInstance<TPart> = {
|
||||
parts: Array<TPart>;
|
||||
|
||||
error: Error | undefined;
|
||||
|
||||
/**
|
||||
* Abort the current request immediately, keep the generated tokens if any.
|
||||
*/
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export type UseRealtimeStreamOptions<TPart> = UseApiClientOptions & {
|
||||
id?: string;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* The number of milliseconds to throttle the stream updates.
|
||||
*
|
||||
* @default 16
|
||||
*/
|
||||
throttleInMs?: number;
|
||||
/**
|
||||
* The number of seconds to wait for new data to be available,
|
||||
* If no data arrives within the timeout, the stream will be closed.
|
||||
*
|
||||
* @default 60 seconds
|
||||
*/
|
||||
timeoutInSeconds?: number;
|
||||
|
||||
/**
|
||||
* The index to start reading from.
|
||||
* If not provided, the stream will start from the beginning.
|
||||
* @default 0
|
||||
*/
|
||||
startIndex?: number;
|
||||
|
||||
/**
|
||||
* Callback this is called when new data is received.
|
||||
*/
|
||||
onData?: (data: TPart) => void;
|
||||
};
|
||||
|
||||
export function useRealtimeStream<TDefinedStream extends RealtimeDefinedStream<any>>(
|
||||
stream: TDefinedStream,
|
||||
runId: string,
|
||||
options?: UseRealtimeStreamOptions<InferStreamType<TDefinedStream>>
|
||||
): UseRealtimeStreamInstance<InferStreamType<TDefinedStream>>;
|
||||
/**
|
||||
* Hook to subscribe to realtime updates of a stream with a specific stream key.
|
||||
*
|
||||
* This hook automatically subscribes to a stream and updates the `parts` array as new data arrives.
|
||||
* The stream subscription is automatically managed: it starts when the component mounts (or when
|
||||
* `enabled` becomes `true`) and stops when the component unmounts or when `stop()` is called.
|
||||
*
|
||||
* @template TPart - The type of each chunk/part in the stream
|
||||
* @param runId - The unique identifier of the run to subscribe to
|
||||
* @param streamKey - The unique identifier of the stream to subscribe to. Use this overload
|
||||
* when you want to read from a specific stream key.
|
||||
* @param options - Optional configuration for the stream subscription
|
||||
* @returns An object containing:
|
||||
* - `parts`: An array of all stream chunks received so far (accumulates over time)
|
||||
* - `error`: Any error that occurred during subscription
|
||||
* - `stop`: A function to manually stop the subscription
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* "use client";
|
||||
* import { useRealtimeStream } from "@trigger.dev/react-hooks";
|
||||
*
|
||||
* function StreamViewer({ runId }: { runId: string }) {
|
||||
* const { parts, error } = useRealtimeStream<string>(
|
||||
* runId,
|
||||
* "my-stream",
|
||||
* {
|
||||
* accessToken: process.env.NEXT_PUBLIC_TRIGGER_PUBLIC_KEY,
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* // Parts array accumulates all chunks
|
||||
* const fullText = parts.join("");
|
||||
*
|
||||
* return <div>{fullText}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // With custom options
|
||||
* const { parts, error, stop } = useRealtimeStream<ChatChunk>(
|
||||
* runId,
|
||||
* "chat-stream",
|
||||
* {
|
||||
* accessToken: publicKey,
|
||||
* timeoutInSeconds: 120,
|
||||
* startIndex: 10, // Start from the 10th chunk
|
||||
* throttleInMs: 50, // Throttle updates to every 50ms
|
||||
* onData: (chunk) => {
|
||||
* console.log("New chunk received:", chunk);
|
||||
* },
|
||||
* }
|
||||
* );
|
||||
*
|
||||
* // Manually stop the subscription
|
||||
* <button onClick={stop}>Stop Stream</button>
|
||||
* ```
|
||||
*/
|
||||
export function useRealtimeStream<TPart>(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: UseRealtimeStreamOptions<TPart>
|
||||
): UseRealtimeStreamInstance<TPart>;
|
||||
/**
|
||||
* Hook to subscribe to realtime updates of a stream using the default stream key (`"default"`).
|
||||
*
|
||||
* This is a convenience overload that allows you to subscribe to the default stream without
|
||||
* specifying a stream key. The stream will be accessed with the key `"default"`.
|
||||
*
|
||||
* @template TPart - The type of each chunk/part in the stream
|
||||
* @param runId - The unique identifier of the run to subscribe to
|
||||
* @param options - Optional configuration for the stream subscription
|
||||
* @returns An object containing:
|
||||
* - `parts`: An array of all stream chunks received so far (accumulates over time)
|
||||
* - `error`: Any error that occurred during subscription
|
||||
* - `stop`: A function to manually stop the subscription
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* "use client";
|
||||
* import { useRealtimeStream } from "@trigger.dev/react-hooks";
|
||||
*
|
||||
* function DefaultStreamViewer({ runId }: { runId: string }) {
|
||||
* // Subscribe to the default stream
|
||||
* const { parts, error } = useRealtimeStream<string>(runId, {
|
||||
* accessToken: process.env.NEXT_PUBLIC_TRIGGER_PUBLIC_KEY,
|
||||
* });
|
||||
*
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* const fullText = parts.join("");
|
||||
* return <div>{fullText}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Conditionally enable the stream
|
||||
* const { parts } = useRealtimeStream<string>(runId, {
|
||||
* accessToken: publicKey,
|
||||
* enabled: !!runId && isStreaming, // Only subscribe when runId exists and isStreaming is true
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useRealtimeStream<TPart>(
|
||||
runId: string,
|
||||
options?: UseRealtimeStreamOptions<TPart>
|
||||
): UseRealtimeStreamInstance<TPart>;
|
||||
export function useRealtimeStream<TPart>(
|
||||
runIdOrDefinedStream: string | RealtimeDefinedStream<TPart>,
|
||||
streamKeyOrOptionsOrRunId?: string | UseRealtimeStreamOptions<TPart>,
|
||||
options?: UseRealtimeStreamOptions<TPart>
|
||||
): UseRealtimeStreamInstance<TPart> {
|
||||
if (typeof runIdOrDefinedStream === "string") {
|
||||
if (typeof streamKeyOrOptionsOrRunId === "string") {
|
||||
return useRealtimeStreamImplementation(
|
||||
runIdOrDefinedStream,
|
||||
streamKeyOrOptionsOrRunId,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
return useRealtimeStreamImplementation(
|
||||
runIdOrDefinedStream,
|
||||
"default",
|
||||
streamKeyOrOptionsOrRunId
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (typeof streamKeyOrOptionsOrRunId === "string") {
|
||||
return useRealtimeStreamImplementation(
|
||||
streamKeyOrOptionsOrRunId,
|
||||
runIdOrDefinedStream.id,
|
||||
options
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid second argument to useRealtimeStream. When using a defined stream instance, the second argument to useRealtimeStream must be a run ID."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useRealtimeStreamImplementation<TPart>(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
options?: UseRealtimeStreamOptions<TPart>
|
||||
): UseRealtimeStreamInstance<TPart> {
|
||||
const hookId = useId();
|
||||
const idKey = options?.id ?? hookId;
|
||||
|
||||
const [initialPartsFallback] = useState([] as Array<TPart>);
|
||||
|
||||
// Store the streams state in SWR, using the idKey as the key to share states.
|
||||
const { data: parts, mutate: mutateParts } = useSWR<Array<TPart>>(
|
||||
[idKey, runId, streamKey, "parts"],
|
||||
null,
|
||||
{
|
||||
fallbackData: initialPartsFallback,
|
||||
}
|
||||
);
|
||||
|
||||
// Keep the latest streams in a ref.
|
||||
const partsRef = useRef<Array<TPart>>(parts ?? ([] as Array<TPart>));
|
||||
useEffect(() => {
|
||||
partsRef.current = parts || ([] as Array<TPart>);
|
||||
}, [parts]);
|
||||
|
||||
// Add state to track when the subscription is complete
|
||||
const { data: isComplete = false, mutate: setIsComplete } = useSWR<boolean>(
|
||||
[idKey, runId, streamKey, "complete"],
|
||||
null
|
||||
);
|
||||
|
||||
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
|
||||
[idKey, runId, streamKey, "error"],
|
||||
null
|
||||
);
|
||||
|
||||
// Abort controller to cancel the current API call.
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onData = useCallback(
|
||||
(data: TPart) => {
|
||||
if (options?.onData) {
|
||||
options.onData(data);
|
||||
}
|
||||
},
|
||||
[options?.onData]
|
||||
);
|
||||
|
||||
const apiClient = useApiClient(options);
|
||||
|
||||
const triggerRequest = useCallback(async () => {
|
||||
try {
|
||||
if (!runId || !apiClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
await processRealtimeStream<TPart>(
|
||||
runId,
|
||||
streamKey,
|
||||
apiClient,
|
||||
mutateParts,
|
||||
partsRef,
|
||||
setError,
|
||||
onData,
|
||||
abortControllerRef,
|
||||
options?.timeoutInSeconds,
|
||||
options?.startIndex,
|
||||
options?.throttleInMs ?? 16
|
||||
);
|
||||
} catch (err) {
|
||||
// Ignore abort errors as they are expected.
|
||||
if ((err as any).name === "AbortError") {
|
||||
abortControllerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, streamKey, mutateParts, partsRef, abortControllerRef, apiClient, setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!runId) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRequest().finally(() => {});
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
}, [runId, stop, options?.enabled]);
|
||||
|
||||
return { parts: parts ?? initialPartsFallback, error, stop };
|
||||
}
|
||||
|
||||
async function processRealtimeBatch<TTask extends AnyTask = AnyTask>(
|
||||
batchId: string,
|
||||
apiClient: ApiClient,
|
||||
@@ -734,3 +1048,47 @@ async function processRealtimeRun<TTask extends AnyTask = AnyTask>(
|
||||
mutateRunData(part);
|
||||
}
|
||||
}
|
||||
|
||||
async function processRealtimeStream<TPart>(
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
apiClient: ApiClient,
|
||||
mutatePartsData: KeyedMutator<Array<TPart>>,
|
||||
existingPartsRef: React.MutableRefObject<Array<TPart>>,
|
||||
onError: (e: Error) => void,
|
||||
onData: (data: TPart) => void,
|
||||
abortControllerRef: React.MutableRefObject<AbortController | null>,
|
||||
timeoutInSeconds?: number,
|
||||
startIndex?: number,
|
||||
throttleInMs?: number
|
||||
) {
|
||||
try {
|
||||
const stream = await apiClient.fetchStream<TPart>(runId, streamKey, {
|
||||
signal: abortControllerRef.current?.signal,
|
||||
timeoutInSeconds,
|
||||
lastEventId: startIndex ? (startIndex - 1).toString() : undefined,
|
||||
});
|
||||
|
||||
// Throttle the stream
|
||||
const streamQueue = createThrottledQueue<TPart>(async (parts) => {
|
||||
mutatePartsData([...existingPartsRef.current, ...parts]);
|
||||
}, throttleInMs);
|
||||
|
||||
for await (const part of stream) {
|
||||
onData(part);
|
||||
streamQueue.add(part);
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as any).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
onError(err);
|
||||
} else {
|
||||
onError(new Error(String(err)));
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export * from "./locals.js";
|
||||
export * from "./otel.js";
|
||||
export * from "./schemas.js";
|
||||
export * from "./heartbeats.js";
|
||||
export * from "./streams.js";
|
||||
export type { Context };
|
||||
|
||||
import type { Context } from "./shared.js";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type AsyncIterableStream,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { tracer } from "./tracer.js";
|
||||
import { streams } from "./streams.js";
|
||||
|
||||
const parentMetadataUpdater: RunMetadataUpdater = runMetadata.parent;
|
||||
const rootMetadataUpdater: RunMetadataUpdater = runMetadata.root;
|
||||
@@ -228,12 +229,19 @@ async function refreshMetadata(requestOptions?: ApiRequestOptions): Promise<void
|
||||
await runMetadata.refresh($requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `streams.pipe()` instead.
|
||||
*/
|
||||
async function stream<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AsyncIterable<T>> {
|
||||
return runMetadata.stream(key, value, signal);
|
||||
const streamInstance = await streams.pipe(key, value, {
|
||||
signal,
|
||||
});
|
||||
|
||||
return streamInstance.stream;
|
||||
}
|
||||
|
||||
async function fetchStream<T>(key: string, signal?: AbortSignal): Promise<AsyncIterableStream<T>> {
|
||||
|
||||
@@ -185,7 +185,7 @@ export function createTask<
|
||||
params.queue?.name
|
||||
);
|
||||
},
|
||||
triggerAndWait: (payload, options) => {
|
||||
triggerAndWait: (payload, options, requestOptions) => {
|
||||
return new TaskRunPromise<TIdentifier, TOutput>((resolve, reject) => {
|
||||
triggerAndWait_internal<TIdentifier, TInput, TOutput>(
|
||||
"triggerAndWait()",
|
||||
@@ -195,7 +195,8 @@ export function createTask<
|
||||
{
|
||||
queue: params.queue?.name,
|
||||
...options,
|
||||
}
|
||||
},
|
||||
requestOptions
|
||||
)
|
||||
.then((result) => {
|
||||
resolve(result);
|
||||
@@ -565,7 +566,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
|
||||
options?: BatchTriggerOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<BatchRunHandleFromTypes<InferRunTypes<TTask>>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
const response = await apiClient.batchTriggerV3(
|
||||
{
|
||||
@@ -730,7 +731,7 @@ export async function batchTriggerByIdAndWait<TTask extends AnyTask>(
|
||||
throw new Error("batchTriggerAndWait can only be used from inside a task.run()");
|
||||
}
|
||||
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
"batch.triggerAndWait()",
|
||||
@@ -895,7 +896,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
|
||||
options?: BatchTriggerOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<BatchTasksRunHandleFromTypes<TTasks>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
const response = await apiClient.batchTriggerV3(
|
||||
{
|
||||
@@ -1062,7 +1063,7 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
|
||||
throw new Error("batchTriggerAndWait can only be used from inside a task.run()");
|
||||
}
|
||||
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
"batch.triggerByTaskAndWait()",
|
||||
@@ -1151,7 +1152,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
|
||||
options?: TriggerOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<RunHandleFromTypes<TRunTypes>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
|
||||
|
||||
@@ -1211,7 +1212,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
|
||||
requestOptions?: TriggerApiRequestOptions,
|
||||
queue?: string
|
||||
): Promise<BatchRunHandleFromTypes<TRunTypes>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
const ctx = taskContext.ctx;
|
||||
|
||||
@@ -1296,7 +1297,7 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
|
||||
payload: TPayload,
|
||||
parsePayload?: SchemaParseFn<TPayload>,
|
||||
options?: TriggerAndWaitOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<TaskRunResult<TIdentifier, TOutput>> {
|
||||
const ctx = taskContext.ctx;
|
||||
|
||||
@@ -1304,7 +1305,7 @@ async function triggerAndWait_internal<TIdentifier extends string, TPayload, TOu
|
||||
throw new Error("triggerAndWait can only be used from inside a task.run()");
|
||||
}
|
||||
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
const parsedPayload = parsePayload ? await parsePayload(payload) : payload;
|
||||
|
||||
@@ -1375,7 +1376,7 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
|
||||
items: Array<BatchTriggerAndWaitItem<TPayload>>,
|
||||
parsePayload?: SchemaParseFn<TPayload>,
|
||||
options?: BatchTriggerAndWaitOptions,
|
||||
requestOptions?: ApiRequestOptions,
|
||||
requestOptions?: TriggerApiRequestOptions,
|
||||
queue?: string
|
||||
): Promise<BatchResult<TIdentifier, TOutput>> {
|
||||
const ctx = taskContext.ctx;
|
||||
@@ -1384,7 +1385,7 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
|
||||
throw new Error("batchTriggerAndWait can only be used from inside a task.run()");
|
||||
}
|
||||
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig);
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
name,
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
import {
|
||||
type ApiRequestOptions,
|
||||
realtimeStreams,
|
||||
taskContext,
|
||||
type RealtimeStreamOperationOptions,
|
||||
mergeRequestOptions,
|
||||
accessoryAttributes,
|
||||
SemanticInternalAttributes,
|
||||
apiClientManager,
|
||||
AsyncIterableStream,
|
||||
WriterStreamOptions,
|
||||
PipeStreamOptions,
|
||||
PipeStreamResult,
|
||||
ReadStreamOptions,
|
||||
AppendStreamOptions,
|
||||
RealtimeDefinedStream,
|
||||
InferStreamType,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { tracer } from "./tracer.js";
|
||||
import { SpanStatusCode } from "@opentelemetry/api";
|
||||
|
||||
const DEFAULT_STREAM_KEY = "default";
|
||||
|
||||
/**
|
||||
* Pipes data to a realtime stream using the default stream key (`"default"`).
|
||||
*
|
||||
* This is a convenience overload that allows you to pipe data without specifying a stream key.
|
||||
* The stream will be created/accessed with the key `"default"`.
|
||||
*
|
||||
* @template T - The type of data chunks in the stream
|
||||
* @param value - The stream of data to pipe from. Can be an `AsyncIterable<T>` or `ReadableStream<T>`.
|
||||
* @param options - Optional configuration for the stream operation
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - `stream`: The original stream (can be consumed in your task)
|
||||
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk";
|
||||
*
|
||||
* // Stream OpenAI completion chunks to the default stream
|
||||
* const completion = await openai.chat.completions.create({
|
||||
* model: "gpt-4",
|
||||
* messages: [{ role: "user", content: "Hello" }],
|
||||
* stream: true,
|
||||
* });
|
||||
*
|
||||
* const { waitUntilComplete } = await streams.pipe(completion);
|
||||
*
|
||||
* // Process the stream locally
|
||||
* for await (const chunk of completion) {
|
||||
* console.log(chunk);
|
||||
* }
|
||||
*
|
||||
* // Or alternatievely wait for all chunks to be sent to the realtime stream
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*/
|
||||
function pipe<T>(
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: PipeStreamOptions
|
||||
): PipeStreamResult<T>;
|
||||
/**
|
||||
* Pipes data to a realtime stream with a specific stream key.
|
||||
*
|
||||
* Use this overload when you want to use a custom stream key instead of the default.
|
||||
*
|
||||
* @template T - The type of data chunks in the stream
|
||||
* @param key - The unique identifier for this stream. If multiple streams use the same key,
|
||||
* they will be merged into a single stream. Defaults to `"default"` if not provided.
|
||||
* @param value - The stream of data to pipe from. Can be an `AsyncIterable<T>` or `ReadableStream<T>`.
|
||||
* @param options - Optional configuration for the stream operation
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - `stream`: The original stream (can be consumed in your task)
|
||||
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk";
|
||||
*
|
||||
* // Stream data to a specific stream key
|
||||
* const myStream = createAsyncGenerator();
|
||||
* const { waitUntilComplete } = await streams.pipe("my-custom-stream", myStream);
|
||||
*
|
||||
* // Process the stream locally
|
||||
* for await (const chunk of myStream) {
|
||||
* console.log(chunk);
|
||||
* }
|
||||
*
|
||||
* // Wait for all chunks to be sent
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Stream to a parent run
|
||||
* await streams.pipe("output", myStream, {
|
||||
* target: "parent",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
function pipe<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
options?: PipeStreamOptions
|
||||
): PipeStreamResult<T>;
|
||||
function pipe<T>(
|
||||
keyOrValue: string | AsyncIterable<T> | ReadableStream<T>,
|
||||
valueOrOptions?: AsyncIterable<T> | ReadableStream<T> | PipeStreamOptions,
|
||||
options?: PipeStreamOptions
|
||||
): PipeStreamResult<T> {
|
||||
// Handle overload: pipe(value, options?) or pipe(key, value, options?)
|
||||
let key: string;
|
||||
let value: AsyncIterable<T> | ReadableStream<T>;
|
||||
let opts: PipeStreamOptions | undefined;
|
||||
|
||||
if (typeof keyOrValue === "string") {
|
||||
// pipe(key, value, options?)
|
||||
key = keyOrValue;
|
||||
value = valueOrOptions as AsyncIterable<T> | ReadableStream<T>;
|
||||
opts = options;
|
||||
} else {
|
||||
// pipe(value, options?)
|
||||
key = DEFAULT_STREAM_KEY;
|
||||
value = keyOrValue;
|
||||
opts = valueOrOptions as PipeStreamOptions | undefined;
|
||||
}
|
||||
|
||||
return pipeInternal(key, value, opts, "streams.pipe()");
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal pipe implementation that allows customizing the span name.
|
||||
* This is used by both the public `pipe` method and the `writer` method.
|
||||
*/
|
||||
function pipeInternal<T>(
|
||||
key: string,
|
||||
value: AsyncIterable<T> | ReadableStream<T>,
|
||||
opts: PipeStreamOptions | undefined,
|
||||
spanName: string
|
||||
): PipeStreamResult<T> {
|
||||
const runId = getRunIdForOptions(opts);
|
||||
|
||||
if (!runId) {
|
||||
throw new Error(
|
||||
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option or use this function from inside a task."
|
||||
);
|
||||
}
|
||||
|
||||
const span = tracer.startSpan(spanName, {
|
||||
attributes: {
|
||||
key,
|
||||
runId,
|
||||
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
|
||||
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "streams",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: key,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const requestOptions = mergeRequestOptions({}, opts?.requestOptions);
|
||||
|
||||
try {
|
||||
const instance = realtimeStreams.pipe(key, value, {
|
||||
signal: opts?.signal,
|
||||
target: runId,
|
||||
requestOptions,
|
||||
});
|
||||
|
||||
instance.wait().finally(() => {
|
||||
span.end();
|
||||
});
|
||||
|
||||
return {
|
||||
stream: instance.stream,
|
||||
waitUntilComplete: () => instance.wait(),
|
||||
};
|
||||
} catch (error) {
|
||||
// if the error is a signal abort error, we need to end the span but not record an exception
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
span.end();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error || typeof error === "string") {
|
||||
span.recordException(error);
|
||||
} else {
|
||||
span.recordException(String(error));
|
||||
}
|
||||
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads data from a realtime stream using the default stream key (`"default"`).
|
||||
*
|
||||
* This is a convenience overload that allows you to read from the default stream without
|
||||
* specifying a stream key. The stream will be accessed with the key `"default"`.
|
||||
*
|
||||
* @template T - The type of data chunks in the stream
|
||||
* @param runId - The unique identifier of the run to read the stream from
|
||||
* @param options - Optional configuration for reading the stream
|
||||
* @returns A promise that resolves to an `AsyncIterableStream<T>` that can be consumed
|
||||
* using `for await...of` or as a `ReadableStream`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk/v3";
|
||||
*
|
||||
* // Read from the default stream
|
||||
* const stream = await streams.read<string>(runId);
|
||||
*
|
||||
* for await (const chunk of stream) {
|
||||
* console.log("Received chunk:", chunk);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Read with custom timeout and starting position
|
||||
* const stream = await streams.read<string>(runId, {
|
||||
* timeoutInSeconds: 120,
|
||||
* startIndex: 10, // Start from the 10th chunk
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
function read<T>(runId: string, options?: ReadStreamOptions): Promise<AsyncIterableStream<T>>;
|
||||
/**
|
||||
* Reads data from a realtime stream with a specific stream key.
|
||||
*
|
||||
* Use this overload when you want to read from a stream with a custom key.
|
||||
*
|
||||
* @template T - The type of data chunks in the stream
|
||||
* @param runId - The unique identifier of the run to read the stream from
|
||||
* @param key - The unique identifier of the stream to read from. Defaults to `"default"` if not provided.
|
||||
* @param options - Optional configuration for reading the stream
|
||||
* @returns A promise that resolves to an `AsyncIterableStream<T>` that can be consumed
|
||||
* using `for await...of` or as a `ReadableStream`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk";
|
||||
*
|
||||
* // Read from a specific stream key
|
||||
* const stream = await streams.read<string>(runId, "my-custom-stream");
|
||||
*
|
||||
* for await (const chunk of stream) {
|
||||
* console.log("Received chunk:", chunk);
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Read with signal for cancellation
|
||||
* const controller = new AbortController();
|
||||
* const stream = await streams.read<string>(runId, "my-stream", {
|
||||
* signal: controller.signal,
|
||||
* timeoutInSeconds: 30,
|
||||
* });
|
||||
*
|
||||
* // Cancel after 5 seconds
|
||||
* setTimeout(() => controller.abort(), 5000);
|
||||
* ```
|
||||
*/
|
||||
function read<T>(
|
||||
runId: string,
|
||||
key: string,
|
||||
options?: ReadStreamOptions
|
||||
): Promise<AsyncIterableStream<T>>;
|
||||
async function read<T>(
|
||||
runId: string,
|
||||
keyOrOptions?: string | ReadStreamOptions,
|
||||
options?: ReadStreamOptions
|
||||
): Promise<AsyncIterableStream<T>> {
|
||||
// Handle overload: read(runId, options?) or read(runId, key, options?)
|
||||
let key: string;
|
||||
let opts: ReadStreamOptions | undefined;
|
||||
|
||||
if (typeof keyOrOptions === "string") {
|
||||
// read(runId, key, options?)
|
||||
key = keyOrOptions;
|
||||
opts = options;
|
||||
} else {
|
||||
// read(runId, options?)
|
||||
key = DEFAULT_STREAM_KEY;
|
||||
opts = keyOrOptions;
|
||||
}
|
||||
|
||||
// Rename to readStream for consistency with existing code
|
||||
return readStreamImpl(runId, key, opts);
|
||||
}
|
||||
|
||||
async function readStreamImpl<T>(
|
||||
runId: string,
|
||||
key: string,
|
||||
options?: ReadStreamOptions
|
||||
): Promise<AsyncIterableStream<T>> {
|
||||
const apiClient = apiClientManager.clientOrThrow();
|
||||
|
||||
const span = tracer.startSpan("streams.read()", {
|
||||
attributes: {
|
||||
key,
|
||||
runId,
|
||||
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
|
||||
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
|
||||
[SemanticInternalAttributes.ENTITY_METADATA]: JSON.stringify({
|
||||
startIndex: options?.startIndex,
|
||||
}),
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "streams",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: key,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return await apiClient.fetchStream(runId, key, {
|
||||
signal: options?.signal,
|
||||
timeoutInSeconds: options?.timeoutInSeconds ?? 60,
|
||||
lastEventId: options?.startIndex ? (options.startIndex - 1).toString() : undefined,
|
||||
onComplete: () => {
|
||||
span.end();
|
||||
},
|
||||
onError: (error) => {
|
||||
span.recordException(error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function append<TPart extends BodyInit>(value: TPart, options?: AppendStreamOptions): Promise<void>;
|
||||
function append<TPart extends BodyInit>(
|
||||
key: string,
|
||||
value: TPart,
|
||||
options?: AppendStreamOptions
|
||||
): Promise<void>;
|
||||
function append<TPart extends BodyInit>(
|
||||
keyOrValue: string | TPart,
|
||||
valueOrOptions?: TPart | AppendStreamOptions,
|
||||
options?: AppendStreamOptions
|
||||
): Promise<void> {
|
||||
if (typeof keyOrValue === "string" && typeof valueOrOptions === "string") {
|
||||
return appendInternal(keyOrValue, valueOrOptions, options);
|
||||
}
|
||||
|
||||
if (typeof keyOrValue === "string") {
|
||||
if (isAppendStreamOptions(valueOrOptions)) {
|
||||
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, valueOrOptions);
|
||||
} else {
|
||||
if (!valueOrOptions) {
|
||||
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, options);
|
||||
}
|
||||
|
||||
return appendInternal(keyOrValue, valueOrOptions, options);
|
||||
}
|
||||
} else {
|
||||
if (isAppendStreamOptions(valueOrOptions)) {
|
||||
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, valueOrOptions);
|
||||
} else {
|
||||
return appendInternal(DEFAULT_STREAM_KEY, keyOrValue, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function appendInternal<TPart extends BodyInit>(
|
||||
key: string,
|
||||
part: TPart,
|
||||
options?: AppendStreamOptions
|
||||
): Promise<void> {
|
||||
const runId = getRunIdForOptions(options);
|
||||
|
||||
if (!runId) {
|
||||
throw new Error(
|
||||
"Could not determine the target run ID for the realtime stream. Please specify a target run ID using the `target` option or use this function from inside a task."
|
||||
);
|
||||
}
|
||||
|
||||
const span = tracer.startSpan("streams.append()", {
|
||||
attributes: {
|
||||
key,
|
||||
runId,
|
||||
[SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream",
|
||||
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`,
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "streams",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: key,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await realtimeStreams.append(key, part, options);
|
||||
span.end();
|
||||
} catch (error) {
|
||||
// if the error is a signal abort error, we need to end the span but not record an exception
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
span.end();
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof Error || typeof error === "string") {
|
||||
span.recordException(error);
|
||||
} else {
|
||||
span.recordException(String(error));
|
||||
}
|
||||
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
span.end();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isAppendStreamOptions(val: unknown): val is AppendStreamOptions {
|
||||
return (
|
||||
typeof val === "object" &&
|
||||
val !== null &&
|
||||
!Array.isArray(val) &&
|
||||
(("target" in val && typeof val.target === "string") ||
|
||||
("requestOptions" in val && typeof val.requestOptions === "object"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data to a realtime stream using the default stream key (`"default"`).
|
||||
*
|
||||
* This is a convenience overload that allows you to write to the default stream without
|
||||
* specifying a stream key. The stream will be created/accessed with the key `"default"`.
|
||||
*
|
||||
* @template TPart - The type of data chunks in the stream
|
||||
* @param options - The options for writing to the stream
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - `stream`: The original stream (can be consumed in your task)
|
||||
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk";
|
||||
*
|
||||
* // Write to the default stream
|
||||
* const { waitUntilComplete } = await streams.writer({
|
||||
* execute: ({ write, merge }) => {
|
||||
* write("chunk 1");
|
||||
* write("chunk 2");
|
||||
* write("chunk 3");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Wait for all chunks to be written
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Write to a specific stream key
|
||||
* const { waitUntilComplete } = await streams.writer("my-custom-stream", {
|
||||
* execute: ({ write, merge }) => {
|
||||
* write("chunk 1");
|
||||
* write("chunk 2");
|
||||
* write("chunk 3");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Wait for all chunks to be written
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Write to a parent run
|
||||
* await streams.writer("output", {
|
||||
* execute: ({ write, merge }) => {
|
||||
* write("chunk 1");
|
||||
* write("chunk 2");
|
||||
* write("chunk 3");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Wait for all chunks to be written
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Write to a specific stream key
|
||||
* await streams.writer("my-custom-stream", {
|
||||
* execute: ({ write, merge }) => {
|
||||
* write("chunk 1");
|
||||
* write("chunk 2");
|
||||
* write("chunk 3");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Wait for all chunks to be written
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*/
|
||||
function writer<TPart>(options: WriterStreamOptions<TPart>): PipeStreamResult<TPart>;
|
||||
/**
|
||||
* Writes data to a realtime stream with a specific stream key.
|
||||
*
|
||||
* @template TPart - The type of data chunks in the stream
|
||||
* @param key - The unique identifier of the stream to write to. Defaults to `"default"` if not provided.
|
||||
* @param options - The options for writing to the stream
|
||||
* @returns A promise that resolves to an object containing:
|
||||
* - `stream`: The original stream (can be consumed in your task)
|
||||
* - `waitUntilComplete`: A function that returns a promise resolving when the stream is fully sent
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { streams } from "@trigger.dev/sdk";
|
||||
*
|
||||
* // Write to a specific stream key
|
||||
* const { waitUntilComplete } = await streams.writer("my-custom-stream", {
|
||||
* execute: ({ write, merge }) => {
|
||||
* write("chunk 1");
|
||||
* write("chunk 2");
|
||||
* write("chunk 3");
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Wait for all chunks to be written
|
||||
* await waitUntilComplete();
|
||||
* ```
|
||||
*/
|
||||
function writer<TPart>(key: string, options: WriterStreamOptions<TPart>): PipeStreamResult<TPart>;
|
||||
function writer<TPart>(
|
||||
keyOrOptions: string | WriterStreamOptions<TPart>,
|
||||
valueOrOptions?: WriterStreamOptions<TPart>
|
||||
): PipeStreamResult<TPart> {
|
||||
if (typeof keyOrOptions === "string") {
|
||||
return writerInternal(keyOrOptions, valueOrOptions!);
|
||||
}
|
||||
|
||||
return writerInternal(DEFAULT_STREAM_KEY, keyOrOptions);
|
||||
}
|
||||
|
||||
function writerInternal<TPart>(key: string, options: WriterStreamOptions<TPart>) {
|
||||
let controller!: ReadableStreamDefaultController<TPart>;
|
||||
|
||||
const ongoingStreamPromises: Promise<void>[] = [];
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controllerArg) {
|
||||
controller = controllerArg;
|
||||
},
|
||||
});
|
||||
|
||||
function safeEnqueue(data: TPart) {
|
||||
try {
|
||||
controller.enqueue(data);
|
||||
} catch (error) {
|
||||
// suppress errors when the stream has been closed
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = options.execute({
|
||||
write(part) {
|
||||
safeEnqueue(part);
|
||||
},
|
||||
merge(streamArg) {
|
||||
ongoingStreamPromises.push(
|
||||
(async () => {
|
||||
const reader = streamArg.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
safeEnqueue(value);
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
if (result) {
|
||||
ongoingStreamPromises.push(
|
||||
result.catch((error) => {
|
||||
console.error(error);
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const waitForStreams: Promise<void> = new Promise((resolve, reject) => {
|
||||
(async () => {
|
||||
while (ongoingStreamPromises.length > 0) {
|
||||
await ongoingStreamPromises.shift();
|
||||
}
|
||||
resolve();
|
||||
})().catch(reject);
|
||||
});
|
||||
|
||||
waitForStreams.finally(() => {
|
||||
try {
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
// suppress errors when the stream has been closed
|
||||
}
|
||||
});
|
||||
|
||||
return pipeInternal(key, stream, options, "streams.writer()");
|
||||
}
|
||||
|
||||
export type RealtimeDefineStreamOptions = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function define<TPart>(opts: RealtimeDefineStreamOptions): RealtimeDefinedStream<TPart> {
|
||||
return {
|
||||
id: opts.id,
|
||||
pipe(value, options) {
|
||||
return pipe(opts.id, value, options);
|
||||
},
|
||||
read(runId, options) {
|
||||
return read(runId, opts.id, options);
|
||||
},
|
||||
append(value, options) {
|
||||
return append(opts.id, value as BodyInit, options);
|
||||
},
|
||||
writer(options) {
|
||||
return writer(opts.id, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type { InferStreamType };
|
||||
|
||||
export const streams = {
|
||||
pipe,
|
||||
read,
|
||||
append,
|
||||
writer,
|
||||
define,
|
||||
};
|
||||
|
||||
function getRunIdForOptions(options?: RealtimeStreamOperationOptions): string | undefined {
|
||||
if (options?.target) {
|
||||
if (options.target === "parent") {
|
||||
return taskContext.ctx?.run?.parentTaskRunId;
|
||||
}
|
||||
|
||||
if (options.target === "root") {
|
||||
return taskContext.ctx?.run?.rootTaskRunId;
|
||||
}
|
||||
|
||||
if (options.target === "self") {
|
||||
return taskContext.ctx?.run?.id;
|
||||
}
|
||||
|
||||
return options.target;
|
||||
}
|
||||
|
||||
return taskContext.ctx?.run?.id;
|
||||
}
|
||||
Generated
+1903
-359
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { logger, runs, task } from "@trigger.dev/sdk";
|
||||
import { logger, metadata, runs, task } from "@trigger.dev/sdk";
|
||||
import { helloWorldTask } from "./example.js";
|
||||
import { setTimeout } from "timers/promises";
|
||||
|
||||
@@ -59,3 +59,70 @@ export const realtimeUpToDateTask = task({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const realtimeStreamsTask = task({
|
||||
id: "realtime-streams",
|
||||
run: async () => {
|
||||
const mockStream = createStreamFromGenerator(generateMockData(5 * 60 * 1000));
|
||||
|
||||
const stream = await metadata.stream("mock-data", mockStream);
|
||||
|
||||
for await (const chunk of stream) {
|
||||
logger.info("Received chunk", { chunk });
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Hello, world!",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const realtimeStreamsV2Task = task({
|
||||
id: "realtime-streams-v2",
|
||||
run: async () => {
|
||||
const mockStream1 = createStreamFromGenerator(generateMockData(5 * 60 * 1000));
|
||||
|
||||
await metadata.stream("mock-data", mockStream1);
|
||||
|
||||
await setTimeout(10000); // Offset by 10 seconds
|
||||
|
||||
const mockStream2 = createStreamFromGenerator(generateMockData(5 * 60 * 1000));
|
||||
const stream2 = await metadata.stream("mock-data", mockStream2);
|
||||
|
||||
for await (const chunk of stream2) {
|
||||
logger.info("Received chunk", { chunk });
|
||||
}
|
||||
|
||||
return {
|
||||
message: "Hello, world!",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
async function* generateMockData(durationMs: number = 5 * 60 * 1000) {
|
||||
const chunkInterval = 1000;
|
||||
const totalChunks = Math.floor(durationMs / chunkInterval);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
await setTimeout(chunkInterval);
|
||||
|
||||
yield JSON.stringify({
|
||||
chunk: i + 1,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: `Mock data chunk ${i + 1}`,
|
||||
}) + "\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to ReadableStream
|
||||
function createStreamFromGenerator(generator: AsyncGenerator<string>) {
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
for await (const chunk of generator) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Realtime Streams Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This app is set up to test Trigger.dev realtime streams with resume/reconnection functionality.
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Home Page (`/`)
|
||||
|
||||
- Displays buttons for different stream scenarios
|
||||
- Each button triggers a server action that:
|
||||
1. Starts a new task run
|
||||
2. Redirects to `/runs/[runId]?accessToken=xxx`
|
||||
|
||||
### 2. Run Page (`/runs/[runId]`)
|
||||
|
||||
- Displays the live stream for a specific run
|
||||
- Receives `runId` from URL path parameter
|
||||
- Receives `accessToken` from URL query parameter
|
||||
- Shows real-time streaming content using `useRealtimeRunWithStreams`
|
||||
|
||||
## Testing Resume/Reconnection
|
||||
|
||||
### Test Scenario 1: Page Refresh
|
||||
|
||||
1. Click any stream button (e.g., "Markdown Stream")
|
||||
2. Watch the stream start
|
||||
3. **Refresh the page** (Cmd/Ctrl + R)
|
||||
4. The stream should reconnect and continue from where it left off
|
||||
|
||||
### Test Scenario 2: Network Interruption
|
||||
|
||||
1. Start a long-running stream (e.g., "Stall Stream")
|
||||
2. Open DevTools → Network tab
|
||||
3. Throttle to "Offline" briefly
|
||||
4. Return to "Online"
|
||||
5. Stream should recover and resume
|
||||
|
||||
### Test Scenario 3: URL Navigation
|
||||
|
||||
1. Start a stream
|
||||
2. Copy the URL
|
||||
3. Open in a new tab
|
||||
4. Both tabs should show the same stream state
|
||||
|
||||
## Available Stream Scenarios
|
||||
|
||||
- **Markdown Stream**: Fast streaming of formatted markdown (good for quick tests)
|
||||
- **Continuous Stream**: 45 seconds of continuous word streaming
|
||||
- **Burst Stream**: 10 bursts of rapid tokens with pauses
|
||||
- **Stall Stream**: 3-minute test with long pauses (tests timeout handling)
|
||||
- **Slow Steady Stream**: 5-minute slow stream (tests long connections)
|
||||
|
||||
## What to Watch For
|
||||
|
||||
1. **Resume functionality**: After refresh, does the stream continue or restart?
|
||||
2. **No duplicate data**: Reconnection should not repeat already-seen chunks
|
||||
3. **Console logs**: Check for `[MetadataStream]` logs showing resume behavior
|
||||
4. **Run status**: Status should update correctly (EXECUTING → COMPLETED)
|
||||
5. **Token count**: Final token count should be accurate (no missing chunks)
|
||||
|
||||
## Debugging
|
||||
|
||||
Check browser console for:
|
||||
|
||||
- `[MetadataStream]` logs showing HEAD requests and resume logic
|
||||
- Network requests to `/realtime/v1/streams/...`
|
||||
- Any errors or warnings
|
||||
|
||||
Check server logs for:
|
||||
|
||||
- Stream ingestion logs
|
||||
- Resume header values (`X-Resume-From-Chunk`, `X-Last-Chunk-Index`)
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "references-realtime-streams",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"start": "next start",
|
||||
"dev:trigger": "trigger dev",
|
||||
"deploy": "trigger deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@trigger.dev/react-hooks": "workspace:*",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"ai": "^5.0.76",
|
||||
"next": "15.5.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"shiki": "^3.13.0",
|
||||
"streamdown": "^1.4.0",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"trigger.dev": "workspace:*",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,65 @@
|
||||
"use server";
|
||||
|
||||
import { tasks, auth } from "@trigger.dev/sdk";
|
||||
import type { streamsTask } from "@/trigger/streams";
|
||||
import type { aiChatTask } from "@/trigger/ai-chat";
|
||||
import { redirect } from "next/navigation";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
export async function triggerStreamTask(
|
||||
scenario: string,
|
||||
redirectPath?: string,
|
||||
useDurableStreams?: boolean
|
||||
) {
|
||||
const config = useDurableStreams
|
||||
? {
|
||||
future: {
|
||||
v2RealtimeStreams: true,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Trigger the streams task
|
||||
const handle = await tasks.trigger<typeof streamsTask>(
|
||||
"streams",
|
||||
{
|
||||
scenario: scenario as any,
|
||||
},
|
||||
{},
|
||||
{
|
||||
clientConfig: config,
|
||||
}
|
||||
);
|
||||
|
||||
console.log("Triggered run:", handle.id);
|
||||
|
||||
// Redirect to custom path or default run page
|
||||
const path = redirectPath
|
||||
? `${redirectPath}/${handle.id}?accessToken=${handle.publicAccessToken}`
|
||||
: `/runs/${handle.id}?accessToken=${handle.publicAccessToken}`;
|
||||
|
||||
redirect(path);
|
||||
}
|
||||
|
||||
export async function triggerAIChatTask(messages: UIMessage[]) {
|
||||
// Trigger the AI chat task
|
||||
const handle = await tasks.trigger<typeof aiChatTask>(
|
||||
"ai-chat",
|
||||
{
|
||||
messages,
|
||||
},
|
||||
{},
|
||||
{
|
||||
clientConfig: {
|
||||
future: {
|
||||
v2RealtimeStreams: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log("Triggered AI chat run:", handle.id);
|
||||
|
||||
// Redirect to chat page
|
||||
redirect(`/chat/${handle.id}?accessToken=${handle.publicAccessToken}`);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { AIChat } from "@/components/ai-chat";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ChatPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { runId: string };
|
||||
searchParams: { accessToken?: string };
|
||||
}) {
|
||||
const { runId } = params;
|
||||
const accessToken = searchParams.accessToken;
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center">
|
||||
<h1 className="text-2xl font-bold text-red-600">Missing Access Token</h1>
|
||||
<p className="text-gray-600">This page requires an access token to view the stream.</p>
|
||||
<Link href="/" className="text-blue-600 hover:underline">
|
||||
Go back home
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-start w-full max-w-4xl">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h1 className="text-2xl font-bold">AI Chat Stream: {runId}</h1>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-purple-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-purple-900 mb-2">
|
||||
🤖 <strong>AI SDK v5:</strong> This stream uses AI SDK's streamText with
|
||||
toUIMessageStream()
|
||||
</p>
|
||||
<p className="text-xs text-purple-700">
|
||||
Try refreshing to test stream reconnection - it should resume where it left off.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full border border-gray-200 rounded-lg p-6 bg-white">
|
||||
<AIChat accessToken={accessToken} runId={runId} />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script crossOrigin="anonymous" src="//unpkg.com/react-scan/dist/auto.global.js" />
|
||||
</head>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TriggerButton } from "@/components/trigger-button";
|
||||
import { AIChatButton } from "@/components/ai-chat-button";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
||||
<h1 className="text-3xl font-bold mb-4">Realtime Streams Test</h1>
|
||||
<p className="text-gray-600 mb-8">
|
||||
Click a button below to trigger a streaming task and watch it in real-time. You can
|
||||
refresh the page to test stream reconnection.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-300 w-full">
|
||||
<h2 className="text-xl font-semibold mb-4">AI Chat Stream (AI SDK v5)</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Test AI SDK v5's streamText with toUIMessageStream()
|
||||
</p>
|
||||
<AIChatButton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<TriggerButton scenario="markdown">Markdown Stream</TriggerButton>
|
||||
<TriggerButton scenario="continuous">Continuous Stream</TriggerButton>
|
||||
<TriggerButton scenario="burst">Burst Stream</TriggerButton>
|
||||
<TriggerButton scenario="stall">Stall Stream (3 min)</TriggerButton>
|
||||
<TriggerButton scenario="slow-steady">Slow Steady Stream (5 min)</TriggerButton>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<TriggerButton useDurableStreams={true} scenario="markdown">
|
||||
Markdown Stream (Durable)
|
||||
</TriggerButton>
|
||||
<TriggerButton useDurableStreams={true} scenario="continuous">
|
||||
Continuous Stream (Durable)
|
||||
</TriggerButton>
|
||||
<TriggerButton useDurableStreams={true} scenario="burst">
|
||||
Burst Stream (Durable)
|
||||
</TriggerButton>
|
||||
<TriggerButton useDurableStreams={true} scenario="stall">
|
||||
Stall Stream (3 min) (Durable)
|
||||
</TriggerButton>
|
||||
<TriggerButton useDurableStreams={true} scenario="slow-steady">
|
||||
Slow Steady Stream (5 min) (Durable)
|
||||
</TriggerButton>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 pt-8 border-t border-gray-300">
|
||||
<h2 className="text-xl font-semibold mb-4">Performance Testing</h2>
|
||||
<TriggerButton scenario="performance" redirect="/performance">
|
||||
📊 Performance Test V1 (Latency Monitoring)
|
||||
</TriggerButton>
|
||||
|
||||
<TriggerButton scenario="performance" redirect="/performance" useDurableStreams={true}>
|
||||
📊 Performance Test V2 (Latency Monitoring)
|
||||
</TriggerButton>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PerformanceMonitor } from "@/components/performance-monitor";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function PerformancePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { runId: string };
|
||||
searchParams: { accessToken?: string };
|
||||
}) {
|
||||
const { runId } = params;
|
||||
const accessToken = searchParams.accessToken;
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center">
|
||||
<h1 className="text-2xl font-bold text-red-600">Missing Access Token</h1>
|
||||
<p className="text-gray-600">This page requires an access token to view the stream.</p>
|
||||
<Link href="/" className="text-blue-600 hover:underline">
|
||||
Go back home
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-sans min-h-screen p-8 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">Performance Monitor</h1>
|
||||
<p className="text-sm text-gray-600 mt-1">Run: {runId}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="px-4 py-2 bg-white border border-gray-300 text-gray-800 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
← Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-blue-900">
|
||||
<strong>📊 Real-time Latency Monitoring:</strong> This page measures the time it takes
|
||||
for each chunk to travel from the task to your browser. Lower latency = better
|
||||
performance!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PerformanceMonitor accessToken={accessToken} runId={runId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user