Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30ea5eb13a | |||
| 5846f30228 | |||
| 3afa42c209 | |||
| 86b1628953 | |||
| ea23dbd297 | |||
| 9065e64be8 | |||
| 9970b9b68e | |||
| b4113134ad | |||
| 2a07ea42f1 | |||
| 91afa5ebbf | |||
| 65262dc3d7 | |||
| 9105701ae0 | |||
| 9b35cc484b | |||
| 30a04a5a06 | |||
| 493315af48 | |||
| 8db1da69e9 | |||
| cd7a45101e | |||
| 29d107dc0a |
@@ -0,0 +1,57 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
type CheckBatchCompletionDialogProps = {
|
||||
batchId: string;
|
||||
redirectPath: string;
|
||||
};
|
||||
|
||||
export function CheckBatchCompletionDialog({
|
||||
batchId,
|
||||
redirectPath,
|
||||
}: CheckBatchCompletionDialogProps) {
|
||||
const navigation = useNavigation();
|
||||
|
||||
const formAction = `/resources/batches/${batchId}/check-completion`;
|
||||
const isLoading = navigation.formAction === formAction;
|
||||
|
||||
return (
|
||||
<DialogContent key="check-completion">
|
||||
<DialogHeader>Try and resume batch</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<Paragraph>
|
||||
In rare cases, parent runs don't continue after child runs have completed.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
If this doesn't help, please get in touch. We are working on a permanent fix for this.
|
||||
</Paragraph>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Form action={`/resources/batches/${batchId}/check-completion`} method="post">
|
||||
<Button
|
||||
type="submit"
|
||||
name="redirectUrl"
|
||||
value={redirectPath}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={isLoading ? "spinner-white" : undefined}
|
||||
disabled={isLoading}
|
||||
shortcut={{ modifiers: ["meta"], key: "enter" }}
|
||||
>
|
||||
{isLoading ? "Attempting resume..." : "Attempt resume"}
|
||||
</Button>
|
||||
</Form>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant={"tertiary/medium"}>Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -243,6 +243,8 @@ const EnvironmentSchema = z.object({
|
||||
MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(),
|
||||
MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500),
|
||||
|
||||
REALTIME_STREAM_VERSION: z.enum(["v1", "v2"]).default("v1"),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ListRunResponse, ListRunResponseItem, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { ListRunResponse, ListRunResponseItem, parsePacket, RunStatus } from "@trigger.dev/core/v3";
|
||||
import { Project, RuntimeEnvironment, TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
@@ -220,36 +220,46 @@ export class ApiRunListPresenter extends BasePresenter {
|
||||
|
||||
const results = await presenter.call(options);
|
||||
|
||||
const data: ListRunResponseItem[] = results.runs.map((run) => {
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
});
|
||||
logger.debug("RunListPresenter results", { results });
|
||||
|
||||
const data: ListRunResponseItem[] = await Promise.all(
|
||||
results.runs.map(async (run) => {
|
||||
const metadata = await parsePacket({
|
||||
data: run.metadata ?? undefined,
|
||||
dataType: run.metadataType,
|
||||
});
|
||||
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status),
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
idempotencyKey: run.idempotencyKey,
|
||||
version: run.version ?? undefined,
|
||||
createdAt: new Date(run.createdAt),
|
||||
updatedAt: new Date(run.updatedAt),
|
||||
startedAt: run.startedAt ? new Date(run.startedAt) : undefined,
|
||||
finishedAt: run.finishedAt ? new Date(run.finishedAt) : undefined,
|
||||
delayedUntil: run.delayUntil ? new Date(run.delayUntil) : undefined,
|
||||
isTest: run.isTest,
|
||||
ttl: run.ttl ?? undefined,
|
||||
expiredAt: run.expiredAt ? new Date(run.expiredAt) : undefined,
|
||||
env: {
|
||||
id: run.environment.id,
|
||||
name: run.environment.slug,
|
||||
user: run.environment.userName,
|
||||
},
|
||||
tags: run.tags,
|
||||
costInCents: run.costInCents,
|
||||
baseCostInCents: run.baseCostInCents,
|
||||
durationMs: run.usageDurationMs,
|
||||
depth: run.depth,
|
||||
metadata,
|
||||
...ApiRetrieveRunPresenter.apiBooleanHelpersFromRunStatus(
|
||||
ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status)
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -108,6 +108,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
},
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
},
|
||||
triggeredBy: {
|
||||
@@ -145,6 +146,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
cliVersion: deployment.worker?.cliVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
|
||||
@@ -216,6 +216,8 @@ export class RunListPresenter extends BasePresenter {
|
||||
depth: number;
|
||||
rootTaskRunId: string | null;
|
||||
batchId: string | null;
|
||||
metadata: string | null;
|
||||
metadataType: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -241,7 +243,9 @@ export class RunListPresenter extends BasePresenter {
|
||||
tr."usageDurationMs" AS "usageDurationMs",
|
||||
tr."depth" AS "depth",
|
||||
tr."rootTaskRunId" AS "rootTaskRunId",
|
||||
tr."runTags" AS "tags"
|
||||
tr."runTags" AS "tags",
|
||||
tr."metadata" AS "metadata",
|
||||
tr."metadataType" AS "metadataType"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
@@ -374,6 +378,8 @@ WHERE
|
||||
tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [],
|
||||
depth: run.depth,
|
||||
rootTaskRunId: run.rootTaskRunId,
|
||||
metadata: run.metadata,
|
||||
metadataType: run.metadataType,
|
||||
};
|
||||
}),
|
||||
pagination: {
|
||||
|
||||
@@ -215,7 +215,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
const span = await eventRepository.getSpan(spanId, run.traceId);
|
||||
|
||||
const metadata = run.metadata
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams"] })
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, {
|
||||
filteredKeys: ["$$streams", "$$streamsVersion"],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const context = {
|
||||
|
||||
+70
-7
@@ -1,6 +1,10 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightIcon,
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useLocation, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
@@ -8,16 +12,19 @@ import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
@@ -29,12 +36,17 @@ import {
|
||||
BatchStatusCombo,
|
||||
descriptionForBatchStatus,
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server";
|
||||
import {
|
||||
BatchList,
|
||||
BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
@@ -150,11 +162,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<TableHeaderCell>Duration</TableHeaderCell>
|
||||
<TableHeaderCell>Created</TableHeaderCell>
|
||||
<TableHeaderCell>Finished</TableHeaderCell>
|
||||
<TableHeaderCell>
|
||||
<span className="sr-only">Go to batch</span>
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{batches.length === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
<TableBlankRow colSpan={8}>
|
||||
{!isLoading && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches</Paragraph>
|
||||
@@ -162,7 +177,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
)}
|
||||
</TableBlankRow>
|
||||
) : batches.length === 0 ? (
|
||||
<TableBlankRow colSpan={7}>
|
||||
<TableBlankRow colSpan={8}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph className="w-auto">No batches match these filters</Paragraph>
|
||||
</div>
|
||||
@@ -215,13 +230,14 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
<TableCell to={path}>
|
||||
{batch.finishedAt ? <DateTime date={batch.finishedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<BatchActionsCell batch={batch} path={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={7}
|
||||
colSpan={8}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
@@ -231,3 +247,50 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BatchActionsCell({ batch, path }: { batch: BatchListItem; path: string }) {
|
||||
const location = useLocation();
|
||||
|
||||
if (batch.hasFinished || batch.environment.type === "DEVELOPMENT") {
|
||||
return <TableCell to={path}>{""}</TableCell>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
to={path}
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
title="View batch"
|
||||
/>
|
||||
{!batch.hasFinished && (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
asChild
|
||||
className="size-6 rounded-sm p-1 text-text-dimmed transition hover:bg-charcoal-700 hover:text-text-bright"
|
||||
>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-success"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="w-full px-1.5 py-[0.9rem]"
|
||||
>
|
||||
Try and resume
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CheckBatchCompletionDialog
|
||||
batchId={batch.id}
|
||||
redirectPath={`${location.pathname}${location.search}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+4
@@ -151,6 +151,10 @@ export default function Page() {
|
||||
<Property.Label>SDK Version</Property.Label>
|
||||
<Property.Value>{deployment.sdkVersion ? deployment.sdkVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>CLI Version</Property.Label>
|
||||
<Property.Value>{deployment.cliVersion ? deployment.cliVersion : "–"}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Started at</Property.Label>
|
||||
<Property.Value>
|
||||
|
||||
+1
-1
@@ -204,7 +204,7 @@ function StandardTaskForm({ task, runs }: { task: TestTask["task"]; runs: Standa
|
||||
);
|
||||
e.preventDefault();
|
||||
},
|
||||
[currentPayloadJson, currentMetadataJson]
|
||||
[currentPayloadJson, currentMetadataJson, task]
|
||||
);
|
||||
|
||||
const [form, { environmentId, payload }] = useForm({
|
||||
|
||||
@@ -9,15 +9,21 @@ import { env } from "~/env.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server";
|
||||
import {
|
||||
BatchProcessingStrategy,
|
||||
BatchTriggerV2Service,
|
||||
} from "~/v3/services/batchTriggerV2.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { OutOfEntitlementError } from "~/v3/services/triggerTask.server";
|
||||
import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { z } from "zod";
|
||||
|
||||
const { action, loader } = createActionApiRoute(
|
||||
{
|
||||
headers: HeadersSchema,
|
||||
headers: HeadersSchema.extend({
|
||||
"batch-processing-strategy": BatchProcessingStrategy.nullish(),
|
||||
}),
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
allowJWT: true,
|
||||
maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE,
|
||||
@@ -52,6 +58,7 @@ const { action, loader } = createActionApiRoute(
|
||||
"x-trigger-span-parent-as-link": spanParentAsLink,
|
||||
"x-trigger-worker": isFromWorker,
|
||||
"x-trigger-client": triggerClient,
|
||||
"batch-processing-strategy": batchProcessingStrategy,
|
||||
traceparent,
|
||||
tracestate,
|
||||
} = headers;
|
||||
@@ -67,6 +74,7 @@ const { action, loader } = createActionApiRoute(
|
||||
triggerClient,
|
||||
traceparent,
|
||||
tracestate,
|
||||
batchProcessingStrategy,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
@@ -79,7 +87,7 @@ const { action, loader } = createActionApiRoute(
|
||||
resolveIdempotencyKeyTTL(idempotencyKeyTTL) ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30);
|
||||
|
||||
const service = new BatchTriggerV2Service();
|
||||
const service = new BatchTriggerV2Service(batchProcessingStrategy ?? undefined);
|
||||
|
||||
try {
|
||||
const batch = await service.call(authentication.environment, body, {
|
||||
|
||||
@@ -15,6 +15,7 @@ export const loader = createLoaderApiRoute(
|
||||
findResource: (params, auth) => {
|
||||
return ApiRetrieveRunPresenter.findRun(params.runId, auth.environment);
|
||||
},
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { realtimeStreams } from "~/services/realtimeStreamsGlobal.server";
|
||||
import { v1RealtimeStreams } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -16,7 +16,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
return new Response("No body provided", { status: 400 });
|
||||
}
|
||||
|
||||
return realtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
|
||||
return v1RealtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
|
||||
}
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
@@ -50,7 +50,13 @@ export const loader = createLoaderApiRoute(
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run }) => {
|
||||
return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal);
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
return v1RealtimeStreams.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
params.streamId,
|
||||
authentication.environment,
|
||||
request.signal
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { v2RealtimeStreams } from "~/services/realtime/v2StreamsGlobal.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
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,
|
||||
runtimeEnvironmentId: authentication.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
return v2RealtimeStreams.ingestData(request.body, run.id, params.streamId);
|
||||
}
|
||||
);
|
||||
|
||||
export { action };
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
params: ParamsSchema,
|
||||
allowJWT: true,
|
||||
corsStrategy: "all",
|
||||
findResource: async (params, auth) => {
|
||||
return $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
return v2RealtimeStreams.streamResponse(
|
||||
request,
|
||||
run.id,
|
||||
params.streamId,
|
||||
authentication.environment,
|
||||
request.signal
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
import { assertExhaustive } from "@trigger.dev/core";
|
||||
import { z } from "zod";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
|
||||
|
||||
export const checkCompletionSchema = z.object({
|
||||
redirectUrl: z.string(),
|
||||
});
|
||||
|
||||
const ParamSchema = z.object({
|
||||
batchId: z.string(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { batchId } = ParamSchema.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema: checkCompletionSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeBatchRunService = new ResumeBatchRunService();
|
||||
const resumeResult = await resumeBatchRunService.call(batchId);
|
||||
|
||||
let message: string | undefined;
|
||||
|
||||
switch (resumeResult) {
|
||||
case "ERROR": {
|
||||
throw "Unknown error during batch completion check";
|
||||
}
|
||||
case "ALREADY_COMPLETED": {
|
||||
message = "Batch already completed.";
|
||||
break;
|
||||
}
|
||||
case "COMPLETED": {
|
||||
message = "Batch completed and parent tasks resumed.";
|
||||
break;
|
||||
}
|
||||
case "PENDING": {
|
||||
message = "Child runs still in progress. Please try again later.";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertExhaustive(resumeResult);
|
||||
}
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(submission.value.redirectUrl, request, message);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error("Failed to check batch completion", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
return redirectWithErrorMessage(submission.value.redirectUrl, request, error.message);
|
||||
} else {
|
||||
logger.error("Failed to check batch completion", { error });
|
||||
return redirectWithErrorMessage(submission.value.redirectUrl, request, "Unknown error");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { RealtimeClient } from "../realtimeClient.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
|
||||
export type DatabaseRealtimeStreamsOptions = {
|
||||
prisma: PrismaClient;
|
||||
realtimeClient: RealtimeClient;
|
||||
};
|
||||
|
||||
// Class implementing both interfaces
|
||||
export class DatabaseRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
constructor(private options: DatabaseRealtimeStreamsOptions) {}
|
||||
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
return this.options.realtimeClient.streamChunks(
|
||||
request.url,
|
||||
environment,
|
||||
runId,
|
||||
streamId,
|
||||
signal,
|
||||
request.headers.get("x-trigger-electric-version") ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
async ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const textStream = stream.pipeThrough(new TextDecoderStream());
|
||||
const reader = textStream.getReader();
|
||||
let sequence = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
logger.debug("[DatabaseRealtimeStreams][ingestData] Reading data", {
|
||||
streamId,
|
||||
runId,
|
||||
value,
|
||||
});
|
||||
|
||||
const chunks = value
|
||||
.split("\n")
|
||||
.filter((chunk) => chunk) // Remove empty lines
|
||||
.map((line) => {
|
||||
return {
|
||||
sequence: sequence++,
|
||||
value: line,
|
||||
};
|
||||
});
|
||||
|
||||
await this.options.prisma.realtimeStreamChunk.createMany({
|
||||
data: chunks.map((chunk) => {
|
||||
return {
|
||||
runId,
|
||||
key: streamId,
|
||||
sequence: chunk.sequence,
|
||||
value: chunk.value,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
} catch (error) {
|
||||
logger.error("[DatabaseRealtimeStreams][ingestData] Error in ingestData:", { error });
|
||||
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,5 +1,7 @@
|
||||
import Redis, { RedisKey, RedisOptions, RedisValue } from "ioredis";
|
||||
import { logger } from "./logger.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { StreamIngestor, StreamResponder } from "./types";
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
export type RealtimeStreamsOptions = {
|
||||
redis: RedisOptions | undefined;
|
||||
@@ -7,10 +9,17 @@ export type RealtimeStreamsOptions = {
|
||||
|
||||
const END_SENTINEL = "<<CLOSE_STREAM>>";
|
||||
|
||||
export class RealtimeStreams {
|
||||
// Class implementing both interfaces
|
||||
export class RedisRealtimeStreams implements StreamIngestor, StreamResponder {
|
||||
constructor(private options: RealtimeStreamsOptions) {}
|
||||
|
||||
async streamResponse(runId: string, streamId: string, signal: AbortSignal): Promise<Response> {
|
||||
async streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response> {
|
||||
const redis = new Redis(this.options.redis ?? {});
|
||||
const streamKey = `stream:${runId}:${streamId}`;
|
||||
let isCleanedUp = false;
|
||||
@@ -115,11 +124,10 @@ export class RealtimeStreams {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use TextDecoderStream to simplify text decoding
|
||||
const textStream = stream.pipeThrough(new TextDecoderStream());
|
||||
const reader = textStream.getReader();
|
||||
|
||||
const batchSize = 10; // Adjust this value based on performance testing
|
||||
const batchSize = 10;
|
||||
let batchCommands: Array<[key: RedisKey, ...args: RedisValue[]]> = [];
|
||||
|
||||
while (true) {
|
||||
@@ -131,17 +139,13 @@ export class RealtimeStreams {
|
||||
|
||||
logger.debug("[RealtimeStreams][ingestData] Reading data", { streamKey, value });
|
||||
|
||||
// 'value' is a string containing the decoded text
|
||||
const lines = value.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
// Avoid unnecessary parsing; assume 'line' is already a JSON string
|
||||
// Add XADD command with MAXLEN option to limit stream size
|
||||
batchCommands.push([streamKey, "MAXLEN", "~", "2500", "*", "data", line]);
|
||||
|
||||
if (batchCommands.length >= batchSize) {
|
||||
// Send batch using a pipeline
|
||||
const pipeline = redis.pipeline();
|
||||
for (const args of batchCommands) {
|
||||
pipeline.xadd(...args);
|
||||
@@ -153,7 +157,6 @@ export class RealtimeStreams {
|
||||
}
|
||||
}
|
||||
|
||||
// Send any remaining commands
|
||||
if (batchCommands.length > 0) {
|
||||
const pipeline = redis.pipeline();
|
||||
for (const args of batchCommands) {
|
||||
@@ -162,7 +165,6 @@ export class RealtimeStreams {
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
// Send the __end message to indicate the end of the stream
|
||||
await redis.xadd(streamKey, "MAXLEN", "~", "1000", "*", "data", END_SENTINEL);
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AuthenticatedEnvironment } from "../apiAuth.server";
|
||||
|
||||
// Interface for stream ingestion
|
||||
export interface StreamIngestor {
|
||||
ingestData(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
runId: string,
|
||||
streamId: string
|
||||
): Promise<Response>;
|
||||
}
|
||||
|
||||
// Interface for stream response
|
||||
export interface StreamResponder {
|
||||
streamResponse(
|
||||
request: Request,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
signal: AbortSignal
|
||||
): Promise<Response>;
|
||||
}
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { RealtimeStreams } from "./realtimeStreams.server";
|
||||
import { RedisRealtimeStreams } from "./redisRealtimeStreams.server";
|
||||
|
||||
function initializeRealtimeStreams() {
|
||||
return new RealtimeStreams({
|
||||
function initializeRedisRealtimeStreams() {
|
||||
return new RedisRealtimeStreams({
|
||||
redis: {
|
||||
port: env.REDIS_PORT,
|
||||
host: env.REDIS_HOST,
|
||||
@@ -16,4 +16,4 @@ function initializeRealtimeStreams() {
|
||||
});
|
||||
}
|
||||
|
||||
export const realtimeStreams = singleton("realtimeStreams", initializeRealtimeStreams);
|
||||
export const v1RealtimeStreams = singleton("realtimeStreams", initializeRedisRealtimeStreams);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { realtimeClient } from "../realtimeClientGlobal.server";
|
||||
import { DatabaseRealtimeStreams } from "./databaseRealtimeStreams.server";
|
||||
|
||||
function initializeDatabaseRealtimeStreams() {
|
||||
return new DatabaseRealtimeStreams({
|
||||
prisma,
|
||||
realtimeClient,
|
||||
});
|
||||
}
|
||||
|
||||
export const v2RealtimeStreams = singleton("dbRealtimeStreams", initializeDatabaseRealtimeStreams);
|
||||
@@ -37,6 +37,23 @@ export class RealtimeClient {
|
||||
this.#registerCommands();
|
||||
}
|
||||
|
||||
async streamChunks(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
runId: string,
|
||||
streamId: string,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
return this.#streamChunksWhere(
|
||||
url,
|
||||
environment,
|
||||
`"runId"='${runId}' AND "key"='${streamId}'`,
|
||||
signal,
|
||||
clientVersion
|
||||
);
|
||||
}
|
||||
|
||||
async streamRun(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
@@ -85,12 +102,12 @@ export class RealtimeClient {
|
||||
whereClause: string,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const electricUrl = this.#constructElectricUrl(url, whereClause, clientVersion);
|
||||
const electricUrl = this.#constructRunsElectricUrl(url, whereClause, clientVersion);
|
||||
|
||||
return this.#performElectricRequest(electricUrl, environment, clientVersion);
|
||||
return this.#performElectricRequest(electricUrl, environment, undefined, clientVersion);
|
||||
}
|
||||
|
||||
#constructElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
#constructRunsElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
const $url = new URL(url.toString());
|
||||
|
||||
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape`);
|
||||
@@ -112,9 +129,44 @@ export class RealtimeClient {
|
||||
return electricUrl;
|
||||
}
|
||||
|
||||
async #streamChunksWhere(
|
||||
url: URL | string,
|
||||
environment: RealtimeEnvironment,
|
||||
whereClause: string,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const electricUrl = this.#constructChunksElectricUrl(url, whereClause, clientVersion);
|
||||
|
||||
return this.#performElectricRequest(electricUrl, environment, signal, clientVersion);
|
||||
}
|
||||
|
||||
#constructChunksElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
|
||||
const $url = new URL(url.toString());
|
||||
|
||||
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape`);
|
||||
|
||||
// Copy over all the url search params to the electric url
|
||||
$url.searchParams.forEach((value, key) => {
|
||||
electricUrl.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
electricUrl.searchParams.set("where", whereClause);
|
||||
electricUrl.searchParams.set("table", `public."RealtimeStreamChunk"`);
|
||||
|
||||
if (!clientVersion) {
|
||||
// If the client version is not provided, that means we're using an older client
|
||||
// This means the client will be sending shape_id instead of handle
|
||||
electricUrl.searchParams.set("handle", electricUrl.searchParams.get("shape_id") ?? "");
|
||||
}
|
||||
|
||||
return electricUrl;
|
||||
}
|
||||
|
||||
async #performElectricRequest(
|
||||
url: URL,
|
||||
environment: RealtimeEnvironment,
|
||||
signal?: AbortSignal,
|
||||
clientVersion?: string
|
||||
) {
|
||||
const shapeId = extractShapeId(url);
|
||||
@@ -129,13 +181,13 @@ export class RealtimeClient {
|
||||
|
||||
if (!shapeId) {
|
||||
// If the shapeId is not present, we're just getting the initial value
|
||||
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
}
|
||||
|
||||
const isLive = isLiveRequestUrl(url);
|
||||
|
||||
if (!isLive) {
|
||||
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
return longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
@@ -177,7 +229,7 @@ export class RealtimeClient {
|
||||
|
||||
try {
|
||||
// ... (rest of your existing code for the long polling request)
|
||||
const response = await longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
|
||||
const response = await longPollingFetch(url.toString(), { signal }, rewriteResponseHeaders);
|
||||
|
||||
// Decrement the counter after the long polling request is complete
|
||||
await this.#decrementConcurrency(environment.id, requestId);
|
||||
|
||||
@@ -33,6 +33,7 @@ type ApiKeyRouteBuilderOptions<
|
||||
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
|
||||
authentication: ApiAuthenticationResultSuccess
|
||||
) => Promise<TResource | undefined>;
|
||||
shouldRetryNotFound?: boolean;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (
|
||||
@@ -81,6 +82,7 @@ export function createLoaderApiRoute<
|
||||
corsStrategy = "none",
|
||||
authorization,
|
||||
findResource,
|
||||
shouldRetryNotFound,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
@@ -162,7 +164,10 @@ export function createLoaderApiRoute<
|
||||
if (!resource) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Not found" }, { status: 404 }),
|
||||
json(
|
||||
{ error: "Not found" },
|
||||
{ status: 404, headers: { "x-should-retry": shouldRetryNotFound ? "true" : "false" } }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ function getWorkerQueue() {
|
||||
handler: async (payload, job) => {
|
||||
const service = new ResumeBatchRunService();
|
||||
|
||||
return await service.call(payload.batchRunId);
|
||||
await service.call(payload.batchRunId);
|
||||
},
|
||||
},
|
||||
"v3.resumeTaskDependency": {
|
||||
@@ -733,7 +733,7 @@ function getWorkerQueue() {
|
||||
priority: 0,
|
||||
maxAttempts: 5,
|
||||
handler: async (payload, job) => {
|
||||
const service = new BatchTriggerV2Service();
|
||||
const service = new BatchTriggerV2Service(payload.strategy);
|
||||
|
||||
await service.processBatchTaskRun(payload);
|
||||
},
|
||||
|
||||
@@ -662,12 +662,25 @@ export async function resolveVariablesForEnvironment(runtimeEnvironment: Runtime
|
||||
runtimeEnvironment.id
|
||||
);
|
||||
|
||||
const overridableTriggerVariables = await resolveOverridableTriggerVariables(runtimeEnvironment);
|
||||
|
||||
const builtInVariables =
|
||||
runtimeEnvironment.type === "DEVELOPMENT"
|
||||
? await resolveBuiltInDevVariables(runtimeEnvironment)
|
||||
: await resolveBuiltInProdVariables(runtimeEnvironment);
|
||||
|
||||
return [...projectSecrets, ...builtInVariables];
|
||||
return [...overridableTriggerVariables, ...projectSecrets, ...builtInVariables];
|
||||
}
|
||||
|
||||
async function resolveOverridableTriggerVariables(runtimeEnvironment: RuntimeEnvironment) {
|
||||
let result: Array<EnvironmentVariable> = [
|
||||
{
|
||||
key: "TRIGGER_REALTIME_STREAM_VERSION",
|
||||
value: env.REALTIME_STREAM_VERSION,
|
||||
},
|
||||
];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveBuiltInDevVariables(runtimeEnvironment: RuntimeEnvironment) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parsePacket,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchTaskRun, Prisma, TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { $transaction, prisma, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
@@ -25,12 +25,10 @@ import { z } from "zod";
|
||||
|
||||
const PROCESSING_BATCH_SIZE = 50;
|
||||
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
|
||||
type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
const CURRENT_STRATEGY: BatchProcessingStrategy = "parallel";
|
||||
export const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
export type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
export const BatchProcessingOptions = z.object({
|
||||
batchId: z.string(),
|
||||
@@ -52,6 +50,17 @@ export type BatchTriggerTaskServiceOptions = {
|
||||
};
|
||||
|
||||
export class BatchTriggerV2Service extends BaseService {
|
||||
private _batchProcessingStrategy: BatchProcessingStrategy;
|
||||
|
||||
constructor(
|
||||
batchProcessingStrategy?: BatchProcessingStrategy,
|
||||
protected readonly _prisma: PrismaClientOrTransaction = prisma
|
||||
) {
|
||||
super(_prisma);
|
||||
|
||||
this._batchProcessingStrategy = batchProcessingStrategy ?? "parallel";
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
@@ -452,14 +461,14 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
switch (CURRENT_STRATEGY) {
|
||||
switch (this._batchProcessingStrategy) {
|
||||
case "sequential": {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: batchId,
|
||||
range: { start: 0, count: PROCESSING_BATCH_SIZE },
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
});
|
||||
|
||||
break;
|
||||
@@ -480,7 +489,7 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
processingId: `${index}`,
|
||||
range,
|
||||
attemptCount: 0,
|
||||
strategy: CURRENT_STRATEGY,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
},
|
||||
tx
|
||||
)
|
||||
@@ -539,6 +548,16 @@ export class BatchTriggerV2Service extends BaseService {
|
||||
|
||||
const $attemptCount = options.attemptCount + 1;
|
||||
|
||||
// Add early return if max attempts reached
|
||||
if ($attemptCount > MAX_ATTEMPTS) {
|
||||
logger.error("[BatchTriggerV2][processBatchTaskRun] Max attempts reached", {
|
||||
options,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
// You might want to update the batch status to failed here
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = await this._prisma.batchTaskRun.findFirst({
|
||||
where: { id: options.batchId },
|
||||
include: {
|
||||
|
||||
@@ -208,6 +208,15 @@ export async function createBackgroundTasks(
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
} else {
|
||||
logger.debug("CreateBackgroundWorkerService: removing concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
concurrencyLimit,
|
||||
taskidentifier: task.id,
|
||||
});
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -64,9 +64,13 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
completedAt,
|
||||
});
|
||||
|
||||
// I moved the error update here for two reasons:
|
||||
// - A single update is more efficient than two
|
||||
// - If the status updates to a final status, realtime will receive that status and then shut down the stream
|
||||
// before the error is updated, which would cause the error to be lost
|
||||
const run = await this._prisma.taskRun.update({
|
||||
where: { id },
|
||||
data: { status, expiredAt, completedAt },
|
||||
data: { status, expiredAt, completedAt, error: error ? sanitizeError(error) : undefined },
|
||||
...(include ? { include } : {}),
|
||||
});
|
||||
|
||||
@@ -78,10 +82,6 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
await this.finalizeAttempt({ attemptStatus, error, run });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
await this.finalizeRunError(run, error);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#finalizeBatch(run);
|
||||
} catch (finalizeBatchError) {
|
||||
@@ -211,15 +211,6 @@ export class FinalizeTaskRunService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeRunError(run: TaskRun, error: TaskRunError) {
|
||||
await this._prisma.taskRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
error: sanitizeError(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async finalizeAttempt({
|
||||
attemptStatus,
|
||||
error,
|
||||
|
||||
@@ -35,7 +35,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
batchRunId,
|
||||
}
|
||||
);
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
if (batchRun.status === "COMPLETED") {
|
||||
@@ -46,7 +47,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
if (batchRun.items.some((item) => !finishedBatchRunStatuses.includes(item.status))) {
|
||||
@@ -57,7 +59,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: batchRun.status,
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "PENDING";
|
||||
}
|
||||
|
||||
// If we are in development, or there is no dependent attempt, we can just mark the batch as completed and return
|
||||
@@ -71,7 +74,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
return "COMPLETED";
|
||||
}
|
||||
|
||||
const dependentTaskAttempt = await this._prisma.taskRunAttempt.findFirst({
|
||||
@@ -98,12 +102,11 @@ export class ResumeBatchRunService extends BaseService {
|
||||
dependentTaskAttemptId: batchRun.dependentTaskAttemptId,
|
||||
});
|
||||
|
||||
return;
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
// This batch has a dependent attempt and just finalized, we should resume that attempt
|
||||
const environment = batchRun.runtimeEnvironment;
|
||||
|
||||
const dependentRun = dependentTaskAttempt.taskRun;
|
||||
|
||||
if (dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) {
|
||||
@@ -115,11 +118,13 @@ export class ResumeBatchRunService extends BaseService {
|
||||
|
||||
// We need to update the batchRun status so we don't resume it again
|
||||
const wasUpdated = await this.#setBatchToCompletedOnce(batchRun.id);
|
||||
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run with checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
dependentTaskAttemptId: dependentTaskAttempt.id,
|
||||
});
|
||||
|
||||
await marqs?.enqueueMessage(
|
||||
environment,
|
||||
dependentRun.queue,
|
||||
@@ -136,6 +141,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
},
|
||||
dependentRun.concurrencyKey ?? undefined
|
||||
);
|
||||
|
||||
return "COMPLETED";
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: with checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -143,6 +150,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
return "ALREADY_COMPLETED";
|
||||
}
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: attempt is not paused or there's no checkpoint event", {
|
||||
@@ -161,11 +170,13 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
return;
|
||||
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
// We need to update the batchRun status so we don't resume it again
|
||||
const wasUpdated = await this.#setBatchToCompletedOnce(batchRun.id);
|
||||
|
||||
if (wasUpdated) {
|
||||
logger.debug("ResumeBatchRunService: Resuming dependent run without checkpoint", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -173,6 +184,7 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
await marqs?.replaceMessage(dependentRun.id, {
|
||||
type: "RESUME",
|
||||
completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean),
|
||||
@@ -183,6 +195,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return "COMPLETED";
|
||||
} else {
|
||||
logger.debug("ResumeBatchRunService: without checkpoint was already completed", {
|
||||
batchRunId: batchRun.id,
|
||||
@@ -190,6 +204,8 @@ export class ResumeBatchRunService extends BaseService {
|
||||
checkpointEventId: batchRun.checkpointEventId,
|
||||
hasCheckpointEvent: !!batchRun.checkpointEventId,
|
||||
});
|
||||
|
||||
return "ALREADY_COMPLETED";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,6 +474,16 @@ export class TriggerTaskService extends BaseService {
|
||||
taskQueue.concurrencyLimit
|
||||
);
|
||||
} else {
|
||||
logger.debug("TriggerTaskService: removing concurrency limit", {
|
||||
runId: taskRun.id,
|
||||
friendlyId: taskRun.friendlyId,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
existingConcurrencyLimit,
|
||||
concurrencyLimit,
|
||||
queueOptions: body.options?.queue,
|
||||
});
|
||||
await marqs?.removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { containerWithElectricTest } from "@internal/testcontainers";
|
||||
import { containerWithElectricAndRedisTest } from "@internal/testcontainers";
|
||||
import { expect, describe } from "vitest";
|
||||
import { RealtimeClient } from "../app/services/realtimeClient.server.js";
|
||||
|
||||
describe("RealtimeClient", () => {
|
||||
containerWithElectricTest(
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should only track concurrency for live requests",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
@@ -139,7 +139,7 @@ describe("RealtimeClient", () => {
|
||||
}
|
||||
);
|
||||
|
||||
containerWithElectricTest(
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should support subscribing to a run tag",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
@@ -218,7 +218,7 @@ describe("RealtimeClient", () => {
|
||||
}
|
||||
);
|
||||
|
||||
containerWithElectricTest(
|
||||
containerWithElectricAndRedisTest(
|
||||
"Should adapt for older client versions",
|
||||
{ timeout: 30_000 },
|
||||
async ({ redis, electricOrigin, prisma }) => {
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { redisTest } from "@internal/testcontainers";
|
||||
import { describe, expect, vi } from "vitest";
|
||||
import { RealtimeStreams } from "../app/services/realtimeStreams.server.js";
|
||||
import { convertArrayToReadableStream, convertResponseSSEStreamToArray } from "./utils/streams.js";
|
||||
|
||||
vi.setConfig({ testTimeout: 10_000 }); // 5 seconds
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("./logger.server", () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("RealtimeStreams", () => {
|
||||
redisTest("should stream data from producer to consumer", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
const streamId = "test-stream";
|
||||
|
||||
// Create a stream of test data
|
||||
const stream = convertArrayToReadableStream(["chunk1", "chunk2", "chunk3"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming the stream
|
||||
const abortController = new AbortController();
|
||||
const responsePromise = streams.streamResponse(runId, streamId, abortController.signal);
|
||||
|
||||
// Start ingesting data
|
||||
await streams.ingestData(stream, runId, streamId);
|
||||
|
||||
// Get the response and read the stream
|
||||
const response = await responsePromise;
|
||||
const received = await convertResponseSSEStreamToArray(response);
|
||||
|
||||
expect(received).toEqual(["chunk1", "chunk2", "chunk3"]);
|
||||
});
|
||||
|
||||
redisTest("should handle multiple concurrent streams", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
|
||||
// Set up two different streams
|
||||
const stream1 = convertArrayToReadableStream(["1a", "1b", "1c"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
const stream2 = convertArrayToReadableStream(["2a", "2b", "2c"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming both streams
|
||||
const abortController = new AbortController();
|
||||
const response1Promise = streams.streamResponse(runId, "stream1", abortController.signal);
|
||||
const response2Promise = streams.streamResponse(runId, "stream2", abortController.signal);
|
||||
|
||||
// Ingest data to both streams
|
||||
await Promise.all([
|
||||
streams.ingestData(stream1, runId, "stream1"),
|
||||
streams.ingestData(stream2, runId, "stream2"),
|
||||
]);
|
||||
|
||||
// Get and verify both responses
|
||||
const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
|
||||
const [received1, received2] = await Promise.all([
|
||||
convertResponseSSEStreamToArray(response1),
|
||||
convertResponseSSEStreamToArray(response2),
|
||||
]);
|
||||
|
||||
expect(received1).toEqual(["1a", "1b", "1c"]);
|
||||
expect(received2).toEqual(["2a", "2b", "2c"]);
|
||||
});
|
||||
|
||||
redisTest("should handle early consumer abort", async ({ redis }) => {
|
||||
const streams = new RealtimeStreams({ redis: redis.options });
|
||||
const runId = "test-run";
|
||||
const streamId = "test-stream";
|
||||
|
||||
const stream = convertArrayToReadableStream(["chunk1", "chunk2", "chunk3"]).pipeThrough(
|
||||
new TextEncoderStream()
|
||||
);
|
||||
|
||||
// Start consuming but abort early
|
||||
const abortController = new AbortController();
|
||||
const responsePromise = streams.streamResponse(runId, streamId, abortController.signal);
|
||||
|
||||
// Get the response before aborting to ensure stream is properly set up
|
||||
const response = await responsePromise;
|
||||
|
||||
// Start reading the stream
|
||||
const readPromise = convertResponseSSEStreamToArray(response);
|
||||
|
||||
// Abort after a small delay to ensure everything is set up
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
abortController.abort();
|
||||
|
||||
// Start ingesting data after abort
|
||||
await streams.ingestData(stream, runId, streamId);
|
||||
|
||||
// Verify the stream was terminated
|
||||
const received = await readPromise;
|
||||
|
||||
expect(received).toEqual(["chunk1"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM postgres:14
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y postgresql-14-partman \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -13,7 +13,9 @@ networks:
|
||||
services:
|
||||
database:
|
||||
container_name: database
|
||||
image: postgres:14
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.postgres
|
||||
restart: always
|
||||
volumes:
|
||||
- ${DB_VOLUME:-database-data}:/var/lib/postgresql/data/
|
||||
@@ -30,6 +32,8 @@ services:
|
||||
- listen_addresses=*
|
||||
- -c
|
||||
- wal_level=logical
|
||||
- -c
|
||||
- shared_preload_libraries=pg_partman_bgw
|
||||
|
||||
pgadmin:
|
||||
container_name: pgadmin
|
||||
@@ -61,7 +65,7 @@ services:
|
||||
- 6379:6379
|
||||
|
||||
electric:
|
||||
image: electricsql/electric:0.8.1
|
||||
image: electricsql/electric:0.9.4
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres?sslmode=disable
|
||||
|
||||
@@ -89,35 +89,6 @@ const publicToken = await auth.createPublicToken({
|
||||
});
|
||||
```
|
||||
|
||||
### Write scopes
|
||||
|
||||
You can also specify write scopes, which is required for triggering tasks from your frontend application:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
write: {
|
||||
tasks: ["my-task-1", "my-task-2"],
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This will allow the token to trigger the specified tasks. `tasks` is the only write scope available at the moment.
|
||||
|
||||
We **strongly** recommend creating short-lived tokens for write scopes, as they can be used to trigger tasks from your frontend application:
|
||||
|
||||
```ts
|
||||
const publicToken = await auth.createPublicToken({
|
||||
scopes: {
|
||||
write: {
|
||||
tasks: ["my-task-1"], // ✅ this token can trigger this task
|
||||
},
|
||||
},
|
||||
expirationTime: "1m", // ✅ this token will expire after 1 minute
|
||||
});
|
||||
```
|
||||
|
||||
### Expiration
|
||||
|
||||
By default, Public Access Token's expire after 15 minutes. You can specify a different expiration time when creating a Public Access Token:
|
||||
|
||||
@@ -1,797 +0,0 @@
|
||||
---
|
||||
title: React hooks
|
||||
sidebarTitle: React hooks
|
||||
description: Using the Trigger.dev v3 API from your React application.
|
||||
---
|
||||
|
||||
Our react hooks package provides a set of hooks that make it easy to interact with the Trigger.dev API from your React application, using our [frontend API](/frontend/overview). You can use these hooks to fetch runs, batches, and subscribe to real-time updates.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
All hooks accept an optional last argument `options` that accepts an `accessToken` param, which should be a valid Public Access Token. Learn more about [generating tokens in the frontend guide](/frontend/overview).
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken, // This is required
|
||||
baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can use our `TriggerAuthContext` provider
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: publicAccessToken }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Now children components can use the hooks to interact with the Trigger.dev API. If you are self-hosting Trigger.dev, you can provide the `baseURL` to the `TriggerAuthContext` provider.
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://your-trigger-dev-instance.com",
|
||||
}}
|
||||
>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js and client components
|
||||
|
||||
If you are using Next.js with the App Router, you have to make sure the component that uses the `TriggerAuthContext` is a client component. So for example, the following code will not work:
|
||||
|
||||
```tsx app/page.tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: "your-access-token" }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
That's because `Page` is a server component and the `TriggerAuthContext.Provider` uses client-only react code. To fix this, wrap the `TriggerAuthContext.Provider` in a client component:
|
||||
|
||||
```ts components/TriggerProvider.tsx
|
||||
"use client";
|
||||
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function TriggerProvider({
|
||||
accessToken,
|
||||
children,
|
||||
}: {
|
||||
accessToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the token to the frontend
|
||||
|
||||
Techniques for passing the token to the frontend vary depending on your setup. Here are a few ways to do it for different setups:
|
||||
|
||||
#### Next.js App Router
|
||||
|
||||
If you are using Next.js with the App Router and you are triggering a task from a server action, you can use cookies to store and pass the token to the frontend.
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
"use server";
|
||||
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
// Set the auto-generated publicAccessToken in a cookie
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run
|
||||
|
||||
redirect(`/runs/${handle.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Then in the `/runs/[id].tsx` page, you can read the token from the cookie and pass it to the `TriggerProvider`.
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({ params }: { params: { id: string } }) {
|
||||
const publicAccessToken = cookies().get("publicAccessToken");
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Instead of a cookie, you could also use a query parameter to pass the token to the frontend:
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);
|
||||
}
|
||||
```
|
||||
|
||||
And then in the `/runs/[id].tsx` page:
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { publicAccessToken: string };
|
||||
}) {
|
||||
return (
|
||||
<TriggerProvider accessToken={searchParams.publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Another alternative would be to use a server-side rendered page to fetch the token and pass it to the frontend:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
import { generatePublicAccessToken } from "@/trigger/auth";
|
||||
|
||||
export default async function RunPage({ params }: { params: { id: string } }) {
|
||||
// This will be executed on the server only
|
||||
const publicAccessToken = await generatePublicAccessToken(params.id);
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx trigger/auth.ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export async function generatePublicAccessToken(runId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: [runId],
|
||||
},
|
||||
},
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## SWR vs Realtime hooks
|
||||
|
||||
We offer two "styles" of hooks: SWR and Realtime. The SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. The Realtime hooks use [Trigger.dev realtime](/realtime) to subscribe to updates in real-time.
|
||||
|
||||
<Note>
|
||||
It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be
|
||||
configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works,
|
||||
we recommend using the Realtime hooks for most use-cases.
|
||||
</Note>
|
||||
|
||||
All hooks named `useRealtime*` are Realtime hooks, and all hooks named `use*` are SWR hooks.
|
||||
|
||||
## Realtime hooks
|
||||
|
||||
### useRealtimeRun
|
||||
|
||||
The `useRealtimeRun` hook allows you to subscribe to a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information about the type of the run object and more.
|
||||
|
||||
### useRealtimeRunsWithTag
|
||||
|
||||
The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now runs[i].payload and runs[i].output are correctly typed
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask1, myTask2 } from "@/trigger/myTasks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask1 | typeof myTask2>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// You can narrow down the type of the run based on the taskIdentifier
|
||||
for (const run of runs) {
|
||||
if (run.taskIdentifier === "my-task-1") {
|
||||
// run is correctly typed as myTask1
|
||||
} else if (run.taskIdentifier === "my-task-2") {
|
||||
// run is correctly typed as myTask2
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeBatch
|
||||
|
||||
The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeBatch } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ batchId }: { batchId: string }) {
|
||||
const { runs, error } = useRealtimeBatch(batchId);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeRunWithStreams
|
||||
|
||||
The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. See our [Realtime documentation](/realtime#streams) for more information about emitting streams from a task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>
|
||||
{Object.keys(streams).map((stream) => (
|
||||
<div key={stream}>Stream: {stream}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can provide the type of the streams to the `useRealtimeRunWithStreams` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object:
|
||||
|
||||
```tsx
|
||||
import type { TextStreamPart } from "ai";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = { openai: TextStreamPart<{}> };
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai
|
||||
?.filter((stream) => stream.type === "text-delta")
|
||||
?.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
#### enabled
|
||||
|
||||
You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to conditionally disable using the hook based on some state.
|
||||
|
||||
#### id
|
||||
|
||||
You can pass the `id` option to the Realtime hooks to change the ID of the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
id,
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
id,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data).
|
||||
|
||||
#### experimental_throttleInMs
|
||||
|
||||
The `*withStreams` variants of the Realtime hooks accept an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { runs, error } = useRealtimeRunsWithStreams(tag, {
|
||||
accessToken: publicAccessToken,
|
||||
experimental_throttleInMs: 1000, // Throttle updates to once per second
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## SWR Hooks
|
||||
|
||||
### useRun
|
||||
|
||||
The `useRun` hook allows you to fetch a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun(runId);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The `run` object returned is the same as the [run object](/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun<typeof myTask>(runId, {
|
||||
refreshInterval: 0, // Disable polling
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
You can pass the following options to the all SWR hooks:
|
||||
|
||||
<ParamField path="revalidateOnFocus" type="boolean">
|
||||
Revalidate the data when the window regains focus.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="revalidateOnReconnect" type="boolean">
|
||||
Revalidate the data when the browser regains a network connection.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="refreshInterval" type="number">
|
||||
Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most
|
||||
use-cases. Use the Realtime hooks instead.
|
||||
</ParamField>
|
||||
|
||||
### Common return values
|
||||
|
||||
<ResponseField name="error" type="Error">
|
||||
An error object if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isLoading" type="boolean">
|
||||
A boolean indicating if the data is currently being fetched.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isValidating" type="boolean">
|
||||
A boolean indicating if the data is currently being revalidated.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isError" type="boolean">
|
||||
A boolean indicating if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
## Trigger Hooks
|
||||
|
||||
We provide a set of hooks that can be used to trigger tasks from your frontend application. You'll need to generate a Public Access Token with `write` permissions to use these hooks. See our [frontend guide](/frontend/overview#write-scopes) for more information.
|
||||
|
||||
### useTaskTrigger
|
||||
|
||||
The `useTaskTrigger` hook allows you to trigger a task from your frontend application.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTrigger
|
||||
|
||||
The `useRealtimeTaskTrigger` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime:
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, error, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
// This is the realtime run object, which will automatically update when the run changes
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTriggerWithStreams
|
||||
|
||||
The `useRealtimeTaskTriggerWithStreams` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime, and also receive any streams that are emitted by the task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTriggerWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, streams, error, isLoading } = useRealtimeTaskTriggerWithStreams<
|
||||
typeof myTask,
|
||||
STREAMS
|
||||
>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (streams && run) {
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run ID: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,333 @@
|
||||
---
|
||||
title: Overview
|
||||
sidebarTitle: Overview
|
||||
description: Using the Trigger.dev v3 API from your React application.
|
||||
---
|
||||
|
||||
Our react hooks package provides a set of hooks that make it easy to interact with the Trigger.dev API from your React application, using our [frontend API](/frontend/overview). You can use these hooks to fetch runs, and subscribe to real-time updates, and trigger tasks from your frontend application.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
All hooks accept an optional last argument `options` that accepts an `accessToken` param, which should be a valid Public Access Token. Learn more about [generating tokens in the frontend guide](/frontend/overview).
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken, // This is required
|
||||
baseURL: "https://your-trigger-dev-instance.com", // optional, only needed if you are self-hosting Trigger.dev
|
||||
});
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, you can use our `TriggerAuthContext` provider
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: publicAccessToken }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Now children components can use the hooks to interact with the Trigger.dev API. If you are self-hosting Trigger.dev, you can provide the `baseURL` to the `TriggerAuthContext` provider.
|
||||
|
||||
```tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function SetupTrigger({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://your-trigger-dev-instance.com",
|
||||
}}
|
||||
>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Next.js and client components
|
||||
|
||||
If you are using Next.js with the App Router, you have to make sure the component that uses the `TriggerAuthContext` is a client component. So for example, the following code will not work:
|
||||
|
||||
```tsx app/page.tsx
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<TriggerAuthContext.Provider value={{ accessToken: "your-access-token" }}>
|
||||
<MyComponent />
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
That's because `Page` is a server component and the `TriggerAuthContext.Provider` uses client-only react code. To fix this, wrap the `TriggerAuthContext.Provider` in a client component:
|
||||
|
||||
```ts components/TriggerProvider.tsx
|
||||
"use client";
|
||||
|
||||
import { TriggerAuthContext } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function TriggerProvider({
|
||||
accessToken,
|
||||
children,
|
||||
}: {
|
||||
accessToken: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<TriggerAuthContext.Provider
|
||||
value={{
|
||||
accessToken,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TriggerAuthContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Passing the token to the frontend
|
||||
|
||||
Techniques for passing the token to the frontend vary depending on your setup. Here are a few ways to do it for different setups:
|
||||
|
||||
#### Next.js App Router
|
||||
|
||||
If you are using Next.js with the App Router and you are triggering a task from a server action, you can use cookies to store and pass the token to the frontend.
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
"use server";
|
||||
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
// Set the auto-generated publicAccessToken in a cookie
|
||||
cookies().set("publicAccessToken", handle.publicAccessToken); // ✅ this token only has access to read this run
|
||||
|
||||
redirect(`/runs/${handle.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
Then in the `/runs/[id].tsx` page, you can read the token from the cookie and pass it to the `TriggerProvider`.
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({ params }: { params: { id: string } }) {
|
||||
const publicAccessToken = cookies().get("publicAccessToken");
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Instead of a cookie, you could also use a query parameter to pass the token to the frontend:
|
||||
|
||||
```tsx actions/trigger.ts
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
import type { exampleTask } from "@/trigger/example";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function startRun() {
|
||||
const handle = await tasks.trigger<typeof exampleTask>("example", { foo: "bar" });
|
||||
|
||||
redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`);
|
||||
}
|
||||
```
|
||||
|
||||
And then in the `/runs/[id].tsx` page:
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
|
||||
export default function RunPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { id: string };
|
||||
searchParams: { publicAccessToken: string };
|
||||
}) {
|
||||
return (
|
||||
<TriggerProvider accessToken={searchParams.publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Another alternative would be to use a server-side rendered page to fetch the token and pass it to the frontend:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx pages/runs/[id].tsx
|
||||
import { TriggerProvider } from "@/components/TriggerProvider";
|
||||
import { generatePublicAccessToken } from "@/trigger/auth";
|
||||
|
||||
export default async function RunPage({ params }: { params: { id: string } }) {
|
||||
// This will be executed on the server only
|
||||
const publicAccessToken = await generatePublicAccessToken(params.id);
|
||||
|
||||
return (
|
||||
<TriggerProvider accessToken={publicAccessToken}>
|
||||
<RunDetails id={params.id} />
|
||||
</TriggerProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx trigger/auth.ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export async function generatePublicAccessToken(runId: string) {
|
||||
return auth.createPublicToken({
|
||||
scopes: {
|
||||
read: {
|
||||
runs: [runId],
|
||||
},
|
||||
},
|
||||
expirationTime: "1h",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## SWR vs Realtime hooks
|
||||
|
||||
We offer two "styles" of hooks: SWR and Realtime. The SWR hooks use the [swr](https://swr.vercel.app/) library to fetch data once and cache it. The Realtime hooks use [Trigger.dev realtime](/realtime) to subscribe to updates in real-time.
|
||||
|
||||
<Note>
|
||||
It can be a little confusing which one to use because [swr](https://swr.vercel.app/) can also be
|
||||
configured to poll for updates. But because of rate-limits and the way the Trigger.dev API works,
|
||||
we recommend using the Realtime hooks for most use-cases.
|
||||
</Note>
|
||||
|
||||
## SWR Hooks
|
||||
|
||||
### useRun
|
||||
|
||||
The `useRun` hook allows you to fetch a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun(runId);
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
The `run` object returned is the same as the [run object](/management/runs/retrieve) returned by the Trigger.dev API. To correctly type the run's payload and output, you can provide the type of your task to the `useRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ runId }: { runId: string }) {
|
||||
const { run, error, isLoading } = useRun<typeof myTask>(runId, {
|
||||
refreshInterval: 0, // Disable polling
|
||||
});
|
||||
|
||||
if (isLoading) return <div>Loading...</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Common options
|
||||
|
||||
You can pass the following options to the all SWR hooks:
|
||||
|
||||
<ParamField path="revalidateOnFocus" type="boolean">
|
||||
Revalidate the data when the window regains focus.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="revalidateOnReconnect" type="boolean">
|
||||
Revalidate the data when the browser regains a network connection.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="refreshInterval" type="number">
|
||||
Poll for updates at the specified interval (in milliseconds). Polling is not recommended for most
|
||||
use-cases. Use the Realtime hooks instead.
|
||||
</ParamField>
|
||||
|
||||
### Common return values
|
||||
|
||||
<ResponseField name="error" type="Error">
|
||||
An error object if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isLoading" type="boolean">
|
||||
A boolean indicating if the data is currently being fetched.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isValidating" type="boolean">
|
||||
A boolean indicating if the data is currently being revalidated.
|
||||
</ResponseField>
|
||||
|
||||
<ResponseField name="isError" type="boolean">
|
||||
A boolean indicating if an error occurred while fetching the data.
|
||||
</ResponseField>
|
||||
|
||||
## Realtime hooks
|
||||
|
||||
See our [Realtime hooks documentation](/frontend/react-hooks/realtime) for more information.
|
||||
|
||||
## Trigger Hooks
|
||||
|
||||
See our [Trigger hooks documentation](/frontend/react-hooks/triggering) for more information.
|
||||
@@ -0,0 +1,416 @@
|
||||
---
|
||||
title: Realtime hooks
|
||||
sidebarTitle: Realtime
|
||||
description: Get live updates from the Trigger.dev API in your frontend application.
|
||||
---
|
||||
|
||||
These hooks allow you to subscribe to runs, batches, and streams using [Trigger.dev realtime](/realtime). Before reading this guide:
|
||||
|
||||
- Read our [Realtime documentation](/realtime) to understand how the Trigger.dev realtime API works.
|
||||
- Read how to [setup and authenticate](/frontend/overview) using the `@trigger.dev/react-hooks` package.
|
||||
|
||||
## Hooks
|
||||
|
||||
### useRealtimeRun
|
||||
|
||||
The `useRealtimeRun` hook allows you to subscribe to a run by its ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the run's payload and output, you can provide the type of your task to the `useRealtimeRun` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun<typeof myTask>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now run.payload and run.output are correctly typed
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
You can supply an `onComplete` callback to the `useRealtimeRun` hook to be called when the run is completed or errored. This is useful if you want to perform some action when the run is completed, like navigating to a different page or showing a notification.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
onComplete: (run, error) => {
|
||||
console.log("Run completed", run);
|
||||
},
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information about the type of the run object and more.
|
||||
|
||||
### useRealtimeRunsWithTag
|
||||
|
||||
The `useRealtimeRunsWithTag` hook allows you to subscribe to multiple runs with a specific tag.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
To correctly type the runs payload and output, you can provide the type of your task to the `useRealtimeRunsWithTag` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// Now runs[i].payload and runs[i].output are correctly typed
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
If `useRealtimeRunsWithTag` could return multiple different types of tasks, you can pass a union of all the task types to the hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithTag } from "@trigger.dev/react-hooks";
|
||||
import type { myTask1, myTask2 } from "@/trigger/myTasks";
|
||||
|
||||
export function MyComponent({ tag }: { tag: string }) {
|
||||
const { runs, error } = useRealtimeRunsWithTag<typeof myTask1 | typeof myTask2>(tag);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
// You can narrow down the type of the run based on the taskIdentifier
|
||||
for (const run of runs) {
|
||||
if (run.taskIdentifier === "my-task-1") {
|
||||
// run is correctly typed as myTask1
|
||||
} else if (run.taskIdentifier === "my-task-2") {
|
||||
// run is correctly typed as myTask2
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeBatch
|
||||
|
||||
The `useRealtimeBatch` hook allows you to subscribe to a batch of runs by its the batch ID.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeBatch } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({ batchId }: { batchId: string }) {
|
||||
const { runs, error } = useRealtimeBatch(batchId);
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
See our [Realtime documentation](/realtime) for more information.
|
||||
|
||||
### useRealtimeRunWithStreams
|
||||
|
||||
The `useRealtimeRunWithStreams` hook allows you to subscribe to a run by its ID and also receive any streams that are emitted by the task. See our [Realtime documentation](/realtime#streams) for more information about emitting streams from a task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>
|
||||
{Object.keys(streams).map((stream) => (
|
||||
<div key={stream}>Stream: {stream}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can provide the type of the streams to the `useRealtimeRunWithStreams` hook:
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
As you can see above, each stream is an array of the type you provided, keyed by the stream name. If instead of a pure text stream you have a stream of objects, you can provide the type of the object:
|
||||
|
||||
```tsx
|
||||
import type { TextStreamPart } from "ai";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = { openai: TextStreamPart<{}> };
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, STREAMS>(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
const text = streams.openai
|
||||
?.filter((stream) => stream.type === "text-delta")
|
||||
?.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Common options
|
||||
|
||||
### accessToken & baseURL
|
||||
|
||||
You can pass the `accessToken` option to the Realtime hooks to authenticate the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
baseURL: "https://my-self-hosted-trigger.com", // Optional if you are using a self-hosted Trigger.dev instance
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### enabled
|
||||
|
||||
You can pass the `enabled` option to the Realtime hooks to enable or disable the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to conditionally disable using the hook based on some state.
|
||||
|
||||
### id
|
||||
|
||||
You can pass the `id` option to the Realtime hooks to change the ID of the subscription.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
id,
|
||||
runId,
|
||||
publicAccessToken,
|
||||
enabled,
|
||||
}: {
|
||||
id: string;
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const { run, error } = useRealtimeRun(runId, {
|
||||
accessToken: publicAccessToken,
|
||||
enabled,
|
||||
id,
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return <div>Run: {run.id}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
This allows you to change the ID of the subscription based on some state. Passing in a different ID will unsubscribe from the current subscription and subscribe to the new one (and remove any cached data).
|
||||
|
||||
### experimental_throttleInMs
|
||||
|
||||
The `*withStreams` variants of the Realtime hooks accept an `experimental_throttleInMs` option to throttle the updates from the server. This can be useful if you are getting too many updates and want to reduce the number of updates.
|
||||
|
||||
```tsx
|
||||
import { useRealtimeRunsWithStreams } from "@trigger.dev/react-hooks";
|
||||
|
||||
export function MyComponent({
|
||||
runId,
|
||||
publicAccessToken,
|
||||
}: {
|
||||
runId: string;
|
||||
publicAccessToken: string;
|
||||
}) {
|
||||
const { runs, error } = useRealtimeRunsWithStreams(tag, {
|
||||
accessToken: publicAccessToken,
|
||||
experimental_throttleInMs: 1000, // Throttle updates to once per second
|
||||
});
|
||||
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{runs.map((run) => (
|
||||
<div key={run.id}>Run: {run.id}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,250 @@
|
||||
---
|
||||
title: Trigger hooks
|
||||
sidebarTitle: Triggering
|
||||
description: Triggering tasks from your frontend application.
|
||||
---
|
||||
|
||||
We provide a set of hooks that can be used to trigger tasks from your frontend application.
|
||||
|
||||
## Demo
|
||||
|
||||
We've created a [Demo application](https://github.com/triggerdotdev/realtime-llm-battle) that demonstrates how to use our React hooks to trigger tasks in a Next.js application. The application uses the `@trigger.dev/react-hooks` package to trigger a task and subscribe to the run in real-time.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the `@trigger.dev/react-hooks` package in your project:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```bash npm
|
||||
npm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash pnpm
|
||||
pnpm add @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
```bash yarn
|
||||
yarn install @trigger.dev/react-hooks
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Authentication
|
||||
|
||||
To authenticate a trigger hook, you must provide a special one-time use "trigger" token. These tokens are very similar to [Public Access Tokens](/frontend/overview#authentication), but they can only be used once to trigger a task. You can generate a trigger token using the `auth.createTriggerPublicToken` function in your backend code:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task");
|
||||
```
|
||||
|
||||
These tokens also expire, with the default expiration time being 15 minutes. You can specify a custom expiration time by passing a `expirationTime` parameter:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task", {
|
||||
expirationTime: "24hr",
|
||||
});
|
||||
```
|
||||
|
||||
You can also pass multiple tasks to the `createTriggerPublicToken` function to create a token that can trigger multiple tasks:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken(["my-task-1", "my-task-2"]);
|
||||
```
|
||||
|
||||
You can also pass the `multipleUse` parameter to create a token that can be used multiple times:
|
||||
|
||||
```ts
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// Somewhere in your backend code
|
||||
const triggerToken = await auth.createTriggerPublicToken("my-task", {
|
||||
multipleUse: true, // ❌ Use this with caution!
|
||||
});
|
||||
```
|
||||
|
||||
<Note>
|
||||
After generating the trigger token in your backend, you must pass it to your frontend application.
|
||||
We have a guide on how to do this in the [React hooks
|
||||
overview](/frontend/react-hooks/overview#passing-the-token-to-the-frontend).
|
||||
</Note>
|
||||
|
||||
## Hooks
|
||||
|
||||
### useTaskTrigger
|
||||
|
||||
The `useTaskTrigger` hook allows you to trigger a task from your frontend application.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
// 👆 This is the type of your task
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
// pass the type of your task here 👇
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken, // 👈 this is the "trigger" token
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`useTaskTrigger` returns an object with the following properties:
|
||||
|
||||
- `submit`: A function that triggers the task. It takes the payload of the task as an argument.
|
||||
- `handle`: The run handle object. This object contains the ID of the run that was triggered, along with a Public Access Token that can be used to access the run.
|
||||
- `isLoading`: A boolean that indicates whether the task is currently being triggered.
|
||||
- `error`: An error object that contains any errors that occurred while triggering the task.
|
||||
|
||||
The `submit` function triggers the task with the specified payload. You can additionally pass an optional [options](/triggering#options) argument to the `submit` function:
|
||||
|
||||
```tsx
|
||||
submit({ foo: "bar" }, { tags: ["tag1", "tag2"] });
|
||||
```
|
||||
|
||||
#### Using the handle object
|
||||
|
||||
You can use the `handle` object to initiate a subsequent [realtime hook](/frontend/react-hooks/realtime#userealtimerun) to subscribe to the run.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useTaskTrigger, useRealtimeRun } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
// 👆 This is the type of your task
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
// pass the type of your task here 👇
|
||||
const { submit, handle, error, isLoading } = useTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken, // 👈 this is the "trigger" token
|
||||
});
|
||||
|
||||
// use the handle object to preserve type-safety 👇
|
||||
const { run, error: realtimeError } = useRealtimeRun(handle, {
|
||||
accessToken: handle?.publicAccessToken,
|
||||
enabled: !!handle, // Only subscribe to the run if the handle is available
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (handle) {
|
||||
return <div>Run ID: {handle.id}</div>;
|
||||
}
|
||||
|
||||
if (realtimeError) {
|
||||
return <div>Error: {realtimeError.message}</div>;
|
||||
}
|
||||
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
We've also created some additional hooks that allow you to trigger tasks and subscribe to the run in one step:
|
||||
|
||||
### useRealtimeTaskTrigger
|
||||
|
||||
The `useRealtimeTaskTrigger` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime:
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, error, isLoading } = useRealtimeTaskTrigger<typeof myTask>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
// This is the realtime run object, which will automatically update when the run changes
|
||||
if (run) {
|
||||
return <div>Run ID: {run.id}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### useRealtimeTaskTriggerWithStreams
|
||||
|
||||
The `useRealtimeTaskTriggerWithStreams` hook allows you to trigger a task from your frontend application and then subscribe to the run in using Realtime, and also receive any streams that are emitted by the task.
|
||||
|
||||
```tsx
|
||||
"use client"; // This is needed for Next.js App Router or other RSC frameworks
|
||||
|
||||
import { useRealtimeTaskTriggerWithStreams } from "@trigger.dev/react-hooks";
|
||||
import type { myTask } from "@/trigger/myTask";
|
||||
|
||||
type STREAMS = {
|
||||
openai: string; // this is the type of each "part" of the stream
|
||||
};
|
||||
|
||||
export function MyComponent({ publicAccessToken }: { publicAccessToken: string }) {
|
||||
const { submit, run, streams, error, isLoading } = useRealtimeTaskTriggerWithStreams<
|
||||
typeof myTask,
|
||||
STREAMS
|
||||
>("my-task", {
|
||||
accessToken: publicAccessToken,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (streams && run) {
|
||||
const text = streams.openai?.map((part) => part).join("");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>Run ID: {run.id}</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => submit({ foo: "bar" })} disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : "Trigger Task"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
+12
-1
@@ -110,6 +110,10 @@
|
||||
{
|
||||
"source": "/runs-and-attempts",
|
||||
"destination": "/runs"
|
||||
},
|
||||
{
|
||||
"source": "/frontend/react-hooks",
|
||||
"destination": "/frontend/react-hooks/overview"
|
||||
}
|
||||
],
|
||||
"anchors": [
|
||||
@@ -207,7 +211,14 @@
|
||||
"group": "Frontend usage",
|
||||
"pages": [
|
||||
"frontend/overview",
|
||||
"frontend/react-hooks"
|
||||
{
|
||||
"group": "React hooks",
|
||||
"pages": [
|
||||
"frontend/react-hooks/overview",
|
||||
"frontend/react-hooks/realtime",
|
||||
"frontend/react-hooks/triggering"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -699,7 +699,7 @@ export const childTask2 = task({
|
||||
## Triggering from your frontend
|
||||
|
||||
If you want to trigger a task directly from a frontend application, you can use our [React
|
||||
hooks](/frontend/react-hooks#trigger-hooks).
|
||||
hooks](/frontend/react-hooks/triggering).
|
||||
|
||||
## Options
|
||||
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "RealtimeStreamChunk" (
|
||||
"id" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"sequence" INTEGER NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RealtimeStreamChunk_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Add index on (runID, createdAt) for efficient queries
|
||||
CREATE INDEX "RealtimeStreamChunk_runId" ON "RealtimeStreamChunk" ("runId");
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RealtimeStreamChunk_createdAt_idx" ON "RealtimeStreamChunk"("createdAt");
|
||||
|
||||
-- RenameIndex
|
||||
ALTER INDEX "RealtimeStreamChunk_runId" RENAME TO "RealtimeStreamChunk_runId_idx";
|
||||
@@ -2667,3 +2667,19 @@ enum BulkActionItemStatus {
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model RealtimeStreamChunk {
|
||||
id String @id @default(cuid())
|
||||
|
||||
key String
|
||||
value String
|
||||
|
||||
sequence Int
|
||||
|
||||
runId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([runId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export class EmailClient {
|
||||
|
||||
async #sendEmail({ to, subject, react }: { to: string; subject: string; react: ReactElement }) {
|
||||
if (this.#client) {
|
||||
await this.#client.emails.send({
|
||||
const result = await this.#client.emails.send({
|
||||
from: this.#from,
|
||||
to,
|
||||
reply_to: this.#replyTo,
|
||||
@@ -135,6 +135,13 @@ export class EmailClient {
|
||||
react,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(
|
||||
`Failed to send email to ${to}, ${subject}. Error ${result.error.name}: ${result.error.message}`
|
||||
);
|
||||
throw new EmailError(result.error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -147,3 +154,11 @@ ${render(react, {
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
//EmailError type where you can set the name and message
|
||||
export class EmailError extends Error {
|
||||
constructor({ name, message }: { name: string; message: string }) {
|
||||
super(message);
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { StartedPostgreSqlContainer } from "@testcontainers/postgresql";
|
||||
import { StartedRedisContainer } from "@testcontainers/redis";
|
||||
import { Redis } from "ioredis";
|
||||
import { test } from "vitest";
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { createPostgresContainer, createRedisContainer, createElectricContainer } from "./utils";
|
||||
import { Network, type StartedNetwork, type StartedTestContainer } from "testcontainers";
|
||||
import { Redis } from "ioredis";
|
||||
import { Network, type StartedNetwork } from "testcontainers";
|
||||
import { test } from "vitest";
|
||||
import { createElectricContainer, createPostgresContainer, createRedisContainer } from "./utils";
|
||||
|
||||
type NetworkContext = { network: StartedNetwork };
|
||||
|
||||
@@ -20,7 +20,8 @@ type ElectricContext = {
|
||||
};
|
||||
|
||||
type ContainerContext = NetworkContext & PostgresContext & RedisContext;
|
||||
type ContainerWithElectricContext = ContainerContext & ElectricContext;
|
||||
type ContainerWithElectricAndRedisContext = ContainerContext & ElectricContext;
|
||||
type ContainerWithElectricContext = NetworkContext & PostgresContext & ElectricContext;
|
||||
|
||||
type Use<T> = (value: T) => Promise<void>;
|
||||
|
||||
@@ -97,6 +98,13 @@ export const containerTest = test.extend<ContainerContext>({
|
||||
});
|
||||
|
||||
export const containerWithElectricTest = test.extend<ContainerWithElectricContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
electricOrigin,
|
||||
});
|
||||
|
||||
export const containerWithElectricAndRedisTest = test.extend<ContainerWithElectricAndRedisContext>({
|
||||
network,
|
||||
postgresContainer,
|
||||
prisma,
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function createElectricContainer(
|
||||
network.getName()
|
||||
)}:5432/${postgresContainer.getDatabase()}?sslmode=disable`;
|
||||
|
||||
const container = await new GenericContainer("electricsql/electric:0.8.1")
|
||||
const container = await new GenericContainer("electricsql/electric:0.9.4")
|
||||
.withExposedPorts(3000)
|
||||
.withNetwork(network)
|
||||
.withEnvironment({
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @trigger.dev/build
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/build",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "trigger.dev build extensions",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -65,7 +65,7 @@
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:3.3.3",
|
||||
"@trigger.dev/core": "workspace:3.3.6",
|
||||
"pkg-types": "^1.1.3",
|
||||
"tinyglobby": "^0.2.2",
|
||||
"tsconfck": "3.1.3"
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
# trigger.dev
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
- `@trigger.dev/build@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
- `@trigger.dev/build@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix SDK version in build manifest for out-of-sync detection ([#1530](https://github.com/triggerdotdev/trigger.dev/pull/1530))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/build@3.3.4`
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trigger.dev",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "A Command-Line Interface for Trigger.dev (v3) projects",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -87,8 +87,8 @@
|
||||
"@opentelemetry/sdk-trace-base": "1.25.1",
|
||||
"@opentelemetry/sdk-trace-node": "1.25.1",
|
||||
"@opentelemetry/semantic-conventions": "1.25.1",
|
||||
"@trigger.dev/build": "workspace:3.3.3",
|
||||
"@trigger.dev/core": "workspace:3.3.3",
|
||||
"@trigger.dev/build": "workspace:3.3.6",
|
||||
"@trigger.dev/core": "workspace:3.3.6",
|
||||
"c12": "^1.11.1",
|
||||
"chalk": "^5.2.0",
|
||||
"cli-table3": "^0.6.3",
|
||||
|
||||
@@ -27,6 +27,7 @@ import { writeJSONFile } from "../utilities/fileSystem.js";
|
||||
import { isWindows } from "std-env";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { SdkVersionExtractor } from "./plugins.js";
|
||||
|
||||
export type BuildWorkerEventListener = {
|
||||
onBundleStart?: () => void;
|
||||
@@ -61,6 +62,8 @@ export async function buildWorker(options: BuildWorkerOptions) {
|
||||
await notifyExtensionOnBuildStart(buildContext);
|
||||
const pluginsFromExtensions = resolvePluginsForContext(buildContext);
|
||||
|
||||
const sdkVersionExtractor = new SdkVersionExtractor();
|
||||
|
||||
options.listener?.onBundleStart?.();
|
||||
|
||||
const bundleResult = await bundleWorker({
|
||||
@@ -69,7 +72,7 @@ export async function buildWorker(options: BuildWorkerOptions) {
|
||||
destination: options.destination,
|
||||
watch: false,
|
||||
resolvedConfig,
|
||||
plugins: [...pluginsFromExtensions],
|
||||
plugins: [sdkVersionExtractor.plugin, ...pluginsFromExtensions],
|
||||
jsxFactory: resolvedConfig.build.jsx.factory,
|
||||
jsxFragment: resolvedConfig.build.jsx.fragment,
|
||||
jsxAutomatic: resolvedConfig.build.jsx.automatic,
|
||||
@@ -81,7 +84,7 @@ export async function buildWorker(options: BuildWorkerOptions) {
|
||||
contentHash: bundleResult.contentHash,
|
||||
runtime: resolvedConfig.runtime ?? DEFAULT_RUNTIME,
|
||||
environment: options.environment,
|
||||
packageVersion: CORE_VERSION,
|
||||
packageVersion: sdkVersionExtractor.sdkVersion ?? CORE_VERSION,
|
||||
cliPackageVersion: VERSION,
|
||||
target: "deploy",
|
||||
files: bundleResult.files,
|
||||
|
||||
@@ -4,6 +4,10 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import { configPlugin } from "../config.js";
|
||||
import { logger } from "../utilities/logger.js";
|
||||
import { bunPlugin } from "../runtimes/bun.js";
|
||||
import { resolvePathSync as esmResolveSync } from "mlly";
|
||||
import { readPackageJSON, resolvePackageJSON } from "pkg-types";
|
||||
import { dirname } from "node:path";
|
||||
import { readJSONFile } from "../utilities/fileSystem.js";
|
||||
|
||||
export async function buildPlugins(
|
||||
target: BuildTarget,
|
||||
@@ -87,3 +91,94 @@ export function polyshedPlugin(): esbuild.Plugin {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class SdkVersionExtractor {
|
||||
private _sdkVersion: string | undefined;
|
||||
private _ranOnce = false;
|
||||
|
||||
get sdkVersion() {
|
||||
return this._sdkVersion;
|
||||
}
|
||||
|
||||
get plugin(): esbuild.Plugin {
|
||||
return {
|
||||
name: "sdk-version",
|
||||
setup: (build) => {
|
||||
build.onResolve({ filter: /^@trigger\.dev\/sdk\// }, async (args) => {
|
||||
if (this._ranOnce) {
|
||||
return undefined;
|
||||
} else {
|
||||
this._ranOnce = true;
|
||||
}
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Extracting SDK version", { args });
|
||||
|
||||
try {
|
||||
const resolvedPath = esmResolveSync(args.path, {
|
||||
url: args.resolveDir,
|
||||
});
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Resolved SDK module path", { resolvedPath });
|
||||
|
||||
const packageJsonPath = await resolvePackageJSON(dirname(resolvedPath), {
|
||||
test: async (filePath) => {
|
||||
try {
|
||||
const candidate = await readJSONFile(filePath);
|
||||
|
||||
// Exclude esm type markers
|
||||
return Object.keys(candidate).length > 1 || !candidate.type;
|
||||
} catch (error) {
|
||||
logger.debug("[SdkVersionExtractor] Error during package.json test", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!packageJsonPath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Found package.json", { packageJsonPath });
|
||||
|
||||
const packageJson = await readPackageJSON(packageJsonPath);
|
||||
|
||||
if (!packageJson.name || packageJson.name !== "@trigger.dev/sdk") {
|
||||
logger.debug("[SdkVersionExtractor] No match for SDK package name", {
|
||||
packageJsonPath,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!packageJson.version) {
|
||||
logger.debug("[SdkVersionExtractor] No version found in package.json", {
|
||||
packageJsonPath,
|
||||
packageJson,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._sdkVersion = packageJson.version;
|
||||
|
||||
logger.debug("[SdkVersionExtractor] Found SDK version", {
|
||||
args,
|
||||
packageJsonPath,
|
||||
sdkVersion: this._sdkVersion,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.debug("[SdkVersionExtractor] Failed to extract SDK version", { error });
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,8 @@ 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"
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_REALTIME_STREAM_VERSION") ?? "v1") as "v1" | "v2"
|
||||
);
|
||||
runMetadata.setGlobalManager(runMetadataManager);
|
||||
const waitUntilManager = new StandardWaitUntilManager();
|
||||
|
||||
@@ -87,7 +87,8 @@ runtime.setGlobalRuntimeManager(devRuntimeManager);
|
||||
timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager));
|
||||
const runMetadataManager = new StandardMetadataManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev"
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_REALTIME_STREAM_VERSION") ?? "v1") as "v1" | "v2"
|
||||
);
|
||||
runMetadata.setGlobalManager(runMetadataManager);
|
||||
const waitUntilManager = new StandardWaitUntilManager();
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# internal-platform
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add option to trigger batched items sequentially, and default to parallel triggering which is faster ([#1536](https://github.com/triggerdotdev/trigger.dev/pull/1536))
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Fix an issue that caused errors when using realtime with a run that is cancelled ([#1533](https://github.com/triggerdotdev/trigger.dev/pull/1533))
|
||||
|
||||
## 3.3.4
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/core",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "Core code used across the Trigger.dev SDK and platform",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -182,7 +182,7 @@
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/client": "0.7.1",
|
||||
"@electric-sql/client": "0.9.0",
|
||||
"@google-cloud/precise-date": "^4.0.0",
|
||||
"@jsonhero/path": "^1.0.21",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
|
||||
@@ -74,6 +74,7 @@ export type ClientTriggerOptions = {
|
||||
export type ClientBatchTriggerOptions = ClientTriggerOptions & {
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyTTL?: string;
|
||||
processingStrategy?: "parallel" | "sequential";
|
||||
};
|
||||
|
||||
export type TriggerRequestOptions = ZodFetchOptions & {
|
||||
@@ -138,6 +139,10 @@ export class ApiClient {
|
||||
return fetchClient;
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return this.#getHeaders(false);
|
||||
}
|
||||
|
||||
async getRunResult(
|
||||
runId: string,
|
||||
requestOptions?: ZodFetchOptions
|
||||
@@ -239,6 +244,7 @@ export class ApiClient {
|
||||
headers: this.#getHeaders(clientOptions?.spanParentAsLink ?? false, {
|
||||
"idempotency-key": clientOptions?.idempotencyKey,
|
||||
"idempotency-key-ttl": clientOptions?.idempotencyKeyTTL,
|
||||
"batch-processing-strategy": clientOptions?.processingStrategy,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
import { DeserializedJson } from "../../schemas/json.js";
|
||||
import { RunStatus, SubscribeRunRawShape } from "../schemas/api.js";
|
||||
import { createJsonErrorObject } from "../errors.js";
|
||||
import {
|
||||
RunStatus,
|
||||
SubscribeRealtimeStreamChunkRawShape,
|
||||
SubscribeRunRawShape,
|
||||
} from "../schemas/api.js";
|
||||
import { SerializedError } from "../schemas/common.js";
|
||||
import { AnyRunTypes, AnyTask, InferRunTypes } from "../types/tasks.js";
|
||||
import { getEnvVar } from "../utils/getEnv.js";
|
||||
@@ -10,8 +16,7 @@ import {
|
||||
} from "../utils/ioSerialization.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { ApiClient } from "./index.js";
|
||||
import { AsyncIterableStream, createAsyncIterableStream, zodShapeStream } from "./stream.js";
|
||||
import { EventSourceParserStream } from "eventsource-parser/stream";
|
||||
import { AsyncIterableStream, createAsyncIterableReadable, zodShapeStream } from "./stream.js";
|
||||
|
||||
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
|
||||
? {
|
||||
@@ -77,19 +82,42 @@ export function runShapeStream<TRunTypes extends AnyRunTypes>(
|
||||
url: string,
|
||||
options?: RunShapeStreamOptions
|
||||
): RunSubscription<TRunTypes> {
|
||||
const $options: RunSubscriptionOptions = {
|
||||
provider: {
|
||||
async onShape(callback) {
|
||||
return zodShapeStream(SubscribeRunRawShape, url, callback, options);
|
||||
},
|
||||
},
|
||||
streamFactory: new SSEStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
signal: options?.signal,
|
||||
const abortController = new AbortController();
|
||||
|
||||
const version1 = new SSEStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
const version2 = new ElectricStreamSubscriptionFactory(
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
{
|
||||
headers: options?.headers,
|
||||
signal: abortController.signal,
|
||||
}
|
||||
);
|
||||
|
||||
// If the user supplied AbortSignal is aborted, we should abort the internal controller
|
||||
options?.signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
abortController.abort();
|
||||
}
|
||||
),
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
const $options: RunSubscriptionOptions = {
|
||||
runShapeStream: zodShapeStream(SubscribeRunRawShape, url, {
|
||||
...options,
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
streamFactory: new VersionedStreamSubscriptionFactory(version1, version2),
|
||||
abortController,
|
||||
...options,
|
||||
};
|
||||
|
||||
@@ -102,7 +130,12 @@ export interface StreamSubscription {
|
||||
}
|
||||
|
||||
export interface StreamSubscriptionFactory {
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription;
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription;
|
||||
}
|
||||
|
||||
// Real implementation for production
|
||||
@@ -153,7 +186,12 @@ export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
|
||||
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription {
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
@@ -163,17 +201,89 @@ export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
}
|
||||
}
|
||||
|
||||
// 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).pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue(safeParseJSON(chunk.value));
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectricStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private options: { headers?: Record<string, string>; signal?: AbortSignal }
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
return new ElectricStreamSubscription(
|
||||
`${baseUrl ?? this.baseUrl}/realtime/v2/streams/${runId}/${streamKey}`,
|
||||
this.options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionedStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
constructor(
|
||||
private version1: StreamSubscriptionFactory,
|
||||
private version2: StreamSubscriptionFactory
|
||||
) {}
|
||||
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string,
|
||||
baseUrl?: string
|
||||
): StreamSubscription {
|
||||
if (!runId || !streamKey) {
|
||||
throw new Error("runId and streamKey are required");
|
||||
}
|
||||
|
||||
const version =
|
||||
typeof metadata.$$streamsVersion === "string" ? metadata.$$streamsVersion : "v1";
|
||||
|
||||
if (version === "v1") {
|
||||
return this.version1.createSubscription(metadata, runId, streamKey, baseUrl);
|
||||
}
|
||||
|
||||
if (version === "v2") {
|
||||
return this.version2.createSubscription(metadata, runId, streamKey, baseUrl);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown stream version: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunShapeProvider {
|
||||
onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
|
||||
export type RunSubscriptionOptions = RunShapeStreamOptions & {
|
||||
provider: RunShapeProvider;
|
||||
runShapeStream: ReadableStream<SubscribeRunRawShape>;
|
||||
streamFactory: StreamSubscriptionFactory;
|
||||
abortController: AbortController;
|
||||
};
|
||||
|
||||
export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
private abortController: AbortController;
|
||||
private unsubscribeShape?: () => void;
|
||||
private stream: AsyncIterableStream<RunShape<TRunTypes>>;
|
||||
private packetCache = new Map<string, any>();
|
||||
@@ -181,44 +291,37 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
private _isRunComplete = false;
|
||||
|
||||
constructor(private options: RunSubscriptionOptions) {
|
||||
this.abortController = new AbortController();
|
||||
this._closeOnComplete =
|
||||
typeof options.closeOnComplete === "undefined" ? true : options.closeOnComplete;
|
||||
|
||||
const source = new ReadableStream<SubscribeRunRawShape>({
|
||||
start: async (controller) => {
|
||||
this.unsubscribeShape = await this.options.provider.onShape(async (shape) => {
|
||||
controller.enqueue(shape);
|
||||
this.stream = createAsyncIterableReadable(
|
||||
this.options.runShapeStream,
|
||||
{
|
||||
transform: async (chunk, controller) => {
|
||||
const run = await this.transformRunShape(chunk);
|
||||
|
||||
this._isRunComplete = !!shape.completedAt;
|
||||
controller.enqueue(run);
|
||||
|
||||
this._isRunComplete = !!run.finishedAt;
|
||||
|
||||
if (
|
||||
this._closeOnComplete &&
|
||||
this._isRunComplete &&
|
||||
!this.abortController.signal.aborted
|
||||
!this.options.abortController.signal.aborted
|
||||
) {
|
||||
controller.close();
|
||||
this.abortController.abort();
|
||||
console.log("Closing stream because run is complete");
|
||||
|
||||
this.options.abortController.abort();
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
cancel: () => {
|
||||
this.unsubscribe();
|
||||
},
|
||||
});
|
||||
|
||||
this.stream = createAsyncIterableStream(source, {
|
||||
transform: async (chunk, controller) => {
|
||||
const run = await this.transformRunShape(chunk);
|
||||
|
||||
controller.enqueue(run);
|
||||
},
|
||||
});
|
||||
this.options.abortController.signal
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribe(): void {
|
||||
if (!this.abortController.signal.aborted) {
|
||||
this.abortController.abort();
|
||||
if (!this.options.abortController.signal.aborted) {
|
||||
this.options.abortController.abort();
|
||||
}
|
||||
this.unsubscribeShape?.();
|
||||
}
|
||||
@@ -237,59 +340,68 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
// Keep track of which streams we've already subscribed to
|
||||
const activeStreams = new Set<string>();
|
||||
|
||||
return createAsyncIterableStream(this.stream, {
|
||||
transform: async (run, controller) => {
|
||||
controller.enqueue({
|
||||
type: "run",
|
||||
run,
|
||||
});
|
||||
return createAsyncIterableReadable(
|
||||
this.stream,
|
||||
{
|
||||
transform: async (run, controller) => {
|
||||
controller.enqueue({
|
||||
type: "run",
|
||||
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 (typeof streamKey !== "string") {
|
||||
continue;
|
||||
}
|
||||
// Check for stream metadata
|
||||
if (
|
||||
run.metadata &&
|
||||
"$$streams" in run.metadata &&
|
||||
Array.isArray(run.metadata.$$streams)
|
||||
) {
|
||||
for (const streamKey of run.metadata.$$streams) {
|
||||
if (typeof streamKey !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!activeStreams.has(streamKey)) {
|
||||
activeStreams.add(streamKey);
|
||||
if (!activeStreams.has(streamKey)) {
|
||||
activeStreams.add(streamKey);
|
||||
|
||||
const subscription = this.options.streamFactory.createSubscription(
|
||||
run.id,
|
||||
streamKey,
|
||||
this.options.client?.baseUrl
|
||||
);
|
||||
const subscription = this.options.streamFactory.createSubscription(
|
||||
run.metadata,
|
||||
run.id,
|
||||
streamKey,
|
||||
this.options.client?.baseUrl
|
||||
);
|
||||
|
||||
const stream = await subscription.subscribe();
|
||||
const stream = await subscription.subscribe();
|
||||
|
||||
// Create the pipeline and start it
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
} as StreamPartResult<RunShape<TRunTypes>, TStreams>);
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(`Error in stream ${streamKey}:`, error);
|
||||
});
|
||||
// Create the pipeline and start it
|
||||
stream
|
||||
.pipeThrough(
|
||||
new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
controller.enqueue({
|
||||
type: streamKey,
|
||||
chunk: chunk as TStreams[typeof streamKey],
|
||||
run,
|
||||
} as StreamPartResult<RunShape<TRunTypes>, TStreams>);
|
||||
},
|
||||
})
|
||||
)
|
||||
.pipeTo(
|
||||
new WritableStream({
|
||||
write(chunk) {
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
console.error(`Error in stream ${streamKey}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
this.options.abortController.signal
|
||||
);
|
||||
}
|
||||
|
||||
private async transformRunShape(row: SubscribeRunRawShape): Promise<RunShape<TRunTypes>> {
|
||||
@@ -347,7 +459,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
|
||||
startedAt: row.startedAt ?? undefined,
|
||||
delayedUntil: row.delayUntil ?? undefined,
|
||||
queuedAt: row.queuedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
error: row.error ? createJsonErrorObject(row.error) : undefined,
|
||||
isTest: row.isTest,
|
||||
metadata,
|
||||
} as RunShape<TRunTypes>;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { ApiError } from "./errors.js";
|
||||
import {
|
||||
FetchError,
|
||||
isChangeMessage,
|
||||
isControlMessage,
|
||||
Offset,
|
||||
ShapeStream,
|
||||
type Message,
|
||||
type Row,
|
||||
type ShapeStreamInterface,
|
||||
// @ts-ignore it's safe to import types from the client
|
||||
} from "@electric-sql/client";
|
||||
|
||||
export type ZodShapeStreamOptions = {
|
||||
headers?: Record<string, string>;
|
||||
@@ -7,14 +17,11 @@ export type ZodShapeStreamOptions = {
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
export function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
schema: TShapeSchema,
|
||||
url: string,
|
||||
callback: (shape: z.output<TShapeSchema>) => void | Promise<void>,
|
||||
options?: ZodShapeStreamOptions
|
||||
) {
|
||||
const { ShapeStream, Shape, FetchError } = await import("@electric-sql/client");
|
||||
|
||||
const stream = new ShapeStream<z.input<TShapeSchema>>({
|
||||
url,
|
||||
headers: {
|
||||
@@ -25,27 +32,21 @@ export async function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
|
||||
signal: options?.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
const shape = new Shape(stream);
|
||||
const readableShape = new ReadableShapeStream(stream);
|
||||
|
||||
const initialRows = await shape.rows;
|
||||
return readableShape.stream.pipeThrough(
|
||||
new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
const result = schema.safeParse(chunk);
|
||||
|
||||
for (const shapeRow of initialRows) {
|
||||
await callback(schema.parse(shapeRow));
|
||||
}
|
||||
|
||||
return shape.subscribe(async (newShape) => {
|
||||
for (const shapeRow of newShape.rows) {
|
||||
await callback(schema.parse(shapeRow));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof FetchError) {
|
||||
throw ApiError.generate(error.status, error.json, error.message, error.headers);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (result.success) {
|
||||
controller.enqueue(result.data);
|
||||
} else {
|
||||
controller.error(new Error(`Unable to parse shape: ${result.error.message}`));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
|
||||
@@ -68,3 +69,137 @@ export function createAsyncIterableStream<S, T>(
|
||||
|
||||
return transformedStream;
|
||||
}
|
||||
|
||||
export function createAsyncIterableReadable<S, T>(
|
||||
source: ReadableStream<S>,
|
||||
transformer: Transformer<S, T>,
|
||||
signal: AbortSignal
|
||||
): AsyncIterableStream<T> {
|
||||
return new ReadableStream<T>({
|
||||
async start(controller) {
|
||||
const transformedStream = source.pipeThrough(new TransformStream(transformer));
|
||||
const reader = transformedStream.getReader();
|
||||
|
||||
signal.addEventListener("abort", () => {
|
||||
queueMicrotask(() => {
|
||||
reader.cancel();
|
||||
controller.close();
|
||||
});
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
}) as AsyncIterableStream<T>;
|
||||
}
|
||||
|
||||
class ReadableShapeStream<T extends Row<unknown> = Row> {
|
||||
readonly #stream: ShapeStreamInterface<T>;
|
||||
readonly #currentState: Map<string, T> = new Map();
|
||||
readonly #changeStream: AsyncIterableStream<T>;
|
||||
#error: FetchError | false = false;
|
||||
|
||||
constructor(stream: ShapeStreamInterface<T>) {
|
||||
this.#stream = stream;
|
||||
|
||||
// Create the source stream that will receive messages
|
||||
const source = new ReadableStream<Message<T>[]>({
|
||||
start: (controller) => {
|
||||
this.#stream.subscribe(
|
||||
(messages) => controller.enqueue(messages),
|
||||
this.#handleError.bind(this)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
// Create the transformed stream that processes messages and emits complete rows
|
||||
this.#changeStream = createAsyncIterableStream(source, {
|
||||
transform: (messages, controller) => {
|
||||
messages.forEach((message) => {
|
||||
if (isChangeMessage(message)) {
|
||||
switch (message.headers.operation) {
|
||||
case "insert": {
|
||||
this.#currentState.set(message.key, message.value);
|
||||
controller.enqueue(message.value);
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
const existingRow = this.#currentState.get(message.key);
|
||||
if (existingRow) {
|
||||
const updatedRow = {
|
||||
...existingRow,
|
||||
...message.value,
|
||||
};
|
||||
this.#currentState.set(message.key, updatedRow);
|
||||
controller.enqueue(updatedRow);
|
||||
} else {
|
||||
this.#currentState.set(message.key, message.value);
|
||||
controller.enqueue(message.value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isControlMessage(message)) {
|
||||
switch (message.headers.control) {
|
||||
case "must-refetch":
|
||||
this.#currentState.clear();
|
||||
this.#error = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get stream(): AsyncIterableStream<T> {
|
||||
return this.#changeStream;
|
||||
}
|
||||
|
||||
get isUpToDate(): boolean {
|
||||
return this.#stream.isUpToDate;
|
||||
}
|
||||
|
||||
get lastOffset(): Offset {
|
||||
return this.#stream.lastOffset;
|
||||
}
|
||||
|
||||
get handle(): string | undefined {
|
||||
return this.#stream.shapeHandle;
|
||||
}
|
||||
|
||||
get error() {
|
||||
return this.#error;
|
||||
}
|
||||
|
||||
lastSyncedAt(): number | undefined {
|
||||
return this.#stream.lastSyncedAt();
|
||||
}
|
||||
|
||||
lastSynced() {
|
||||
return this.#stream.lastSynced();
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.#stream.isLoading();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.#stream.isConnected();
|
||||
}
|
||||
|
||||
#handleError(e: Error): void {
|
||||
if (e instanceof FetchError) {
|
||||
this.#error = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
|
||||
constructor(
|
||||
private apiClient: ApiClient,
|
||||
private streamsBaseUrl: string
|
||||
private streamsBaseUrl: string,
|
||||
private streamsVersion: "v1" | "v2" = "v1"
|
||||
) {}
|
||||
|
||||
public enterWithMetadata(metadata: Record<string, DeserializedJson>): void {
|
||||
@@ -231,6 +232,7 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
try {
|
||||
// Add the key to the special stream metadata object
|
||||
this.appendKey(`$$streams`, key);
|
||||
this.setKey("$$streamsVersion", this.streamsVersion);
|
||||
|
||||
await this.flush();
|
||||
|
||||
@@ -239,7 +241,9 @@ export class StandardMetadataManager implements RunMetadataManager {
|
||||
runId: this.runId,
|
||||
iterator: $value[Symbol.asyncIterator](),
|
||||
baseUrl: this.streamsBaseUrl,
|
||||
headers: this.apiClient.getHeaders(),
|
||||
signal,
|
||||
version: this.streamsVersion,
|
||||
});
|
||||
|
||||
this.activeStreams.set(key, streamInstance);
|
||||
|
||||
@@ -3,7 +3,9 @@ export type MetadataOptions<T> = {
|
||||
runId: string;
|
||||
key: string;
|
||||
iterator: AsyncIterator<T>;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
version?: "v1" | "v2";
|
||||
};
|
||||
|
||||
export class MetadataStream<T> {
|
||||
@@ -43,7 +45,6 @@ export class MetadataStream<T> {
|
||||
private initializeServerStream(): Promise<void | Response> {
|
||||
const serverIterator = this.serverIterator;
|
||||
|
||||
// TODO: Why is this only sending stuff to the server at the end of the run?
|
||||
const serverStream = new ReadableStream({
|
||||
async pull(controller) {
|
||||
try {
|
||||
@@ -62,10 +63,12 @@ export class MetadataStream<T> {
|
||||
});
|
||||
|
||||
return fetch(
|
||||
`${this.options.baseUrl}/realtime/v1/streams/${this.options.runId}/${this.options.key}`,
|
||||
`${this.options.baseUrl}/realtime/${this.options.version ?? "v1"}/streams/${
|
||||
this.options.runId
|
||||
}/${this.options.key}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {},
|
||||
headers: this.options.headers ?? {},
|
||||
body: serverStream,
|
||||
// @ts-expect-error
|
||||
duplex: "half",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { DeserializedJsonSchema } from "../../schemas/json.js";
|
||||
import { SerializedError } from "./common.js";
|
||||
import { SerializedError, TaskRunError } from "./common.js";
|
||||
import { BackgroundWorkerMetadata } from "./resources.js";
|
||||
import { QueueOptions } from "./schemas.js";
|
||||
|
||||
@@ -708,7 +708,7 @@ export const SubscribeRunRawShape = z.object({
|
||||
output: z.string().nullish(),
|
||||
outputType: z.string().nullish(),
|
||||
runTags: z.array(z.string()).nullish().default([]),
|
||||
error: SerializedError.nullish(),
|
||||
error: TaskRunError.nullish(),
|
||||
});
|
||||
|
||||
export type SubscribeRunRawShape = z.infer<typeof SubscribeRunRawShape>;
|
||||
@@ -727,3 +727,16 @@ export const RetrieveBatchResponse = z.object({
|
||||
});
|
||||
|
||||
export type RetrieveBatchResponse = z.infer<typeof RetrieveBatchResponse>;
|
||||
|
||||
export const SubscribeRealtimeStreamChunkRawShape = z.object({
|
||||
id: z.string(),
|
||||
runId: z.string(),
|
||||
sequence: z.number(),
|
||||
key: z.string(),
|
||||
value: z.string(),
|
||||
createdAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export type SubscribeRealtimeStreamChunkRawShape = z.infer<
|
||||
typeof SubscribeRealtimeStreamChunkRawShape
|
||||
>;
|
||||
|
||||
@@ -592,7 +592,8 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
|
||||
* ```
|
||||
*/
|
||||
batchTriggerAndWait: (
|
||||
items: Array<BatchTriggerAndWaitItem<TInput>>
|
||||
items: Array<BatchTriggerAndWaitItem<TInput>>,
|
||||
options?: BatchTriggerAndWaitOptions
|
||||
) => Promise<BatchResult<TIdentifier, TOutput>>;
|
||||
}
|
||||
|
||||
@@ -781,6 +782,32 @@ export type TriggerAndWaitOptions = Omit<TriggerOptions, "idempotencyKey" | "ide
|
||||
export type BatchTriggerOptions = {
|
||||
idempotencyKey?: IdempotencyKey | string | string[];
|
||||
idempotencyKeyTTL?: string;
|
||||
|
||||
/**
|
||||
* When true, triggers tasks sequentially in batch order. This ensures ordering but may be slower,
|
||||
* especially for large batches.
|
||||
*
|
||||
* When false (default), triggers tasks in parallel for better performance, but order is not guaranteed.
|
||||
*
|
||||
* Note: This only affects the order of run creation, not the actual task execution.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
triggerSequentially?: boolean;
|
||||
};
|
||||
|
||||
export type BatchTriggerAndWaitOptions = {
|
||||
/**
|
||||
* When true, triggers tasks sequentially in batch order. This ensures ordering but may be slower,
|
||||
* especially for large batches.
|
||||
*
|
||||
* When false (default), triggers tasks in parallel for better performance, but order is not guaranteed.
|
||||
*
|
||||
* Note: This only affects the order of run creation, not the actual task execution.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
triggerSequentially?: boolean;
|
||||
};
|
||||
|
||||
export type TaskMetadataWithFunctions = TaskMetadata & {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AnyRunShape,
|
||||
RunSubscription,
|
||||
StreamSubscription,
|
||||
StreamSubscriptionFactory,
|
||||
type RunShapeProvider,
|
||||
} from "../src/v3/apiClient/runStream.js";
|
||||
import type { SubscribeRunRawShape } from "../src/v3/schemas/api.js";
|
||||
|
||||
@@ -33,64 +31,54 @@ class TestStreamSubscriptionFactory implements StreamSubscriptionFactory {
|
||||
this.streams.set(`${runId}:${streamKey}`, chunks);
|
||||
}
|
||||
|
||||
createSubscription(runId: string, streamKey: string): StreamSubscription {
|
||||
createSubscription(
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string
|
||||
): StreamSubscription {
|
||||
const chunks = this.streams.get(`${runId}:${streamKey}`) ?? [];
|
||||
return new TestStreamSubscription(chunks);
|
||||
}
|
||||
}
|
||||
|
||||
// Create a real test provider that uses an array of shapes
|
||||
class TestShapeProvider implements RunShapeProvider {
|
||||
private shapes: SubscribeRunRawShape[];
|
||||
private unsubscribed = false;
|
||||
|
||||
constructor(shapes: SubscribeRunRawShape[]) {
|
||||
this.shapes = shapes;
|
||||
}
|
||||
|
||||
async onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void> {
|
||||
// Process all shapes immediately
|
||||
for (const shape of this.shapes) {
|
||||
if (this.unsubscribed) break;
|
||||
await callback(shape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.unsubscribed = true;
|
||||
};
|
||||
}
|
||||
// Remove the RunShapeProvider implementations and replace with stream creators
|
||||
function createTestShapeStream(
|
||||
shapes: SubscribeRunRawShape[]
|
||||
): ReadableStream<SubscribeRunRawShape> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
// Emit all shapes immediately
|
||||
for (const shape of shapes) {
|
||||
controller.enqueue(shape);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add this new provider that can emit shapes over time
|
||||
class DelayedTestShapeProvider implements RunShapeProvider {
|
||||
private shapes: SubscribeRunRawShape[];
|
||||
private unsubscribed = false;
|
||||
private currentShapeIndex = 0;
|
||||
|
||||
constructor(shapes: SubscribeRunRawShape[]) {
|
||||
this.shapes = shapes;
|
||||
}
|
||||
|
||||
async onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void> {
|
||||
// Only emit the first shape immediately
|
||||
if (this.shapes.length > 0) {
|
||||
await callback(this.shapes[this.currentShapeIndex++]!);
|
||||
}
|
||||
|
||||
// Set up an interval to emit remaining shapes
|
||||
const interval = setInterval(async () => {
|
||||
if (this.unsubscribed || this.currentShapeIndex >= this.shapes.length) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
function createDelayedTestShapeStream(
|
||||
shapes: SubscribeRunRawShape[]
|
||||
): ReadableStream<SubscribeRunRawShape> {
|
||||
return new ReadableStream({
|
||||
start: async (controller) => {
|
||||
// Emit first shape immediately
|
||||
if (shapes.length > 0) {
|
||||
controller.enqueue(shapes[0]);
|
||||
}
|
||||
await callback(this.shapes[this.currentShapeIndex++]!);
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
this.unsubscribed = true;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}
|
||||
let currentShapeIndex = 1;
|
||||
|
||||
// Emit remaining shapes with delay
|
||||
const interval = setInterval(() => {
|
||||
if (currentShapeIndex >= shapes.length) {
|
||||
clearInterval(interval);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(shapes[currentShapeIndex++]!);
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunSubscription", () => {
|
||||
@@ -114,9 +102,10 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: true,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await convertAsyncIterableToArray(subscription);
|
||||
@@ -153,9 +142,10 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: true,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await convertAsyncIterableToArray(subscription);
|
||||
@@ -205,9 +195,10 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new DelayedTestShapeProvider(shapes),
|
||||
runShapeStream: createDelayedTestShapeStream(shapes),
|
||||
streamFactory: new TestStreamSubscriptionFactory(),
|
||||
closeOnComplete: false,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
// Collect 2 results
|
||||
@@ -257,8 +248,9 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
@@ -289,9 +281,13 @@ describe("RunSubscription", () => {
|
||||
|
||||
// Override createSubscription to count calls
|
||||
const originalCreate = streamFactory.createSubscription.bind(streamFactory);
|
||||
streamFactory.createSubscription = (runId: string, streamKey: string) => {
|
||||
streamFactory.createSubscription = (
|
||||
metadata: Record<string, unknown>,
|
||||
runId: string,
|
||||
streamKey: string
|
||||
) => {
|
||||
streamCreationCount++;
|
||||
return originalCreate(runId, streamKey);
|
||||
return originalCreate(metadata, runId, streamKey);
|
||||
};
|
||||
|
||||
// Set up test chunks
|
||||
@@ -342,8 +338,9 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
@@ -421,8 +418,9 @@ describe("RunSubscription", () => {
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
runShapeStream: createTestShapeStream(shapes),
|
||||
streamFactory,
|
||||
abortController: new AbortController(),
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
@@ -467,110 +465,6 @@ describe("RunSubscription", () => {
|
||||
run: { id: "run_123" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle streams that appear in different run updates", async () => {
|
||||
const streamFactory = new TestStreamSubscriptionFactory();
|
||||
|
||||
// Set up test chunks for two different streams
|
||||
streamFactory.setStreamChunks("run_123", "openai", [
|
||||
{ id: "openai1", content: "Hello" },
|
||||
{ id: "openai2", content: "World" },
|
||||
]);
|
||||
streamFactory.setStreamChunks("run_123", "anthropic", [
|
||||
{ id: "claude1", message: "Hi" },
|
||||
{ id: "claude2", message: "There" },
|
||||
]);
|
||||
|
||||
const shapes = [
|
||||
// First run update - only has openai stream
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "multi-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 100,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai"],
|
||||
}),
|
||||
metadataType: "application/json",
|
||||
},
|
||||
// Second run update - adds anthropic stream
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "multi-streaming",
|
||||
status: "EXECUTING",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 200,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai", "anthropic"],
|
||||
}),
|
||||
metadataType: "application/json",
|
||||
},
|
||||
// Final run update - marks as complete
|
||||
{
|
||||
id: "123",
|
||||
friendlyId: "run_123",
|
||||
taskIdentifier: "multi-streaming",
|
||||
status: "COMPLETED_SUCCESSFULLY",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
number: 1,
|
||||
usageDurationMs: 300,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
isTest: false,
|
||||
runTags: [],
|
||||
metadata: JSON.stringify({
|
||||
$$streams: ["openai", "anthropic"],
|
||||
}),
|
||||
metadataType: "application/json",
|
||||
},
|
||||
];
|
||||
|
||||
const subscription = new RunSubscription({
|
||||
provider: new TestShapeProvider(shapes),
|
||||
streamFactory,
|
||||
closeOnComplete: true,
|
||||
});
|
||||
|
||||
const results = await collectNResults(
|
||||
subscription.withStreams<{
|
||||
openai: { id: string; content: string };
|
||||
anthropic: { id: string; message: string };
|
||||
}>(),
|
||||
7 // 3 runs + 2 openai chunks + 2 anthropic chunks
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(7);
|
||||
|
||||
// Verify run updates
|
||||
const runUpdates = results.filter((r) => r.type === "run");
|
||||
expect(runUpdates).toHaveLength(3);
|
||||
expect(runUpdates[2]!.run.status).toBe("COMPLETED");
|
||||
|
||||
// Verify openai chunks
|
||||
const openaiChunks = results.filter((r) => r.type === "openai");
|
||||
expect(openaiChunks).toHaveLength(2);
|
||||
|
||||
// Verify anthropic chunks
|
||||
const anthropicChunks = results.filter((r) => r.type === "anthropic");
|
||||
expect(anthropicChunks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
export async function convertAsyncIterableToArray<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
@@ -603,7 +497,12 @@ async function collectNResults<T>(
|
||||
promise,
|
||||
new Promise<T[]>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(`Timeout waiting for ${count} results after ${timeoutMs}ms`)),
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for ${count} results after ${timeoutMs}ms, but only had ${results.length}`
|
||||
)
|
||||
),
|
||||
timeoutMs
|
||||
)
|
||||
),
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# @trigger.dev/react-hooks
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Realtime streams now powered by electric. Also, this change fixes a realtime bug that was causing too many re-renders, even on records that didn't change ([#1541](https://github.com/triggerdotdev/trigger.dev/pull/1541))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add trigger options to all trigger hooks ([#1528](https://github.com/triggerdotdev/trigger.dev/pull/1528))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/react-hooks",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "trigger.dev react hooks",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -37,7 +37,7 @@
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:^3.3.3",
|
||||
"@trigger.dev/core": "workspace:^3.3.6",
|
||||
"swr": "^2.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -12,6 +12,16 @@ export type UseRealtimeRunOptions = UseApiClientOptions & {
|
||||
experimental_throttleInMs?: number;
|
||||
};
|
||||
|
||||
export type UseRealtimeSingleRunOptions<TTask extends AnyTask = AnyTask> = UseRealtimeRunOptions & {
|
||||
/**
|
||||
* Callback this is called when the run completes, an error occurs, or the subscription is stopped.
|
||||
*
|
||||
* @param {RealtimeRun<TTask>} run - The run object
|
||||
* @param {Error} [err] - The error that occurred
|
||||
*/
|
||||
onComplete?: (run: RealtimeRun<TTask>, err?: Error) => void;
|
||||
};
|
||||
|
||||
export type UseRealtimeRunInstance<TTask extends AnyTask = AnyTask> = {
|
||||
run: RealtimeRun<TTask> | undefined;
|
||||
|
||||
@@ -28,7 +38,7 @@ export type UseRealtimeRunInstance<TTask extends AnyTask = AnyTask> = {
|
||||
*
|
||||
* @template TTask - The type of the task
|
||||
* @param {string} [runId] - The unique identifier of the run to subscribe to
|
||||
* @param {UseRealtimeRunOptions} [options] - Configuration options for the subscription
|
||||
* @param {UseRealtimeSingleRunOptions} [options] - Configuration options for the subscription
|
||||
* @returns {UseRealtimeRunInstance<TTask>} An object containing the current state of the run, error handling, and control methods
|
||||
*
|
||||
* @example
|
||||
@@ -40,7 +50,7 @@ export type UseRealtimeRunInstance<TTask extends AnyTask = AnyTask> = {
|
||||
|
||||
export function useRealtimeRun<TTask extends AnyTask>(
|
||||
runId?: string,
|
||||
options?: UseRealtimeRunOptions
|
||||
options?: UseRealtimeSingleRunOptions<TTask>
|
||||
): UseRealtimeRunInstance<TTask> {
|
||||
const hookId = useId();
|
||||
const idKey = options?.id ?? hookId;
|
||||
@@ -48,17 +58,17 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
// Store the streams state in SWR, using the idKey as the key to share states.
|
||||
const { data: run, mutate: mutateRun } = useSWR<RealtimeRun<TTask>>([idKey, "run"], null);
|
||||
|
||||
// Keep the latest streams in a ref.
|
||||
const runRef = useRef<RealtimeRun<TTask> | undefined>();
|
||||
useEffect(() => {
|
||||
runRef.current = run;
|
||||
}, [run]);
|
||||
|
||||
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
|
||||
[idKey, "error"],
|
||||
null
|
||||
);
|
||||
|
||||
// Add state to track when the subscription is complete
|
||||
const { data: isComplete = false, mutate: setIsComplete } = useSWR<boolean>(
|
||||
[idKey, "complete"],
|
||||
null
|
||||
);
|
||||
|
||||
// Abort controller to cancel the current API call.
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -93,9 +103,19 @@ export function useRealtimeRun<TTask extends AnyTask>(
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, mutateRun, abortControllerRef, apiClient, setError]);
|
||||
|
||||
// Effect to handle onComplete callback
|
||||
useEffect(() => {
|
||||
if (isComplete && options?.onComplete && run) {
|
||||
options.onComplete(run, error);
|
||||
}
|
||||
}, [isComplete, run, error, options?.onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
return;
|
||||
@@ -157,7 +177,7 @@ export function useRealtimeRunWithStreams<
|
||||
TStreams extends Record<string, any> = Record<string, any>,
|
||||
>(
|
||||
runId?: string,
|
||||
options?: UseRealtimeRunOptions
|
||||
options?: UseRealtimeSingleRunOptions<TTask>
|
||||
): UseRealtimeRunWithStreamsInstance<TTask, TStreams> {
|
||||
const hookId = useId();
|
||||
const idKey = options?.id ?? hookId;
|
||||
@@ -182,11 +202,11 @@ export function useRealtimeRunWithStreams<
|
||||
// Store the streams state in SWR, using the idKey as the key to share states.
|
||||
const { data: run, mutate: mutateRun } = useSWR<RealtimeRun<TTask>>([idKey, "run"], null);
|
||||
|
||||
// Keep the latest streams in a ref.
|
||||
const runRef = useRef<RealtimeRun<TTask> | undefined>();
|
||||
useEffect(() => {
|
||||
runRef.current = run;
|
||||
}, [run]);
|
||||
// Add state to track when the subscription is complete
|
||||
const { data: isComplete = false, mutate: setIsComplete } = useSWR<boolean>(
|
||||
[idKey, "complete"],
|
||||
null
|
||||
);
|
||||
|
||||
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
|
||||
[idKey, "error"],
|
||||
@@ -235,9 +255,19 @@ export function useRealtimeRunWithStreams<
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
// Mark the subscription as complete
|
||||
setIsComplete(true);
|
||||
}
|
||||
}, [runId, mutateRun, mutateStreams, streamsRef, abortControllerRef, apiClient, setError]);
|
||||
|
||||
// Effect to handle onComplete callback
|
||||
useEffect(() => {
|
||||
if (isComplete && options?.onComplete && run) {
|
||||
options.onComplete(run, error);
|
||||
}
|
||||
}, [isComplete, run, error, options?.onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof options?.enabled === "boolean" && !options.enabled) {
|
||||
return;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
makeIdempotencyKey,
|
||||
RunHandleFromTypes,
|
||||
stringifyIO,
|
||||
TriggerOptions,
|
||||
type TriggerOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { useApiClient, UseApiClientOptions } from "./useApiClient.js";
|
||||
@@ -118,7 +118,7 @@ export type RealtimeTriggerInstanceWithStreams<
|
||||
TTask extends AnyTask,
|
||||
TStreams extends Record<string, any> = Record<string, any>,
|
||||
> = UseRealtimeRunWithStreamsInstance<TTask, TStreams> & {
|
||||
submit: (payload: TaskPayload<TTask>) => void;
|
||||
submit: (payload: TaskPayload<TTask>, options?: TriggerOptions) => void;
|
||||
isLoading: boolean;
|
||||
handle?: RunHandleFromTypes<InferRunTypes<TTask>>;
|
||||
};
|
||||
@@ -166,7 +166,7 @@ export function useRealtimeTaskTriggerWithStreams<
|
||||
}
|
||||
|
||||
export type RealtimeTriggerInstance<TTask extends AnyTask> = UseRealtimeRunInstance<TTask> & {
|
||||
submit: (payload: TaskPayload<TTask>) => void;
|
||||
submit: (payload: TaskPayload<TTask>, options?: TriggerOptions) => void;
|
||||
isLoading: boolean;
|
||||
handle?: RunHandleFromTypes<InferRunTypes<TTask>>;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# @trigger.dev/rsc
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/rsc",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "trigger.dev rsc",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -37,14 +37,14 @@
|
||||
"check-exports": "attw --pack ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@trigger.dev/core": "workspace:^3.3.3",
|
||||
"@trigger.dev/core": "workspace:^3.3.6",
|
||||
"mlly": "^1.7.1",
|
||||
"react": "19.0.0-rc.1",
|
||||
"react-dom": "19.0.0-rc.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "^0.15.4",
|
||||
"@trigger.dev/build": "workspace:^3.3.3",
|
||||
"@trigger.dev/build": "workspace:^3.3.6",
|
||||
"@types/node": "^20.14.14",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# @trigger.dev/sdk
|
||||
|
||||
## 3.3.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Realtime streams now powered by electric. Also, this change fixes a realtime bug that was causing too many re-renders, even on records that didn't change ([#1541](https://github.com/triggerdotdev/trigger.dev/pull/1541))
|
||||
- Add option to trigger batched items sequentially, and default to parallel triggering which is faster ([#1536](https://github.com/triggerdotdev/trigger.dev/pull/1536))
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.6`
|
||||
|
||||
## 3.3.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.5`
|
||||
|
||||
## 3.3.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies:
|
||||
- `@trigger.dev/core@3.3.4`
|
||||
|
||||
## 3.3.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trigger.dev/sdk",
|
||||
"version": "3.3.3",
|
||||
"version": "3.3.6",
|
||||
"description": "trigger.dev Node.JS SDK",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
@@ -48,7 +48,7 @@
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/api-logs": "0.52.1",
|
||||
"@opentelemetry/semantic-conventions": "1.25.1",
|
||||
"@trigger.dev/core": "workspace:3.3.3",
|
||||
"@trigger.dev/core": "workspace:3.3.6",
|
||||
"chalk": "^5.2.0",
|
||||
"cronstrue": "^2.21.0",
|
||||
"debug": "^4.3.4",
|
||||
|
||||
@@ -74,6 +74,7 @@ import type {
|
||||
TriggerApiRequestOptions,
|
||||
TriggerOptions,
|
||||
AnyTaskRunResult,
|
||||
BatchTriggerAndWaitOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
|
||||
export type {
|
||||
@@ -181,7 +182,7 @@ export function createTask<
|
||||
});
|
||||
}, params.id);
|
||||
},
|
||||
batchTriggerAndWait: async (items) => {
|
||||
batchTriggerAndWait: async (items, options) => {
|
||||
const taskMetadata = taskCatalog.getTaskManifest(params.id);
|
||||
|
||||
return await batchTriggerAndWait_internal<TIdentifier, TInput, TOutput>(
|
||||
@@ -191,6 +192,7 @@ export function createTask<
|
||||
params.id,
|
||||
items,
|
||||
undefined,
|
||||
options,
|
||||
undefined,
|
||||
customQueue
|
||||
);
|
||||
@@ -326,7 +328,7 @@ export function createSchemaTask<
|
||||
});
|
||||
}, params.id);
|
||||
},
|
||||
batchTriggerAndWait: async (items) => {
|
||||
batchTriggerAndWait: async (items, options) => {
|
||||
const taskMetadata = taskCatalog.getTaskManifest(params.id);
|
||||
|
||||
return await batchTriggerAndWait_internal<TIdentifier, inferSchemaIn<TSchema>, TOutput>(
|
||||
@@ -336,6 +338,7 @@ export function createSchemaTask<
|
||||
params.id,
|
||||
items,
|
||||
parsePayload,
|
||||
options,
|
||||
undefined,
|
||||
customQueue
|
||||
);
|
||||
@@ -469,13 +472,14 @@ export function triggerAndWait<TTask extends AnyTask>(
|
||||
export async function batchTriggerAndWait<TTask extends AnyTask>(
|
||||
id: TaskIdentifier<TTask>,
|
||||
items: Array<BatchItem<TaskPayload<TTask>>>,
|
||||
options?: BatchTriggerAndWaitOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): Promise<BatchResult<TaskIdentifier<TTask>, TaskOutput<TTask>>> {
|
||||
return await batchTriggerAndWait_internal<
|
||||
TaskIdentifier<TTask>,
|
||||
TaskPayload<TTask>,
|
||||
TaskOutput<TTask>
|
||||
>("tasks.batchTriggerAndWait()", id, items, undefined, requestOptions);
|
||||
>("tasks.batchTriggerAndWait()", id, items, undefined, options, requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -618,6 +622,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
|
||||
spanParentAsLink: true,
|
||||
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
|
||||
idempotencyKeyTTL: options?.idempotencyKeyTTL,
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
{
|
||||
name: "batch.trigger()",
|
||||
@@ -740,6 +745,7 @@ export async function batchTriggerById<TTask extends AnyTask>(
|
||||
*/
|
||||
export async function batchTriggerByIdAndWait<TTask extends AnyTask>(
|
||||
items: Array<BatchByIdAndWaitItem<InferRunTypes<TTask>>>,
|
||||
options?: BatchTriggerAndWaitOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<BatchByIdResult<TTask>> {
|
||||
const ctx = taskContext.ctx;
|
||||
@@ -786,7 +792,9 @@ export async function batchTriggerByIdAndWait<TTask extends AnyTask>(
|
||||
),
|
||||
dependentAttempt: ctx.attempt.id,
|
||||
},
|
||||
{},
|
||||
{
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
@@ -948,6 +956,7 @@ export async function batchTriggerTasks<TTasks extends readonly AnyTask[]>(
|
||||
spanParentAsLink: true,
|
||||
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
|
||||
idempotencyKeyTTL: options?.idempotencyKeyTTL,
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
{
|
||||
name: "batch.triggerByTask()",
|
||||
@@ -1072,6 +1081,7 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
|
||||
items: {
|
||||
[K in keyof TTasks]: BatchByTaskAndWaitItem<TTasks[K]>;
|
||||
},
|
||||
options?: BatchTriggerAndWaitOptions,
|
||||
requestOptions?: TriggerApiRequestOptions
|
||||
): Promise<BatchByTaskResult<TTasks>> {
|
||||
const ctx = taskContext.ctx;
|
||||
@@ -1118,7 +1128,9 @@ export async function batchTriggerAndWaitTasks<TTasks extends readonly AnyTask[]
|
||||
),
|
||||
dependentAttempt: ctx.attempt.id,
|
||||
},
|
||||
{},
|
||||
{
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
@@ -1256,6 +1268,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
|
||||
spanParentAsLink: true,
|
||||
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
|
||||
idempotencyKeyTTL: options?.idempotencyKeyTTL,
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
{
|
||||
name,
|
||||
@@ -1377,6 +1390,7 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
|
||||
id: TIdentifier,
|
||||
items: Array<BatchTriggerAndWaitItem<TPayload>>,
|
||||
parsePayload?: SchemaParseFn<TPayload>,
|
||||
options?: BatchTriggerAndWaitOptions,
|
||||
requestOptions?: ApiRequestOptions,
|
||||
queue?: QueueOptions
|
||||
): Promise<BatchResult<TIdentifier, TOutput>> {
|
||||
@@ -1420,7 +1434,9 @@ async function batchTriggerAndWait_internal<TIdentifier extends string, TPayload
|
||||
),
|
||||
dependentAttempt: ctx.attempt.id,
|
||||
},
|
||||
{},
|
||||
{
|
||||
processingStrategy: options?.triggerSequentially ? "sequential" : undefined,
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
|
||||
Generated
+11
-11
@@ -1015,7 +1015,7 @@ importers:
|
||||
packages/build:
|
||||
dependencies:
|
||||
'@trigger.dev/core':
|
||||
specifier: workspace:3.3.3
|
||||
specifier: workspace:3.3.6
|
||||
version: link:../core
|
||||
pkg-types:
|
||||
specifier: ^1.1.3
|
||||
@@ -1094,10 +1094,10 @@ importers:
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1
|
||||
'@trigger.dev/build':
|
||||
specifier: workspace:3.3.3
|
||||
specifier: workspace:3.3.6
|
||||
version: link:../build
|
||||
'@trigger.dev/core':
|
||||
specifier: workspace:3.3.3
|
||||
specifier: workspace:3.3.6
|
||||
version: link:../core
|
||||
c12:
|
||||
specifier: ^1.11.1
|
||||
@@ -1263,8 +1263,8 @@ importers:
|
||||
packages/core:
|
||||
dependencies:
|
||||
'@electric-sql/client':
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
specifier: 0.9.0
|
||||
version: 0.9.0
|
||||
'@google-cloud/precise-date':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0
|
||||
@@ -1390,7 +1390,7 @@ importers:
|
||||
packages/react-hooks:
|
||||
dependencies:
|
||||
'@trigger.dev/core':
|
||||
specifier: workspace:^3.3.3
|
||||
specifier: workspace:^3.3.6
|
||||
version: link:../core
|
||||
react:
|
||||
specifier: '>=18 || >=19.0.0-beta'
|
||||
@@ -1430,7 +1430,7 @@ importers:
|
||||
packages/rsc:
|
||||
dependencies:
|
||||
'@trigger.dev/core':
|
||||
specifier: workspace:^3.3.3
|
||||
specifier: workspace:^3.3.6
|
||||
version: link:../core
|
||||
mlly:
|
||||
specifier: ^1.7.1
|
||||
@@ -1446,7 +1446,7 @@ importers:
|
||||
specifier: ^0.15.4
|
||||
version: 0.15.4
|
||||
'@trigger.dev/build':
|
||||
specifier: workspace:^3.3.3
|
||||
specifier: workspace:^3.3.6
|
||||
version: link:../build
|
||||
'@types/node':
|
||||
specifier: ^20.14.14
|
||||
@@ -1482,7 +1482,7 @@ importers:
|
||||
specifier: 1.25.1
|
||||
version: 1.25.1
|
||||
'@trigger.dev/core':
|
||||
specifier: workspace:3.3.3
|
||||
specifier: workspace:3.3.6
|
||||
version: link:../core
|
||||
chalk:
|
||||
specifier: ^5.2.0
|
||||
@@ -5112,8 +5112,8 @@ packages:
|
||||
'@rollup/rollup-darwin-arm64': 4.21.3
|
||||
dev: false
|
||||
|
||||
/@electric-sql/client@0.7.1:
|
||||
resolution: {integrity: sha512-NpKEn5hDSy+NaAdG9Ql8kIGfjrj/XfakJOOHTTutb99db3Dza0uUfnkqycFpyUAarFMQ4hYSKgx8AbOm1PCeFQ==}
|
||||
/@electric-sql/client@0.9.0:
|
||||
resolution: {integrity: sha512-UL2Gep9wPdGMTE0oEWVi0HA8R293R2OzFfHeAsN2LABYYl/boXss7nseNEiIV5+RjHPH7Tm8NsjH9iJW2rZkrQ==}
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-darwin-arm64': 4.21.3
|
||||
dev: false
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import RealtimeComparison from "@/components/RealtimeComparison";
|
||||
import { auth } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default async function RuntimeComparisonPage() {
|
||||
const accessToken = await auth.createTriggerPublicToken("openai-streaming");
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4 bg-gray-900">
|
||||
<RealtimeComparison accessToken={accessToken} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ function RunDetailsWrapper({
|
||||
const { run, error } = useRealtimeRun<typeof exampleTask>(runId, {
|
||||
accessToken,
|
||||
enabled: accessToken !== undefined,
|
||||
onComplete: (run) => {
|
||||
console.log("Run completed!", run);
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRealtimeRunWithStreams, useTaskTrigger } from "@trigger.dev/react-hooks";
|
||||
import type { STREAMS, openaiStreaming } from "@/trigger/ai";
|
||||
|
||||
export default function RealtimeComparison({ accessToken }: { accessToken: string }) {
|
||||
const trigger = useTaskTrigger<typeof openaiStreaming>("openai-streaming", {
|
||||
accessToken,
|
||||
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
|
||||
});
|
||||
|
||||
const { streams, stop, run } = useRealtimeRunWithStreams<typeof openaiStreaming, STREAMS>(
|
||||
trigger.handle?.id,
|
||||
{
|
||||
accessToken: trigger.handle?.publicAccessToken,
|
||||
enabled: !!trigger.handle,
|
||||
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
|
||||
onComplete: (...args) => {
|
||||
console.log("Run completed!", args);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 text-gray-200 text-xs">
|
||||
<div className="p-4">
|
||||
<Button
|
||||
className="bg-gray-100 text-gray-900 hover:bg-gray-200 font-semibold text-xs"
|
||||
onClick={() => {
|
||||
trigger.submit({
|
||||
model: "gpt-4o-mini",
|
||||
prompt:
|
||||
"Based on the temperature, will I need to wear extra clothes today in San Fransico? Please be detailed.",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Debug LLM Streaming
|
||||
</Button>
|
||||
|
||||
{run && (
|
||||
<Button
|
||||
className="bg-gray-100 text-gray-900 hover:bg-gray-200 font-semibold text-xs ml-8"
|
||||
onClick={() => {
|
||||
stop();
|
||||
}}
|
||||
>
|
||||
Stop Streaming
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow flex overflow-hidden">
|
||||
<div className="w-1/2 border-r border-gray-700 overflow-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="bg-gray-800">
|
||||
<th className="w-16 p-2 text-left">ID</th>
|
||||
<th className="p-2 text-left">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(streams.openai ?? []).map((part, i) => (
|
||||
<tr key={i} className="border-b border-gray-700">
|
||||
<td className="w-16 p-2 truncate">{i + 1}</td>
|
||||
<td className="p-2">
|
||||
<div className="font-mono whitespace-nowrap overflow-x-auto">
|
||||
{JSON.stringify(part)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="w-1/2 overflow-auto">
|
||||
<table className="w-full table-fixed">
|
||||
<thead>
|
||||
<tr className="bg-gray-800">
|
||||
<th className="w-16 p-2 text-left">ID</th>
|
||||
<th className="p-2 text-left">Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(streams.openaiText ?? []).map((text, i) => (
|
||||
<tr key={i} className="border-b border-gray-700">
|
||||
<td className="w-16 p-2 truncate">{i + 1}</td>
|
||||
<td className="p-2">
|
||||
<div className="font-mono whitespace-nowrap overflow-x-auto">{text}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,10 @@ const openaiSDK = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
export type STREAMS = { openai: TextStreamPart<{ getWeather: typeof weatherTask.tool }> };
|
||||
export type STREAMS = {
|
||||
openai: TextStreamPart<{ getWeather: typeof weatherTask.tool }>;
|
||||
openaiText: string;
|
||||
};
|
||||
|
||||
export const openaiConsumer = schemaTask({
|
||||
id: "openai-consumer",
|
||||
@@ -105,18 +108,7 @@ export const openaiStreaming = schemaTask({
|
||||
});
|
||||
|
||||
const stream = await metadata.stream("openai", result.fullStream);
|
||||
|
||||
let text = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
logger.log("Received chunk", { chunk });
|
||||
|
||||
if (chunk.type === "text-delta") {
|
||||
text += chunk.textDelta;
|
||||
}
|
||||
}
|
||||
|
||||
return { text };
|
||||
await metadata.stream("openaiText", result.textStream);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -124,12 +124,17 @@ export const allV2TestTask = task({
|
||||
retry: {
|
||||
maxAttempts: 1,
|
||||
},
|
||||
run: async () => {
|
||||
const response1 = await batch.trigger<typeof allV2ChildTask1 | typeof allV2ChildTask2>([
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
|
||||
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
|
||||
]);
|
||||
run: async ({ triggerSequentially }: { triggerSequentially?: boolean }) => {
|
||||
const response1 = await batch.trigger<typeof allV2ChildTask1 | typeof allV2ChildTask2>(
|
||||
[
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
|
||||
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Response 1", { response1 });
|
||||
|
||||
@@ -156,11 +161,16 @@ export const allV2TestTask = task({
|
||||
|
||||
const {
|
||||
runs: [batchRun1, batchRun2, batchRun3],
|
||||
} = await batch.triggerByTask([
|
||||
{ task: allV2ChildTask1, payload: { child1: "foo" } },
|
||||
{ task: allV2ChildTask2, payload: { child2: "bar" } },
|
||||
{ task: allV2ChildTask1, payload: { child1: "baz" } },
|
||||
]);
|
||||
} = await batch.triggerByTask(
|
||||
[
|
||||
{ task: allV2ChildTask1, payload: { child1: "foo" } },
|
||||
{ task: allV2ChildTask2, payload: { child2: "bar" } },
|
||||
{ task: allV2ChildTask1, payload: { child1: "baz" } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Batch runs", { batchRun1, batchRun2, batchRun3 });
|
||||
|
||||
@@ -179,11 +189,16 @@ export const allV2TestTask = task({
|
||||
type TaskRun3Payload = Expect<Equal<typeof taskRun3.payload, { child1: string } | undefined>>;
|
||||
type TaskRun3Output = Expect<Equal<typeof taskRun3.output, { foo: string } | undefined>>;
|
||||
|
||||
const response3 = await batch.triggerAndWait<typeof allV2ChildTask1 | typeof allV2ChildTask2>([
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
|
||||
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
|
||||
]);
|
||||
const response3 = await batch.triggerAndWait<typeof allV2ChildTask1 | typeof allV2ChildTask2>(
|
||||
[
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "foo" } },
|
||||
{ id: "all-v2-test-child-2", payload: { child2: "bar" } },
|
||||
{ id: "all-v2-test-child-1", payload: { child1: "baz" } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Response 3", { response3 });
|
||||
|
||||
@@ -225,11 +240,16 @@ export const allV2TestTask = task({
|
||||
|
||||
const {
|
||||
runs: [batch2Run1, batch2Run2, batch2Run3],
|
||||
} = await batch.triggerByTaskAndWait([
|
||||
{ task: allV2ChildTask1, payload: { child1: "foo" } },
|
||||
{ task: allV2ChildTask2, payload: { child2: "bar" } },
|
||||
{ task: allV2ChildTask1, payload: { child1: "baz" } },
|
||||
]);
|
||||
} = await batch.triggerByTaskAndWait(
|
||||
[
|
||||
{ task: allV2ChildTask1, payload: { child1: "foo" } },
|
||||
{ task: allV2ChildTask2, payload: { child2: "bar" } },
|
||||
{ task: allV2ChildTask1, payload: { child1: "baz" } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Batch 2 runs", { batch2Run1, batch2Run2, batch2Run3 });
|
||||
|
||||
@@ -276,14 +296,17 @@ export const batchV2TestTask = task({
|
||||
retry: {
|
||||
maxAttempts: 1,
|
||||
},
|
||||
run: async () => {
|
||||
run: async ({ triggerSequentially }: { triggerSequentially?: boolean }) => {
|
||||
// First lets try triggering with too many items
|
||||
try {
|
||||
await tasks.batchTrigger<typeof batchV2TestChild>(
|
||||
"batch-v2-test-child",
|
||||
Array.from({ length: 501 }, (_, i) => ({
|
||||
payload: { foo: `bar${i}` },
|
||||
}))
|
||||
})),
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
assert.fail("Batch trigger should have failed");
|
||||
@@ -299,10 +322,12 @@ export const batchV2TestTask = task({
|
||||
// tasks.batchTrigger
|
||||
// tasks.batchTriggerAndWait
|
||||
// myTask.batchTriggerAndWait
|
||||
const response1 = await batchV2TestChild.batchTrigger([
|
||||
{ payload: { foo: "bar" } },
|
||||
{ payload: { foo: "baz" } },
|
||||
]);
|
||||
const response1 = await batchV2TestChild.batchTrigger(
|
||||
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Response 1", { response1 });
|
||||
|
||||
@@ -360,7 +385,10 @@ export const batchV2TestTask = task({
|
||||
const response2 = await batchV2TestChild.batchTrigger(
|
||||
Array.from({ length: 30 }, (_, i) => ({
|
||||
payload: { foo: `bar${i}` },
|
||||
}))
|
||||
})),
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Response 2", { response2 });
|
||||
@@ -385,6 +413,7 @@ export const batchV2TestTask = task({
|
||||
{
|
||||
idempotencyKey: idempotencyKey1,
|
||||
idempotencyKeyTTL: "5s",
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -401,6 +430,7 @@ export const batchV2TestTask = task({
|
||||
{
|
||||
idempotencyKey: idempotencyKey1,
|
||||
idempotencyKeyTTL: "5s",
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -429,6 +459,7 @@ export const batchV2TestTask = task({
|
||||
{
|
||||
idempotencyKey: idempotencyKey1,
|
||||
idempotencyKeyTTL: "5s",
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -445,16 +476,21 @@ export const batchV2TestTask = task({
|
||||
const idempotencyKeyChild1 = randomUUID();
|
||||
const idempotencyKeyChild2 = randomUUID();
|
||||
|
||||
const response6 = await batchV2TestChild.batchTrigger([
|
||||
const response6 = await batchV2TestChild.batchTrigger(
|
||||
[
|
||||
{
|
||||
payload: { foo: "bar" },
|
||||
options: { idempotencyKey: idempotencyKeyChild1, idempotencyKeyTTL: "5s" },
|
||||
},
|
||||
{
|
||||
payload: { foo: "baz" },
|
||||
options: { idempotencyKey: idempotencyKeyChild2, idempotencyKeyTTL: "15s" },
|
||||
},
|
||||
],
|
||||
{
|
||||
payload: { foo: "bar" },
|
||||
options: { idempotencyKey: idempotencyKeyChild1, idempotencyKeyTTL: "5s" },
|
||||
},
|
||||
{
|
||||
payload: { foo: "baz" },
|
||||
options: { idempotencyKey: idempotencyKeyChild2, idempotencyKeyTTL: "15s" },
|
||||
},
|
||||
]);
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Response 6", { response6 });
|
||||
|
||||
@@ -466,10 +502,15 @@ export const batchV2TestTask = task({
|
||||
|
||||
await setTimeout(1000);
|
||||
|
||||
const response7 = await batchV2TestChild.batchTrigger([
|
||||
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
|
||||
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
|
||||
]);
|
||||
const response7 = await batchV2TestChild.batchTrigger(
|
||||
[
|
||||
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
|
||||
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Response 7", { response7 });
|
||||
|
||||
@@ -490,10 +531,15 @@ export const batchV2TestTask = task({
|
||||
await wait.for({ seconds: 6 });
|
||||
|
||||
// Now we need to test that the first run is not cached and is a new run, and the second run is cached
|
||||
const response8 = await batchV2TestChild.batchTrigger([
|
||||
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
|
||||
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
|
||||
]);
|
||||
const response8 = await batchV2TestChild.batchTrigger(
|
||||
[
|
||||
{ payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } },
|
||||
{ payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } },
|
||||
],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Response 8", { response8 });
|
||||
|
||||
@@ -512,10 +558,12 @@ export const batchV2TestTask = task({
|
||||
);
|
||||
|
||||
// Now we need to test with batchTriggerAndWait
|
||||
const response9 = await batchV2TestChild.batchTriggerAndWait([
|
||||
{ payload: { foo: "bar" } },
|
||||
{ payload: { foo: "baz" } },
|
||||
]);
|
||||
const response9 = await batchV2TestChild.batchTriggerAndWait(
|
||||
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Response 9", { response9 });
|
||||
|
||||
@@ -548,7 +596,10 @@ export const batchV2TestTask = task({
|
||||
const response10 = await batchV2TestChild.batchTriggerAndWait(
|
||||
Array.from({ length: 21 }, (_, i) => ({
|
||||
payload: { foo: `bar${i}` },
|
||||
}))
|
||||
})),
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Response 10", { response10 });
|
||||
@@ -557,10 +608,13 @@ export const batchV2TestTask = task({
|
||||
assert.equal(response10.runs.length, 21, "response10: Items length is invalid");
|
||||
|
||||
// Now repeat the first few tests using `tasks.batchTrigger`:
|
||||
const response11 = await tasks.batchTrigger<typeof batchV2TestChild>("batch-v2-test-child", [
|
||||
{ payload: { foo: "bar" } },
|
||||
{ payload: { foo: "baz" } },
|
||||
]);
|
||||
const response11 = await tasks.batchTrigger<typeof batchV2TestChild>(
|
||||
"batch-v2-test-child",
|
||||
[{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }],
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Response 11", { response11 });
|
||||
|
||||
@@ -584,7 +638,10 @@ export const batchV2TestTask = task({
|
||||
"batch-v2-test-child",
|
||||
Array.from({ length: 100 }, (_, i) => ({
|
||||
payload: { foo: `bar${i}` },
|
||||
}))
|
||||
})),
|
||||
{
|
||||
triggerSequentially,
|
||||
}
|
||||
);
|
||||
|
||||
const response12Start = performance.now();
|
||||
|
||||
Reference in New Issue
Block a user