feat/realtime-streams (#1470)

* WIP realtime streams

* Handle realtime with large payloads or outputs #1451

* feat: optimize Redis stream handling with batching

Add STREAM_ORIGIN to environment schema. Improve performance in
RealtimeStreams by using TextDecoderStream for simpler text
decoding and implementing batching of XADD commands for Redis
streams. Limit stream size using MAXLEN option. Update
environment variable repository with new variable type. Adjust
import statements for Redis key and value types.

* 🔧 chore: add dev dependencies for bundle analysis

* add metadata tests and a few more utilties

* Add stream tests and improve streaming

* Added AI tool tasks, descriptions to tasks

* Use the config file path to determine the workingDir, then the package.json path

* Remove stream test files

* useTaskTrigger react hook that allows triggering a task from the client

* Add streaming support for the realtime react hooks

* Add ability to stream results after useTaskTrigger

* Improve the stream throttling

* Use the runId as the ID key to bust the cache after triggering

* Upgrade to to the latest electric sql client and server

* Make realtime server backwards compat with 3.1.2 release

* Pass the runId into useRealtimeRun

* Fix scopes when specifiying reading all runs

* WIP @trigger.dev/rsc package

* Various fixes and accepted recommendations by CodeRabbit

* Regenerate pnpm lock file

* A couple tweaks to rsc and give up on rendering react in tasks for now

* Add changeset

* Remove triggerRequest from the useEffect deps

* Improve realtime & frontend authentication errors

* Fixed authorization tests

* Remove unnecessary log

* Add metadata.stream limits and improve the metadata streams structure

* Streams can now have up to 2500 entries

* Various coderabbit fixes

* additional react-hooks jsdocs
This commit is contained in:
Eric Allam
2024-11-19 13:14:25 +00:00
committed by GitHub
parent ea0956464b
commit 23b43be952
112 changed files with 6056 additions and 1053 deletions
+10
View File
@@ -0,0 +1,10 @@
---
"@trigger.dev/react-hooks": patch
"@trigger.dev/sdk": patch
"trigger.dev": patch
"@trigger.dev/build": patch
"@trigger.dev/core": patch
"@trigger.dev/rsc": patch
---
Realtime streams
+17
View File
@@ -13,6 +13,15 @@
"cwd": "${workspaceFolder}",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug realtimeStreams.test.ts",
"command": "pnpm run test -t RealtimeStreams",
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceFolder}/apps/webapp",
"sourceMaps": true
},
{
"type": "chrome",
"request": "launch",
@@ -36,6 +45,14 @@
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug Dev Next.js Realtime",
"command": "pnpm exec trigger dev",
"cwd": "${workspaceFolder}/references/nextjs-realtime",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
+1
View File
@@ -32,6 +32,7 @@ const EnvironmentSchema = z.object({
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
API_ORIGIN: z.string().optional(),
STREAM_ORIGIN: z.string().optional(),
ELECTRIC_ORIGIN: z.string().default("http://localhost:3060"),
APP_ENV: z.string().default(process.env.NODE_ENV),
SERVICE_NAME: z.string().default("trigger.dev webapp"),
@@ -210,7 +210,7 @@ export class SpanPresenter extends BasePresenter {
const span = await eventRepository.getSpan(spanId, run.traceId);
const metadata = run.metadata
? await prettyPrintPacket(run.metadata, run.metadataType)
? await prettyPrintPacket(run.metadata, run.metadataType, { filteredKeys: ["$$streams"] })
: undefined;
const context = {
+23 -23
View File
@@ -2,6 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { generatePresignedUrl } from "~/v3/r2.server";
const ParamsSchema = z.object({
@@ -39,28 +40,27 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ presignedUrl });
}
export async function loader({ request, params }: ActionFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
},
async ({ params, authentication }) => {
const filename = params["*"];
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
const presignedUrl = await generatePresignedUrl(
authentication.environment.project.externalRef,
authentication.environment.slug,
filename,
"GET"
);
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
// Caller can now use this URL to fetch that object.
return json({ presignedUrl });
}
const parsedParams = ParamsSchema.parse(params);
const filename = parsedParams["*"];
const presignedUrl = await generatePresignedUrl(
authenticationResult.environment.project.externalRef,
authenticationResult.environment.slug,
filename,
"GET"
);
if (!presignedUrl) {
return json({ error: "Failed to generate presigned URL" }, { status: 500 });
}
// Caller can now use this URL to fetch that object.
return json({ presignedUrl });
}
);
@@ -5,7 +5,7 @@ import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderPATApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
projectRef: z.string(),
+1 -1
View File
@@ -3,7 +3,7 @@ import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
export const loader = createLoaderApiRoute(
{
@@ -1,15 +1,13 @@
import { fromZodError } from "zod-validation-error";
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { generateJWT as internal_generateJWT, TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { env } from "~/env.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
import { startActiveSpan } from "~/v3/tracer.server";
const ParamsSchema = z.object({
taskId: z.string(),
@@ -20,115 +18,125 @@ export const HeadersSchema = z.object({
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
"x-trigger-worker": z.string().nullish(),
"x-trigger-client": z.string().nullish(),
traceparent: z.string().optional(),
tracestate: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
params: ParamsSchema,
body: TriggerTaskRequestBody,
allowJWT: true,
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "write",
resource: (params) => ({ tasks: params.taskId }),
superScopes: ["write:tasks", "admin"],
},
corsStrategy: "all",
},
async ({ body, headers, params, authentication }) => {
const {
"idempotency-key": idempotencyKey,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
"x-trigger-client": triggerClient,
} = headers;
const service = new TriggerTaskService();
try {
const traceContext =
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
logger.debug("Triggering task", {
taskId: params.taskId,
idempotencyKey,
triggerVersion,
headers,
options: body.options,
isFromWorker,
traceContext,
});
const run = await service.call(params.taskId, authentication.environment, body, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
if (!run) {
return json({ error: "Task not found" }, { status: 404 });
}
const $responseHeaders = await responseHeaders(
run,
authentication.environment,
triggerClient
);
return json(
{
id: run.friendlyId,
},
{
headers: $responseHeaders,
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 500 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > env.TASK_PAYLOAD_MAXIMUM_SIZE) {
return json({ error: "Request body too large" }, { status: 413 });
}
const rawHeaders = Object.fromEntries(request.headers);
const headers = HeadersSchema.safeParse(rawHeaders);
if (!headers.success) {
return json({ error: "Invalid headers" }, { status: 400 });
}
const {
"idempotency-key": idempotencyKey,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
} = headers.data;
const { taskId } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await parseRequestJsonAsync(request, { taskId });
const body = await startActiveSpan("TriggerTaskRequestBody.safeParse()", async (span) => {
return TriggerTaskRequestBody.safeParse(anyBody);
async function responseHeaders(
run: TaskRun,
environment: AuthenticatedEnvironment,
triggerClient?: string | null
): Promise<Record<string, string>> {
const claimsHeader = JSON.stringify({
sub: environment.id,
pub: true,
});
if (!body.success) {
return json(
{ error: fromZodError(body.error, { prefix: "Invalid trigger call" }).toString() },
{ status: 400 }
);
}
if (triggerClient === "browser") {
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`],
};
const service = new TriggerTaskService();
try {
const traceContext =
traceparent && isFromWorker /// If the request is from a worker, we should pass the trace context
? { traceparent, tracestate }
: undefined;
logger.debug("Triggering task", {
taskId,
idempotencyKey,
triggerVersion,
headers: Object.fromEntries(request.headers),
options: body.data.options,
isFromWorker,
traceContext,
const jwt = await internal_generateJWT({
secretKey: environment.apiKey,
payload: claims,
expirationTime: "1h",
});
const run = await service.call(taskId, authenticationResult.environment, body.data, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
});
if (!run) {
return json({ error: "Task not found" }, { status: 404 });
}
return json(
{
id: run.friendlyId,
},
{
headers: {
"x-trigger-jwt-claims": JSON.stringify({
sub: authenticationResult.environment.id,
pub: true,
}),
},
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
} else if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
return {
"x-trigger-jwt-claims": claimsHeader,
"x-trigger-jwt": jwt,
};
}
return {
"x-trigger-jwt-claims": claimsHeader,
};
}
export { action, loader };
+1 -1
View File
@@ -1,7 +1,7 @@
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
batchId: z.string(),
@@ -31,6 +31,11 @@ export const loader = createLoaderApiRoute(
return json({ error: "Batch not found" }, { status: 404 });
}
return realtimeClient.streamBatch(request.url, authentication.environment, batchRun.id);
return realtimeClient.streamBatch(
request.url,
authentication.environment,
batchRun.id,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
@@ -2,7 +2,7 @@ import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
@@ -31,6 +31,11 @@ export const loader = createLoaderApiRoute(
return json({ error: "Run not found" }, { status: 404 });
}
return realtimeClient.streamRun(request.url, authentication.environment, run.id);
return realtimeClient.streamRun(
request.url,
authentication.environment,
run.id,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
+7 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
@@ -23,6 +23,11 @@ export const loader = createLoaderApiRoute(
},
},
async ({ searchParams, authentication, request }) => {
return realtimeClient.streamRuns(request.url, authentication.environment, searchParams);
return realtimeClient.streamRuns(
request.url,
authentication.environment,
searchParams,
request.headers.get("x-trigger-electric-version") ?? undefined
);
}
);
@@ -0,0 +1,47 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { z } from "zod";
import { $replica } from "~/db.server";
import { realtimeStreams } from "~/services/realtimeStreamsGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
streamId: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const $params = ParamsSchema.parse(params);
if (!request.body) {
return new Response("No body provided", { status: 400 });
}
return realtimeStreams.ingestData(request.body, $params.runId, $params.streamId);
}
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ runs: params.runId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const run = await $replica.taskRun.findFirst({
where: {
friendlyId: params.runId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!run) {
return new Response("Run not found", { status: 404 });
}
return realtimeStreams.streamResponse(run.friendlyId, params.streamId, request.signal);
}
);
@@ -41,6 +41,7 @@ export async function authenticateApiRequest(
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
@@ -1,4 +1,4 @@
export type AuthorizationAction = "read"; // Add more actions as needed
export type AuthorizationAction = "read" | "write"; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
@@ -35,36 +35,45 @@ export type AuthorizationEntity = {
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
* ```
*/
export type AuthorizationResult = { authorized: true } | { authorized: false; reason: string };
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*/
export function checkAuthorization(
entity: AuthorizationEntity,
action: AuthorizationAction,
resource: AuthorizationResources,
superScopes?: string[]
) {
): AuthorizationResult {
// "PRIVATE" is a secret key and has access to everything
if (entity.type === "PRIVATE") {
return true;
return { authorized: true };
}
// "PUBLIC" is a deprecated key and has no access
if (entity.type === "PUBLIC") {
return false;
return { authorized: false, reason: "PUBLIC type is deprecated and has no access" };
}
// If the entity has no permissions, deny access
if (!entity.scopes || entity.scopes.length === 0) {
return false;
return {
authorized: false,
reason:
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
};
}
// If the resource object is empty, deny access
if (Object.keys(resource).length === 0) {
return false;
return { authorized: false, reason: "Resource object is empty" };
}
// Check for any of the super scopes
if (superScopes && superScopes.length > 0) {
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
return true;
return { authorized: true };
}
}
@@ -94,10 +103,19 @@ export function checkAuthorization(
// If any resource is not authorized, return false
if (!resourceAuthorized) {
return false;
return {
authorized: false,
reason: `Public Access Token is missing required permissions. Permissions required for ${resourceValues
.map((v) => `'${action}:${resourceType}:${v}'`)
.join(", ")} but token has the following permissions: ${entity.scopes
.map((s) => `'${s}'`)
.join(
", "
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
};
}
}
// All resources are authorized
return true;
return { authorized: true };
}
@@ -4,6 +4,7 @@ export type HttpLocalStorage = {
requestId: string;
path: string;
host: string;
method: string;
};
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
@@ -1,3 +1,4 @@
import { json } from "@remix-run/server-runtime";
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
@@ -9,24 +10,51 @@ export async function validatePublicJwtKey(token: string) {
const sub = extractJWTSub(token);
if (!sub) {
return;
throw json({ error: "Invalid Public Access Token, missing subject." }, { status: 401 });
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return;
throw json({ error: "Invalid Public Access Token, environment not found." }, { status: 401 });
}
const claims = await validateJWT(token, environment.apiKey);
const result = await validateJWT(token, environment.apiKey);
if (!claims) {
return;
if (!result.ok) {
switch (result.code) {
case "ERR_JWT_EXPIRED": {
throw json(
{
error:
"Public Access Token has expired. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
},
{ status: 401 }
);
}
case "ERR_JWT_CLAIM_INVALID": {
throw json(
{
error: `Public Access Token is invalid: ${result.error}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
},
{ status: 401 }
);
}
default: {
throw json(
{
error:
"Public Access Token is invalid. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
},
{ status: 401 }
);
}
}
}
return {
environment,
claims,
claims: result.payload,
};
}
@@ -37,18 +37,29 @@ export class RealtimeClient {
this.#registerCommands();
}
async streamRun(url: URL | string, environment: RealtimeEnvironment, runId: string) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`);
async streamRun(
url: URL | string,
environment: RealtimeEnvironment,
runId: string,
clientVersion?: string
) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`, clientVersion);
}
async streamBatch(url: URL | string, environment: RealtimeEnvironment, batchId: string) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`);
async streamBatch(
url: URL | string,
environment: RealtimeEnvironment,
batchId: string,
clientVersion?: string
) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`, clientVersion);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams
params: RealtimeRunsParams,
clientVersion?: string
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
@@ -58,54 +69,66 @@ export class RealtimeClient {
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause);
return this.#streamRunsWhere(url, environment, whereClause, clientVersion);
}
async #streamRunsWhere(url: URL | string, environment: RealtimeEnvironment, whereClause: string) {
const electricUrl = this.#constructElectricUrl(url, whereClause);
async #streamRunsWhere(
url: URL | string,
environment: RealtimeEnvironment,
whereClause: string,
clientVersion?: string
) {
const electricUrl = this.#constructElectricUrl(url, whereClause, clientVersion);
return this.#performElectricRequest(electricUrl, environment);
return this.#performElectricRequest(electricUrl, environment, clientVersion);
}
#constructElectricUrl(url: URL | string, whereClause: string): URL {
#constructElectricUrl(url: URL | string, whereClause: string, clientVersion?: string): URL {
const $url = new URL(url.toString());
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape/public."TaskRun"`);
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);
});
// const electricParams = ["shape_id", "live", "offset", "columns", "cursor"];
// electricParams.forEach((param) => {
// if ($url.searchParams.has(param) && $url.searchParams.get(param)) {
// electricUrl.searchParams.set(param, $url.searchParams.get(param)!);
// }
// });
electricUrl.searchParams.set("where", whereClause);
electricUrl.searchParams.set("table", 'public."TaskRun"');
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) {
async #performElectricRequest(
url: URL,
environment: RealtimeEnvironment,
clientVersion?: string
) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
const rewriteResponseHeaders: Record<string, string> = clientVersion
? {}
: { "electric-handle": "electric-shape-id", "electric-offset": "electric-chunk-last-offset" };
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return longPollingFetch(url.toString());
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return longPollingFetch(url.toString());
return longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
}
const requestId = randomUUID();
@@ -147,7 +170,7 @@ export class RealtimeClient {
try {
// ... (rest of your existing code for the long polling request)
const response = await longPollingFetch(url.toString());
const response = await longPollingFetch(url.toString(), {}, rewriteResponseHeaders);
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
@@ -231,7 +254,7 @@ export class RealtimeClient {
}
function extractShapeId(url: URL) {
return url.searchParams.get("shape_id");
return url.searchParams.get("handle");
}
function isLiveRequestUrl(url: URL) {
@@ -0,0 +1,177 @@
import Redis, { RedisKey, RedisOptions, RedisValue } from "ioredis";
import { logger } from "./logger.server";
export type RealtimeStreamsOptions = {
redis: RedisOptions | undefined;
};
const END_SENTINEL = "<<CLOSE_STREAM>>";
export class RealtimeStreams {
constructor(private options: RealtimeStreamsOptions) {}
async streamResponse(runId: string, streamId: string, signal: AbortSignal): Promise<Response> {
const redis = new Redis(this.options.redis ?? {});
const streamKey = `stream:${runId}:${streamId}`;
let isCleanedUp = false;
const stream = new ReadableStream({
start: async (controller) => {
let lastId = "0";
let retryCount = 0;
const maxRetries = 3;
try {
while (!signal.aborted) {
try {
const messages = await redis.xread(
"COUNT",
100,
"BLOCK",
5000,
"STREAMS",
streamKey,
lastId
);
retryCount = 0;
if (messages && messages.length > 0) {
const [_key, entries] = messages[0];
for (const [id, fields] of entries) {
lastId = id;
if (fields && fields.length >= 2) {
if (fields[1] === END_SENTINEL) {
controller.close();
return;
}
controller.enqueue(`data: ${fields[1]}\n\n`);
if (signal.aborted) {
controller.close();
return;
}
}
}
}
} catch (error) {
if (signal.aborted) break;
logger.error("[RealtimeStreams][streamResponse] Error reading from Redis stream:", {
error,
});
retryCount++;
if (retryCount >= maxRetries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount));
}
}
} catch (error) {
logger.error("[RealtimeStreams][streamResponse] Fatal error in stream processing:", {
error,
});
controller.error(error);
} finally {
await cleanup();
}
},
cancel: async () => {
await cleanup();
},
});
async function cleanup() {
if (isCleanedUp) return;
isCleanedUp = true;
await redis.quit().catch(console.error);
}
signal.addEventListener("abort", cleanup);
return new Response(stream.pipeThrough(new TextEncoderStream()), {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
async ingestData(
stream: ReadableStream<Uint8Array>,
runId: string,
streamId: string
): Promise<Response> {
const redis = new Redis(this.options.redis ?? {});
const streamKey = `stream:${runId}:${streamId}`;
async function cleanup() {
try {
await redis.quit();
} catch (error) {
logger.error("[RealtimeStreams][ingestData] Error in cleanup:", { error });
}
}
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
let batchCommands: Array<[key: RedisKey, ...args: RedisValue[]]> = [];
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
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);
}
await pipeline.exec();
batchCommands = [];
}
}
}
}
// Send any remaining commands
if (batchCommands.length > 0) {
const pipeline = redis.pipeline();
for (const args of batchCommands) {
pipeline.xadd(...args);
}
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 });
} catch (error) {
logger.error("[RealtimeStreams][ingestData] Error in ingestData:", { error });
return new Response(null, { status: 500 });
} finally {
await cleanup();
}
}
}
@@ -0,0 +1,19 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { RealtimeStreams } from "./realtimeStreams.server";
function initializeRealtimeStreams() {
return new RealtimeStreams({
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
keyPrefix: "tr:realtime:streams:",
},
});
}
export const realtimeStreams = singleton("realtimeStreams", initializeRealtimeStreams);
@@ -1,260 +0,0 @@
import { z } from "zod";
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
import { json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
} from "../personalAccessToken.server";
type ApiKeyRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined
) => AuthorizationResources;
superScopes?: string[];
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: ApiAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
return wrapResponse(
request,
json({ error: "Unauthorized" }, { status: 403 }),
corsStrategy !== "none"
);
}
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
corsStrategy?: "all" | "none";
};
type PATHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
authentication: PersonalAccessTokenAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
corsStrategy = "none",
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
try {
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
function wrapResponse(request: Request, response: Response, useCors: boolean) {
return useCors ? apiCors(request, response) : response;
}
@@ -0,0 +1,536 @@
import { z } from "zod";
import { ApiAuthenticationResult, authenticateApiRequest } from "../apiAuth.server";
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { fromZodError } from "zod-validation-error";
import { apiCors } from "~/utils/apiCors";
import {
AuthorizationAction,
AuthorizationResources,
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.server";
import {
authenticateApiRequestWithPersonalAccessToken,
PersonalAccessTokenAuthenticationResult,
} from "../personalAccessToken.server";
import { safeJsonParse } from "~/utils/json";
type ApiKeyRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
allowJWT?: boolean;
corsStrategy?: "all" | "none";
authorization?: {
action: AuthorizationAction;
resource: (
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined,
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined,
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined
) => AuthorizationResources;
superScopes?: string[];
};
};
type ApiKeyHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
authentication: ApiAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
handler: ApiKeyHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
allowJWT = false,
corsStrategy = "none",
authorization,
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
const authorizationResult = checkAuthorization(
authenticationResult,
action,
$resource,
superScopes
);
if (!authorizationResult.authorized) {
return wrapResponse(
request,
json(
{
error: `Unauthorized: ${authorizationResult.reason}`,
code: "unauthorized",
param: "access_token",
type: "authorization",
},
{ status: 403 }
),
corsStrategy !== "none"
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
type PATRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = {
params?: TParamsSchema;
searchParams?: TSearchParamsSchema;
headers?: THeadersSchema;
corsStrategy?: "all" | "none";
};
type PATHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
authentication: PersonalAccessTokenAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createLoaderPATApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined
>(
options: PATRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema>,
handler: PATHandlerFunction<TParamsSchema, TSearchParamsSchema, THeadersSchema>
) {
return async function loader({ request, params }: LoaderFunctionArgs) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
corsStrategy = "none",
} = options;
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
try {
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
console.error("Error in API route:", error);
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
};
}
type ApiKeyActionRouteBuilderOptions<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
> = ApiKeyRouteBuilderOptions<TParamsSchema, TSearchParamsSchema, THeadersSchema> & {
maxContentLength?: number;
body?: TBodySchema;
};
type ApiKeyActionHandlerFunction<
TParamsSchema extends z.AnyZodObject | undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
> = (args: {
params: TParamsSchema extends z.AnyZodObject ? z.infer<TParamsSchema> : undefined;
searchParams: TSearchParamsSchema extends z.AnyZodObject
? z.infer<TSearchParamsSchema>
: undefined;
headers: THeadersSchema extends z.AnyZodObject ? z.infer<THeadersSchema> : undefined;
body: TBodySchema extends z.AnyZodObject ? z.infer<TBodySchema> : undefined;
authentication: ApiAuthenticationResult;
request: Request;
}) => Promise<Response>;
export function createActionApiRoute<
TParamsSchema extends z.AnyZodObject | undefined = undefined,
TSearchParamsSchema extends z.AnyZodObject | undefined = undefined,
THeadersSchema extends z.AnyZodObject | undefined = undefined,
TBodySchema extends z.AnyZodObject | undefined = undefined
>(
options: ApiKeyActionRouteBuilderOptions<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>,
handler: ApiKeyActionHandlerFunction<
TParamsSchema,
TSearchParamsSchema,
THeadersSchema,
TBodySchema
>
) {
const {
params: paramsSchema,
searchParams: searchParamsSchema,
headers: headersSchema,
body: bodySchema,
allowJWT = false,
corsStrategy = "none",
authorization,
maxContentLength,
} = options;
async function loader({ request, params }: LoaderFunctionArgs) {
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
return new Response(null, { status: 405 });
}
async function action({ request, params }: ActionFunctionArgs) {
try {
const authenticationResult = await authenticateApiRequest(request, { allowJWT });
if (!authenticationResult) {
return wrapResponse(
request,
json({ error: "Invalid or Missing API key" }, { status: 401 }),
corsStrategy !== "none"
);
}
if (maxContentLength) {
const contentLength = request.headers.get("content-length");
if (!contentLength || parseInt(contentLength) > maxContentLength) {
return json({ error: "Request body too large" }, { status: 413 });
}
}
let parsedParams: any = undefined;
if (paramsSchema) {
const parsed = paramsSchema.safeParse(params);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Params Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedParams = parsed.data;
}
let parsedSearchParams: any = undefined;
if (searchParamsSchema) {
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
const parsed = searchParamsSchema.safeParse(searchParams);
if (!parsed.success) {
return wrapResponse(
request,
json(
{ error: "Query Error", details: fromZodError(parsed.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedSearchParams = parsed.data;
}
let parsedHeaders: any = undefined;
if (headersSchema) {
const rawHeaders = Object.fromEntries(request.headers);
const headers = headersSchema.safeParse(rawHeaders);
if (!headers.success) {
return wrapResponse(
request,
json(
{ error: "Headers Error", details: fromZodError(headers.error).details },
{ status: 400 }
),
corsStrategy !== "none"
);
}
parsedHeaders = headers.data;
}
let parsedBody: any = undefined;
if (bodySchema) {
const rawBody = await request.text();
if (rawBody.length === 0) {
return wrapResponse(
request,
json({ error: "Request body is empty" }, { status: 400 }),
corsStrategy !== "none"
);
}
const rawParsedJson = safeJsonParse(rawBody);
if (!rawParsedJson) {
return wrapResponse(
request,
json({ error: "Invalid JSON" }, { status: 400 }),
corsStrategy !== "none"
);
}
const body = bodySchema.safeParse(rawParsedJson);
if (!body.success) {
return wrapResponse(
request,
json({ error: fromZodError(body.error).toString() }, { status: 400 }),
corsStrategy !== "none"
);
}
parsedBody = body.data;
}
if (authorization) {
const { action, resource, superScopes } = authorization;
const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders);
logger.debug("Checking authorization", {
action,
resource: $resource,
superScopes,
scopes: authenticationResult.scopes,
});
if (!checkAuthorization(authenticationResult, action, $resource, superScopes)) {
return wrapResponse(
request,
json({ error: "Unauthorized" }, { status: 403 }),
corsStrategy !== "none"
);
}
}
const result = await handler({
params: parsedParams,
searchParams: parsedSearchParams,
headers: parsedHeaders,
body: parsedBody,
authentication: authenticationResult,
request,
});
return wrapResponse(request, result, corsStrategy !== "none");
} catch (error) {
if (error instanceof Response) {
return wrapResponse(request, error, corsStrategy !== "none");
}
return wrapResponse(
request,
json({ error: "Internal Server Error" }, { status: 500 }),
corsStrategy !== "none"
);
}
}
return { loader, action };
}
function wrapResponse(request: Request, response: Response, useCors: boolean) {
return useCors
? apiCors(request, response, { exposedHeaders: ["x-trigger-jwt", "x-trigger-jwt-claims"] })
: response;
}
+1
View File
@@ -8,6 +8,7 @@ type CorsOptions = {
maxAge?: number;
origin?: boolean | string;
credentials?: boolean;
exposedHeaders?: string[];
};
export async function apiCors(
+25 -1
View File
@@ -6,7 +6,11 @@
import { logger } from "~/services/logger.server";
// Similar-ish problem to https://github.com/wintercg/fetch/issues/23
export async function longPollingFetch(url: string, options?: RequestInit) {
export async function longPollingFetch(
url: string,
options?: RequestInit,
rewriteResponseHeaders?: Record<string, string>
) {
try {
let response = await fetch(url, options);
@@ -14,12 +18,32 @@ export async function longPollingFetch(url: string, options?: RequestInit) {
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("content-length");
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
if (rewriteResponseHeaders) {
const headers = new Headers(response.headers);
for (const [fromKey, toKey] of Object.entries(rewriteResponseHeaders)) {
const value = headers.get(fromKey);
if (value) {
headers.set(toKey, value);
headers.delete(fromKey);
}
}
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
}
return response;
} catch (error) {
if (error instanceof TypeError) {
@@ -734,6 +734,10 @@ async function resolveBuiltInProdVariables(runtimeEnvironment: RuntimeEnvironmen
key: "TRIGGER_API_URL",
value: env.API_ORIGIN ?? env.APP_ORIGIN,
},
{
key: "TRIGGER_STREAM_URL",
value: env.STREAM_ORIGIN ?? env.API_ORIGIN ?? env.APP_ORIGIN,
},
{
key: "TRIGGER_RUNTIME_WAIT_THRESHOLD_IN_MS",
value: String(env.CHECKPOINT_THRESHOLD_IN_MS),
@@ -150,6 +150,7 @@ export async function createBackgroundTasks(
runtimeEnvironmentId: worker.runtimeEnvironmentId,
workerId: worker.id,
slug: task.id,
description: task.description,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
+1 -1
View File
@@ -77,7 +77,7 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
// Generate a unique request ID for each request
const requestId = nanoid();
runWithHttpContext({ requestId, path: req.url, host: req.hostname }, next);
runWithHttpContext({ requestId, path: req.url, host: req.hostname, method: req.method }, next);
});
if (process.env.DASHBOARD_AND_API_DISABLED !== "true") {
+223 -120
View File
@@ -12,110 +12,183 @@ describe("checkAuthorization", () => {
const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
describe("PRIVATE entity", () => {
it("should always return true regardless of action or resource", () => {
expect(checkAuthorization(privateEntity, "read", { runs: "run_1234" })).toBe(true);
expect(checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] })).toBe(true);
expect(checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" })).toBe(true);
it("should always return authorized regardless of action or resource", () => {
const result1 = checkAuthorization(privateEntity, "read", { runs: "run_1234" });
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] });
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
const result3 = checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" });
expect(result3.authorized).toBe(true);
expect(result3).not.toHaveProperty("reason");
});
});
describe("PUBLIC entity", () => {
it("should always return false regardless of action or resource", () => {
expect(checkAuthorization(publicEntity, "read", { runs: "run_1234" })).toBe(false);
expect(checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] })).toBe(false);
expect(checkAuthorization(publicEntity, "read", { tags: "tag_5678" })).toBe(false);
it("should always return unauthorized with reason regardless of action or resource", () => {
const result1 = checkAuthorization(publicEntity, "read", { runs: "run_1234" });
expect(result1.authorized).toBe(false);
if (!result1.authorized) {
expect(result1.reason).toBe("PUBLIC type is deprecated and has no access");
}
const result2 = checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] });
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe("PUBLIC type is deprecated and has no access");
}
const result3 = checkAuthorization(publicEntity, "read", { tags: "tag_5678" });
expect(result3.authorized).toBe(false);
if (!result3.authorized) {
expect(result3.reason).toBe("PUBLIC type is deprecated and has no access");
}
});
});
describe("PUBLIC_JWT entity with scope", () => {
it("should return true for specific resource scope", () => {
expect(checkAuthorization(publicJwtEntityWithPermissions, "read", { runs: "run_1234" })).toBe(
true
);
it("should return authorized for specific resource scope", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234",
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return false for unauthorized specific resources", () => {
expect(checkAuthorization(publicJwtEntityWithPermissions, "read", { runs: "run_5678" })).toBe(
false
);
it("should return unauthorized with reason for unauthorized specific resources", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_5678",
});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should return true for general resource type scope", () => {
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", { tasks: "task_1234" })
).toBe(true);
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", {
tasks: ["task_5678", "task_9012"],
})
).toBe(true);
it("should return authorized for general resource type scope", () => {
const result1 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tasks: "task_1234",
});
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tasks: ["task_5678", "task_9012"],
});
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
});
it("should return true if any resource in an array is authorized", () => {
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", {
tags: ["tag_1234", "tag_5678"],
})
).toBe(true);
it("should return authorized if any resource in an array is authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
tags: ["tag_1234", "tag_5678"],
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should return true for nonexistent resource types", () => {
expect(
it("should return authorized for nonexistent resource types", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
// @ts-expect-error
checkAuthorization(publicJwtEntityWithPermissions, "read", { nonexistent: "resource" })
).toBe(true);
nonexistent: "resource",
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
});
describe("PUBLIC_JWT entity without scope", () => {
it("should always return false regardless of action or resource", () => {
expect(checkAuthorization(publicJwtEntityNoPermissions, "read", { runs: "run_1234" })).toBe(
false
);
expect(
checkAuthorization(publicJwtEntityNoPermissions, "read", { tasks: ["task_1", "task_2"] })
).toBe(false);
expect(checkAuthorization(publicJwtEntityNoPermissions, "read", { tags: "tag_5678" })).toBe(
false
);
it("should always return unauthorized with reason regardless of action or resource", () => {
const result1 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
runs: "run_1234",
});
expect(result1.authorized).toBe(false);
if (!result1.authorized) {
expect(result1.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
const result2 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
tasks: ["task_1", "task_2"],
});
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
const result3 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
tags: "tag_5678",
});
expect(result3.authorized).toBe(false);
if (!result3.authorized) {
expect(result3.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
describe("Edge cases", () => {
it("should handle empty resource objects", () => {
expect(checkAuthorization(publicJwtEntityWithPermissions, "read", {})).toBe(false);
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe("Resource object is empty");
}
});
it("should handle undefined scope", () => {
const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
expect(checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" })).toBe(
false
);
const result = checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" });
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should handle empty scope array", () => {
const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] };
expect(checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" })).toBe(false);
const result = checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" });
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should return false if any resource is not authorized", () => {
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234", // This is authorized
tasks: "task_5678", // This is authorized (general permission)
tags: "tag_3456", // This is not authorized
})
).toBe(false);
it("should return unauthorized if any resource is not authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234", // This is authorized
tasks: "task_5678", // This is authorized (general permission)
tags: "tag_3456", // This is not authorized
});
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Permissions required for 'read:tags:tag_3456' but token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should return true only if all resources are authorized", () => {
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234", // This is authorized
tasks: "task_5678", // This is authorized (general permission)
tags: "tag_5678", // This is authorized
})
).toBe(true);
it("should return authorized only if all resources are authorized", () => {
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
runs: "run_1234", // This is authorized
tasks: "task_5678", // This is authorized (general permission)
tags: "tag_5678", // This is authorized
});
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
});
@@ -131,51 +204,64 @@ describe("checkAuthorization", () => {
};
it("should grant access with any of the super scope", () => {
expect(
checkAuthorization(entityWithSuperPermissions, "read", { tasks: "task_1234" }, [
"read:all",
"admin",
])
).toBe(true);
expect(
checkAuthorization(entityWithSuperPermissions, "read", { tags: ["tag_1", "tag_2"] }, [
"write:all",
"admin",
])
).toBe(true);
const result1 = checkAuthorization(
entityWithSuperPermissions,
"read",
{ tasks: "task_1234" },
["read:all", "admin"]
);
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(
entityWithSuperPermissions,
"read",
{ tags: ["tag_1", "tag_2"] },
["write:all", "admin"]
);
expect(result2.authorized).toBe(true);
expect(result2).not.toHaveProperty("reason");
});
it("should grant access with one matching super permission", () => {
expect(
checkAuthorization(entityWithOneSuperPermission, "read", { runs: "run_5678" }, [
"read:all",
"admin",
])
).toBe(true);
const result = checkAuthorization(
entityWithOneSuperPermission,
"read",
{ runs: "run_5678" },
["read:all", "admin"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should not grant access when no super scope match", () => {
expect(
checkAuthorization(entityWithOneSuperPermission, "read", { tasks: "task_1234" }, [
"write:all",
"admin",
])
).toBe(false);
const result = checkAuthorization(
entityWithOneSuperPermission,
"read",
{ tasks: "task_1234" },
["write:all", "admin"]
);
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Permissions required for 'read:tasks:task_1234' but token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
it("should grant access to multiple resources with super scope", () => {
expect(
checkAuthorization(
entityWithSuperPermissions,
"read",
{
tasks: "task_1234",
tags: ["tag_1", "tag_2"],
runs: "run_5678",
},
["read:all"]
)
).toBe(true);
const result = checkAuthorization(
entityWithSuperPermissions,
"read",
{
tasks: "task_1234",
tags: ["tag_1", "tag_2"],
runs: "run_5678",
},
["read:all"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should fall back to specific scope when super scope are not provided", () => {
@@ -183,12 +269,21 @@ describe("checkAuthorization", () => {
type: "PUBLIC_JWT",
scopes: ["read:tasks", "read:tags"],
};
expect(
checkAuthorization(entityWithSpecificPermissions, "read", { tasks: "task_1234" })
).toBe(true);
expect(checkAuthorization(entityWithSpecificPermissions, "read", { runs: "run_5678" })).toBe(
false
);
const result1 = checkAuthorization(entityWithSpecificPermissions, "read", {
tasks: "task_1234",
});
expect(result1.authorized).toBe(true);
expect(result1).not.toHaveProperty("reason");
const result2 = checkAuthorization(entityWithSpecificPermissions, "read", {
runs: "run_5678",
});
expect(result2.authorized).toBe(false);
if (!result2.authorized) {
expect(result2.reason).toBe(
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
@@ -199,21 +294,29 @@ describe("checkAuthorization", () => {
};
it("should still grant access based on specific scope", () => {
expect(
checkAuthorization(entityWithoutSuperPermissions, "read", { tasks: "task_1234" }, [
"read:all",
"admin",
])
).toBe(true);
const result = checkAuthorization(
entityWithoutSuperPermissions,
"read",
{ tasks: "task_1234" },
["read:all", "admin"]
);
expect(result.authorized).toBe(true);
expect(result).not.toHaveProperty("reason");
});
it("should deny access to resources not in scope", () => {
expect(
checkAuthorization(entityWithoutSuperPermissions, "read", { runs: "run_5678" }, [
"read:all",
"admin",
])
).toBe(false);
const result = checkAuthorization(
entityWithoutSuperPermissions,
"read",
{ runs: "run_5678" },
["read:all", "admin"]
);
expect(result.authorized).toBe(false);
if (!result.authorized) {
expect(result.reason).toBe(
"Public Access Token is missing required permissions. Permissions required for 'read:runs:run_5678' but token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
);
}
});
});
});
+152 -10
View File
@@ -64,7 +64,8 @@ describe("RealtimeClient", () => {
const initialResponsePromise = client.streamRun(
"http://localhost:3000?offset=-1",
environment,
run.id
run.id,
"0.8.1"
);
const initializeResponsePromise2 = new Promise<Response>((resolve) => {
@@ -72,7 +73,8 @@ describe("RealtimeClient", () => {
const response = await client.streamRun(
"http://localhost:3000?offset=-1",
environment,
run.id
run.id,
"0.8.1"
);
resolve(response);
@@ -86,8 +88,8 @@ describe("RealtimeClient", () => {
const headers = Object.fromEntries(response.headers.entries());
const shapeId = headers["electric-shape-id"];
const chunkOffset = headers["electric-chunk-last-offset"];
const shapeId = headers["electric-handle"];
const chunkOffset = headers["electric-offset"];
expect(response.status).toBe(200);
expect(response2.status).toBe(200);
@@ -96,17 +98,19 @@ describe("RealtimeClient", () => {
// Okay, now we will do two live requests, and the second one should fail because of the concurrency limit
const liveResponsePromise = client.streamRun(
`http://localhost:3000?offset=0_0&live=true&shape_id=${shapeId}`,
`http://localhost:3000?offset=0_0&live=true&handle=${shapeId}`,
environment,
run.id
run.id,
"0.8.1"
);
const liveResponsePromise2 = new Promise<Response>((resolve) => {
setTimeout(async () => {
const response = await client.streamRun(
`http://localhost:3000?offset=0_0&live=true&shape_id=${shapeId}`,
`http://localhost:3000?offset=0_0&live=true&handle=${shapeId}`,
environment,
run.id
run.id,
"0.8.1"
);
resolve(response);
@@ -194,18 +198,156 @@ describe("RealtimeClient", () => {
},
});
const response = await client.streamRuns("http://localhost:3000?offset=-1", environment, {
tags: ["test:tag:1234"],
const response = await client.streamRuns(
"http://localhost:3000?offset=-1",
environment,
{
tags: ["test:tag:1234"],
},
"0.8.1"
);
const headers = Object.fromEntries(response.headers.entries());
const shapeId = headers["electric-handle"];
const chunkOffset = headers["electric-offset"];
expect(response.status).toBe(200);
expect(shapeId).toBeDefined();
expect(chunkOffset).toBe("0_0");
}
);
containerWithElectricTest(
"Should adapt for older client versions",
{ timeout: 30_000 },
async ({ redis, electricOrigin, prisma }) => {
const client = new RealtimeClient({
electricOrigin,
keyPrefix: "test:realtime",
redis: redis.options,
expiryTimeInSeconds: 5,
cachedLimitProvider: {
async getCachedLimit() {
return 1;
},
},
});
const organization = await prisma.organization.create({
data: {
title: "test-org",
slug: "test-org",
},
});
const project = await prisma.project.create({
data: {
name: "test-project",
slug: "test-project",
organizationId: organization.id,
externalRef: "test-project",
},
});
const environment = await prisma.runtimeEnvironment.create({
data: {
projectId: project.id,
organizationId: organization.id,
slug: "test",
type: "DEVELOPMENT",
shortcode: "1234",
apiKey: "tr_dev_1234",
pkApiKey: "pk_test_1234",
},
});
const run = await prisma.taskRun.create({
data: {
taskIdentifier: "test-task",
friendlyId: "run_1234",
payload: "{}",
payloadType: "application/json",
traceId: "trace_1234",
spanId: "span_1234",
queue: "test-queue",
projectId: project.id,
runtimeEnvironmentId: environment.id,
},
});
const initialResponsePromise = client.streamRun(
"http://localhost:3000?offset=-1",
environment,
run.id
);
const initializeResponsePromise2 = new Promise<Response>((resolve) => {
setTimeout(async () => {
const response = await client.streamRun(
"http://localhost:3000?offset=-1",
environment,
run.id,
"0.8.1"
);
resolve(response);
}, 1);
});
const [response, response2] = await Promise.all([
initialResponsePromise,
initializeResponsePromise2,
]);
const headers = Object.fromEntries(response.headers.entries());
const shapeId = headers["electric-shape-id"];
const chunkOffset = headers["electric-chunk-last-offset"];
expect(response.status).toBe(200);
expect(response2.status).toBe(200);
expect(shapeId).toBeDefined();
expect(chunkOffset).toBe("0_0");
// Okay, now we will do two live requests, and the second one should fail because of the concurrency limit
const liveResponsePromise = client.streamRun(
`http://localhost:3000?offset=0_0&live=true&shape_id=${shapeId}`,
environment,
run.id
);
const liveResponsePromise2 = new Promise<Response>((resolve) => {
setTimeout(async () => {
const response = await client.streamRun(
`http://localhost:3000?offset=0_0&live=true&shape_id=${shapeId}`,
environment,
run.id
);
resolve(response);
}, 1);
});
const updateRunAfter1SecondPromise = new Promise<void>((resolve) => {
setTimeout(async () => {
await prisma.taskRun.update({
where: { id: run.id },
data: { metadata: "{}" },
});
resolve();
}, 1000);
});
const [liveResponse, liveResponse2] = await Promise.all([
liveResponsePromise,
liveResponsePromise2,
updateRunAfter1SecondPromise,
]);
expect(liveResponse.status).toBe(200);
expect(liveResponse2.status).toBe(429);
}
);
});
+106
View File
@@ -0,0 +1,106 @@
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"]);
});
});
+46
View File
@@ -0,0 +1,46 @@
export async function convertResponseStreamToArray(response: Response): Promise<string[]> {
return convertReadableStreamToArray(response.body!.pipeThrough(new TextDecoderStream()));
}
export async function convertResponseSSEStreamToArray(response: Response): Promise<string[]> {
const parseSSEDataTransform = new TransformStream<string>({
async transform(chunk, controller) {
for (const line of chunk.split("\n")) {
if (line.startsWith("data:")) {
controller.enqueue(line.slice(6));
}
}
},
});
return convertReadableStreamToArray(
response.body!.pipeThrough(new TextDecoderStream()).pipeThrough(parseSSEDataTransform)
);
}
export async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promise<T[]> {
const reader = stream.getReader();
const result: T[] = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
result.push(value);
}
return result;
}
export function convertArrayToReadableStream<T>(values: T[]): ReadableStream<T> {
return new ReadableStream({
start(controller) {
try {
for (const value of values) {
controller.enqueue(value);
}
} finally {
controller.close();
}
},
});
}
+1 -1
View File
@@ -61,7 +61,7 @@ services:
- 6379:6379
electric:
image: electricsql/electric:0.7.5
image: electricsql/electric:0.8.1
restart: always
environment:
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres?sslmode=disable
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BackgroundWorkerTask" ADD COLUMN "description" TEXT;
@@ -1610,6 +1610,8 @@ model BackgroundWorkerTask {
id String @id @default(cuid())
slug String
description String?
friendlyId String @unique
filePath String
@@ -55,7 +55,7 @@ export async function createElectricContainer(
network.getName()
)}:5432/${postgresContainer.getDatabase()}?sslmode=disable`;
const container = await new GenericContainer("electricsql/electric:0.7.5")
const container = await new GenericContainer("electricsql/electric:0.8.1")
.withExposedPorts(3000)
.withNetwork(network)
.withEnvironment({
+6
View File
@@ -184,6 +184,12 @@ function applyLayerToManifest(layer: BuildLayer, manifest: BuildManifest): Build
}
}
if (layer.conditions) {
$manifest.customConditions ??= [];
$manifest.customConditions = $manifest.customConditions.concat(layer.conditions);
$manifest.customConditions = Array.from(new Set($manifest.customConditions));
}
return $manifest;
}
+5 -1
View File
@@ -142,7 +142,11 @@ async function resolveConfig(
const lockfilePath = await resolveLockfile(cwd);
const workspaceDir = await findWorkspaceDir(cwd);
const workingDir = packageJsonPath ? dirname(packageJsonPath) : cwd;
const workingDir = result.configFile
? dirname(result.configFile)
: packageJsonPath
? dirname(packageJsonPath)
: cwd;
const config =
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;
@@ -15,6 +15,8 @@ import {
ExecutorToWorkerMessageCatalog,
timeout,
runMetadata,
waitUntil,
apiClientManager,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import { ProdRuntimeManager } from "@trigger.dev/core/v3/prod";
@@ -34,6 +36,7 @@ import {
usage,
UsageTimeoutManager,
StandardMetadataManager,
StandardWaitUntilManager,
} from "@trigger.dev/core/v3/workers";
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
import { readFile } from "node:fs/promises";
@@ -100,8 +103,18 @@ timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager));
taskCatalog.setGlobalTaskCatalog(new StandardTaskCatalog());
const durableClock = new DurableClock();
clock.setGlobalClock(durableClock);
const runMetadataManager = new StandardMetadataManager();
const runMetadataManager = new StandardMetadataManager(
apiClientManager.clientOrThrow(),
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev"
);
runMetadata.setGlobalManager(runMetadataManager);
const waitUntilManager = new StandardWaitUntilManager();
waitUntil.setGlobalManager(waitUntilManager);
// Wait for all streams to finish before completing the run
waitUntil.register({
requiresResolving: () => runMetadataManager.hasActiveStreams(),
promise: () => runMetadataManager.waitForAllStreams(),
});
const triggerLogLevel = getEnvVar("TRIGGER_LOG_LEVEL");
@@ -308,6 +321,8 @@ const zodIpc = new ZodIpcConnection({
_execution = execution;
_isRunning = true;
runMetadataManager.runId = execution.run.id;
runMetadataManager.startPeriodicFlush(
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
@@ -15,6 +15,8 @@ import {
ExecutorToWorkerMessageCatalog,
timeout,
runMetadata,
waitUntil,
apiClientManager,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import { DevRuntimeManager } from "@trigger.dev/core/v3/dev";
@@ -33,6 +35,7 @@ import {
usage,
getNumberEnvVar,
StandardMetadataManager,
StandardWaitUntilManager,
} from "@trigger.dev/core/v3/workers";
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
import { readFile } from "node:fs/promises";
@@ -82,8 +85,18 @@ usage.setGlobalUsageManager(devUsageManager);
const devRuntimeManager = new DevRuntimeManager();
runtime.setGlobalRuntimeManager(devRuntimeManager);
timeout.setGlobalManager(new UsageTimeoutManager(devUsageManager));
const runMetadataManager = new StandardMetadataManager();
const runMetadataManager = new StandardMetadataManager(
apiClientManager.clientOrThrow(),
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev"
);
runMetadata.setGlobalManager(runMetadataManager);
const waitUntilManager = new StandardWaitUntilManager();
waitUntil.setGlobalManager(waitUntilManager);
// Wait for all streams to finish before completing the run
waitUntil.register({
requiresResolving: () => runMetadataManager.hasActiveStreams(),
promise: () => runMetadataManager.waitForAllStreams(),
});
const triggerLogLevel = getEnvVar("TRIGGER_LOG_LEVEL");
@@ -278,6 +291,8 @@ const zodIpc = new ZodIpcConnection({
_execution = execution;
_isRunning = true;
runMetadataManager.runId = execution.run.id;
runMetadataManager.startPeriodicFlush(
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
+5 -1
View File
@@ -182,7 +182,7 @@
"check-exports": "attw --pack ."
},
"dependencies": {
"@electric-sql/client": "0.6.3",
"@electric-sql/client": "0.7.1",
"@google-cloud/precise-date": "^4.0.0",
"@jsonhero/path": "^1.0.21",
"@opentelemetry/api": "1.9.0",
@@ -197,6 +197,7 @@
"@opentelemetry/sdk-trace-node": "1.25.1",
"@opentelemetry/semantic-conventions": "1.25.1",
"dequal": "^2.0.3",
"eventsource-parser": "^3.0.0",
"execa": "^8.0.1",
"humanize-duration": "^3.27.3",
"jose": "^5.4.0",
@@ -208,10 +209,13 @@
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@ai-sdk/provider-utils": "^1.0.22",
"@arethetypeswrong/cli": "^0.15.4",
"@epic-web/test-server": "^0.1.0",
"@types/humanize-duration": "^3.27.1",
"@types/node": "20.14.14",
"@types/readable-stream": "^4.0.14",
"ai": "^3.4.33",
"defu": "^6.1.4",
"esbuild": "^0.23.0",
"rimraf": "^3.0.2",
+37 -4
View File
@@ -45,6 +45,7 @@ import {
RunStreamCallback,
RunSubscription,
TaskRunShape,
RealtimeRun,
} from "./runStream.js";
import {
CreateEnvironmentVariableParams,
@@ -88,7 +89,14 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
export { isRequestOptions };
export type { ApiRequestOptions };
export type { RunShape, AnyRunShape, TaskRunShape, RunStreamCallback, RunSubscription };
export type {
RunShape,
AnyRunShape,
TaskRunShape,
RealtimeRun,
RunStreamCallback,
RunSubscription,
};
/**
* Trigger.dev v3 API client
@@ -182,6 +190,15 @@ export class ApiClient {
)
.withResponse()
.then(async ({ response, data }) => {
const jwtHeader = response.headers.get("x-trigger-jwt");
if (typeof jwtHeader === "string") {
return {
...data,
publicAccessToken: jwtHeader,
};
}
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
@@ -594,14 +611,19 @@ export class ApiClient {
);
}
subscribeToRun<TRunTypes extends AnyRunTypes>(runId: string) {
subscribeToRun<TRunTypes extends AnyRunTypes>(runId: string, options?: { signal?: AbortSignal }) {
return runShapeStream<TRunTypes>(`${this.baseUrl}/realtime/v1/runs/${runId}`, {
closeOnComplete: true,
headers: this.#getRealtimeHeaders(),
client: this,
signal: options?.signal,
});
}
subscribeToRunsWithTag<TRunTypes extends AnyRunTypes>(tag: string | string[]) {
subscribeToRunsWithTag<TRunTypes extends AnyRunTypes>(
tag: string | string[],
options?: { signal?: AbortSignal }
) {
const searchParams = createSearchQueryForSubscribeToRuns({
tags: tag,
});
@@ -611,14 +633,21 @@ export class ApiClient {
{
closeOnComplete: false,
headers: this.#getRealtimeHeaders(),
client: this,
signal: options?.signal,
}
);
}
subscribeToBatch<TRunTypes extends AnyRunTypes>(batchId: string) {
subscribeToBatch<TRunTypes extends AnyRunTypes>(
batchId: string,
options?: { signal?: AbortSignal }
) {
return runShapeStream<TRunTypes>(`${this.baseUrl}/realtime/v1/batches/${batchId}`, {
closeOnComplete: false,
headers: this.#getRealtimeHeaders(),
client: this,
signal: options?.signal,
});
}
@@ -650,6 +679,10 @@ export class ApiClient {
}
}
if (typeof window !== "undefined" && typeof window.document !== "undefined") {
headers["x-trigger-client"] = "browser";
}
return headers;
}
+179 -25
View File
@@ -2,12 +2,15 @@ import { DeserializedJson } from "../../schemas/json.js";
import { RunStatus, 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";
import {
conditionallyImportAndParsePacket,
IOPacket,
parsePacket,
} from "../utils/ioSerialization.js";
import { ApiClient } from "./index.js";
import { AsyncIterableStream, createAsyncIterableStream, zodShapeStream } from "./stream.js";
import { EventSourceParserStream } from "eventsource-parser/stream";
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
? {
@@ -39,6 +42,7 @@ export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTy
export type AnyRunShape = RunShape<AnyRunTypes>;
export type TaskRunShape<TTask extends AnyTask> = RunShape<InferRunTypes<TTask>>;
export type RealtimeRun<TTask extends AnyTask> = TaskRunShape<TTask>;
export type RunStreamCallback<TRunTypes extends AnyRunTypes> = (
run: RunShape<TRunTypes>
@@ -48,49 +52,148 @@ export type RunShapeStreamOptions = {
headers?: Record<string, string>;
fetchClient?: typeof fetch;
closeOnComplete?: boolean;
signal?: AbortSignal;
client?: ApiClient;
};
export type StreamPartResult<TRun, TStreams extends Record<string, any>> = {
[K in keyof TStreams]: {
type: K;
chunk: TStreams[K];
run: TRun;
};
}[keyof TStreams];
export type RunWithStreamsResult<TRun, TStreams extends Record<string, any>> =
| {
type: "run";
run: TRun;
}
| StreamPartResult<TRun, TStreams>;
export function runShapeStream<TRunTypes extends AnyRunTypes>(
url: string,
options?: RunShapeStreamOptions
): RunSubscription<TRunTypes> {
return new RunSubscription<TRunTypes>(url, options);
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,
}
),
...options,
};
return new RunSubscription<TRunTypes>($options);
}
// First, define interfaces for the stream handling
export interface StreamSubscription {
subscribe(onChunk: (chunk: unknown) => Promise<void>): Promise<() => void>;
}
export interface StreamSubscriptionFactory {
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription;
}
// Real implementation for production
export class SSEStreamSubscription implements StreamSubscription {
constructor(
private url: string,
private options: { headers?: Record<string, string>; signal?: AbortSignal }
) {}
async subscribe(onChunk: (chunk: unknown) => Promise<void>): Promise<() => void> {
const response = await fetch(this.url, {
headers: {
Accept: "text/event-stream",
...this.options.headers,
},
signal: this.options.signal,
});
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.pipeThrough(new EventSourceParserStream())
.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
await onChunk(safeParseJSON(value.data));
}
return () => reader.cancel();
}
}
export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory {
constructor(
private baseUrl: string,
private options: { headers?: Record<string, string>; signal?: AbortSignal }
) {}
createSubscription(runId: string, streamKey: string, baseUrl?: string): StreamSubscription {
if (!runId || !streamKey) {
throw new Error("runId and streamKey are required");
}
const url = `${baseUrl ?? this.baseUrl}/realtime/v1/streams/${runId}/${streamKey}`;
return new SSEStreamSubscription(url, this.options);
}
}
export interface RunShapeProvider {
onShape(callback: (shape: SubscribeRunRawShape) => Promise<void>): Promise<() => void>;
}
export type RunSubscriptionOptions = RunShapeStreamOptions & {
provider: RunShapeProvider;
streamFactory: StreamSubscriptionFactory;
};
export class RunSubscription<TRunTypes extends AnyRunTypes> {
private abortController: AbortController;
private unsubscribeShape?: () => void;
private stream: AsyncIterableStream<RunShape<TRunTypes>>;
private packetCache = new Map<string, any>();
private _closeOnComplete: boolean;
private _isRunComplete = false;
constructor(
private url: string,
private options?: RunShapeStreamOptions
) {
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 zodShapeStream(
SubscribeRunRawShape,
this.url,
async (shape) => {
controller.enqueue(shape);
if (
this.options?.closeOnComplete &&
shape.completedAt &&
!this.abortController.signal.aborted
) {
controller.close();
this.abortController.abort();
}
},
{
signal: this.abortController.signal,
fetchClient: this.options?.fetchClient,
headers: this.options?.headers,
this.unsubscribeShape = await this.options.provider.onShape(async (shape) => {
controller.enqueue(shape);
this._isRunComplete = !!shape.completedAt;
if (
this._closeOnComplete &&
this._isRunComplete &&
!this.abortController.signal.aborted
) {
controller.close();
this.abortController.abort();
}
);
});
},
cancel: () => {
this.unsubscribe();
@@ -121,6 +224,49 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
return this.stream.getReader();
}
withStreams<TStreams extends Record<string, any>>(): AsyncIterableStream<
RunWithStreamsResult<RunShape<TRunTypes>, TStreams>
> {
// 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,
});
// 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);
const subscription = this.options.streamFactory.createSubscription(
run.id,
streamKey,
this.options.client?.baseUrl
);
await subscription.subscribe(async (chunk) => {
controller.enqueue({
type: streamKey,
chunk: chunk as TStreams[typeof streamKey],
run,
} as StreamPartResult<RunShape<TRunTypes>, TStreams>);
});
}
}
}
},
});
}
private async transformRunShape(row: SubscribeRunRawShape): Promise<RunShape<TRunTypes>> {
const payloadPacket = row.payloadType
? ({ data: row.payload ?? undefined, dataType: row.payloadType } satisfies IOPacket)
@@ -145,7 +291,7 @@ export class RunSubscription<TRunTypes extends AnyRunTypes> {
return cachedResult;
}
const result = await conditionallyImportAndParsePacket(packet);
const result = await conditionallyImportAndParsePacket(packet, this.options.client);
this.packetCache.set(`${row.friendlyId}/${key}`, result);
return result;
@@ -230,3 +376,11 @@ function apiStatusFromRunStatus(status: string): RunStatus {
}
}
}
function safeParseJSON(data: string): unknown {
try {
return JSON.parse(data);
} catch (error) {
return data;
}
}
+23 -11
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { ApiError } from "./errors.js";
export type ZodShapeStreamOptions = {
headers?: Record<string, string>;
@@ -12,28 +13,39 @@ export async function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
callback: (shape: z.output<TShapeSchema>) => void | Promise<void>,
options?: ZodShapeStreamOptions
) {
const { ShapeStream, Shape } = await import("@electric-sql/client");
const { ShapeStream, Shape, FetchError } = await import("@electric-sql/client");
const stream = new ShapeStream<z.input<TShapeSchema>>({
url,
headers: options?.headers,
headers: {
...options?.headers,
"x-trigger-electric-version": "0.8.1",
},
fetchClient: options?.fetchClient,
signal: options?.signal,
});
const shape = new Shape(stream);
try {
const shape = new Shape(stream);
const initialValue = await shape.value;
const initialRows = await shape.rows;
for (const shapeRow of initialValue.values()) {
await callback(schema.parse(shapeRow));
}
return shape.subscribe(async (newShape) => {
for (const shapeRow of newShape.values()) {
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;
}
}
}
export type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
+1
View File
@@ -65,6 +65,7 @@ export interface BuildLayer {
override?: boolean;
};
dependencies?: Record<string, string>;
conditions?: string[];
}
export type PluginPlacement = "first" | "last";
+100
View File
@@ -0,0 +1,100 @@
import { taskContext } from "./task-context-api.js";
import { IdempotencyKey } from "./types/idempotencyKeys.js";
export function isIdempotencyKey(
value: string | string[] | IdempotencyKey
): value is IdempotencyKey {
// Cannot check the brand at runtime because it doesn't exist (it's a TypeScript-only construct)
return typeof value === "string" && value.length === 64;
}
export async function makeIdempotencyKey(
idempotencyKey?: IdempotencyKey | string | string[]
): Promise<IdempotencyKey | undefined> {
if (!idempotencyKey) {
return;
}
if (isIdempotencyKey(idempotencyKey)) {
return idempotencyKey;
}
return await createIdempotencyKey(idempotencyKey, { scope: "global" });
}
/**
* Creates a deterministic idempotency key based on the provided key material.
*
* If running inside a task, the task run ID is automatically included in the key material, giving you a unique key per task run.
* This ensures that a given child task is only triggered once per task run, even if the parent task is retried.
*
* @param {string | string[]} key The key material to create the idempotency key from.
* @param {object} [options] Additional options.
* @param {"run" | "attempt" | "global"} [options.scope="run"] The scope of the idempotency key.
*
* @returns {Promise<IdempotencyKey>} The idempotency key as a branded string.
*
* @example
*
* ```typescript
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
*
* export const myTask = task({
* id: "my-task",
* run: async (payload: any) => {
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
*
* // Use the idempotency key when triggering child tasks
* await childTask.triggerAndWait(payload, { idempotencyKey });
* }
* });
* ```
*
* You can also use the `scope` parameter to create a key that is unique per task run, task run attempts (retries of the same run), or globally:
*
* ```typescript
* await idempotencyKeys.create("my-task-key", { scope: "attempt" }); // Creates a key that is unique per task run attempt
* await idempotencyKeys.create("my-task-key", { scope: "global" }); // Skips including the task run ID
* ```
*/
export async function createIdempotencyKey(
key: string | string[],
options?: { scope?: "run" | "attempt" | "global" }
): Promise<IdempotencyKey> {
const idempotencyKey = await generateIdempotencyKey(
[...(Array.isArray(key) ? key : [key])].concat(injectScope(options?.scope ?? "run"))
);
return idempotencyKey as IdempotencyKey;
}
function injectScope(scope: "run" | "attempt" | "global"): string[] {
switch (scope) {
case "run": {
if (taskContext?.ctx) {
return [taskContext.ctx.run.id];
}
break;
}
case "attempt": {
if (taskContext?.ctx) {
return [taskContext.ctx.attempt.id];
}
break;
}
}
return [];
}
async function generateIdempotencyKey(keyMaterial: string[]) {
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(keyMaterial.join("-"))
);
// Return a hex string, using cross-runtime compatible methods
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
+2
View File
@@ -12,6 +12,7 @@ export * from "./task-context-api.js";
export * from "./apiClientManager-api.js";
export * from "./usage-api.js";
export * from "./run-metadata-api.js";
export * from "./wait-until-api.js";
export * from "./timeout-api.js";
export * from "./schemas/index.js";
export { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
@@ -19,6 +20,7 @@ export * from "./task-catalog-api.js";
export * from "./types/index.js";
export { links } from "./links.js";
export * from "./jwt.js";
export * from "./idempotencyKeys.js";
export {
formatDuration,
formatDurationInDays,
+30 -5
View File
@@ -1,3 +1,5 @@
import type { JWTPayload } from "jose";
export type GenerateJWTOptions = {
secretKey: string;
payload: Record<string, any>;
@@ -22,8 +24,19 @@ export async function generateJWT(options: GenerateJWTOptions): Promise<string>
.sign(secret);
}
export async function validateJWT(token: string, apiKey: string) {
const { jwtVerify } = await import("jose");
export type ValidationResult =
| {
ok: true;
payload: JWTPayload;
}
| {
ok: false;
error: string;
code: string;
};
export async function validateJWT(token: string, apiKey: string): Promise<ValidationResult> {
const { jwtVerify, errors } = await import("jose");
const secret = new TextEncoder().encode(apiKey);
@@ -33,8 +46,20 @@ export async function validateJWT(token: string, apiKey: string) {
audience: JWT_AUDIENCE,
});
return payload;
} catch (e) {
return;
return { ok: true, payload };
} catch (error) {
if (error instanceof errors.JOSEError) {
return {
ok: false,
error: error.message,
code: error.code,
};
} else {
return {
ok: false,
error: error instanceof Error ? error.message : "Unknown error",
code: "ERR_UNKNOWN",
};
}
}
}
+24
View File
@@ -49,10 +49,34 @@ export class RunMetadataAPI implements RunMetadataManager {
return this.#getManager().deleteKey(key);
}
public incrementKey(key: string, value: number): void {
return this.#getManager().incrementKey(key, value);
}
decrementKey(key: string, value: number): void {
return this.#getManager().decrementKey(key, value);
}
appendKey(key: string, value: DeserializedJson): void {
this.#getManager().appendKey(key, value);
}
removeFromKey(key: string, value: DeserializedJson): void {
this.#getManager().removeFromKey(key, value);
}
public update(metadata: Record<string, DeserializedJson>): void {
return this.#getManager().update(metadata);
}
public stream<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
signal?: AbortSignal
): Promise<AsyncIterable<T>> {
return this.#getManager().stream(key, value, signal);
}
flush(requestOptions?: ApiRequestOptions): Promise<void> {
return this.#getManager().flush(requestOptions);
}
+211 -17
View File
@@ -1,15 +1,27 @@
import { JSONHeroPath } from "@jsonhero/path";
import { dequal } from "dequal/lite";
import { DeserializedJson } from "../../schemas/json.js";
import { apiClientManager } from "../apiClientManager-api.js";
import { taskContext } from "../task-context-api.js";
import { ApiRequestOptions } from "../zodfetch.js";
import { RunMetadataManager } from "./types.js";
import { MetadataStream } from "./metadataStream.js";
import { ApiClient } from "../apiClient/index.js";
const MAXIMUM_ACTIVE_STREAMS = 2;
const MAXIMUM_TOTAL_STREAMS = 5;
export class StandardMetadataManager implements RunMetadataManager {
private flushTimeoutId: NodeJS.Timeout | null = null;
private hasChanges: boolean = false;
private store: Record<string, DeserializedJson> | undefined;
// Add a Map to track active streams
private activeStreams = new Map<string, MetadataStream<any>>();
public runId: string | undefined;
constructor(
private apiClient: ApiClient,
private streamsBaseUrl: string
) {}
public enterWithMetadata(metadata: Record<string, DeserializedJson>): void {
this.store = metadata ?? {};
@@ -24,9 +36,7 @@ export class StandardMetadataManager implements RunMetadataManager {
}
public setKey(key: string, value: DeserializedJson) {
const runId = taskContext.ctx?.run.id;
if (!runId) {
if (!this.runId) {
return;
}
@@ -56,9 +66,7 @@ export class StandardMetadataManager implements RunMetadataManager {
}
public deleteKey(key: string) {
const runId = taskContext.ctx?.run.id;
if (!runId) {
if (!this.runId) {
return;
}
@@ -72,10 +80,115 @@ export class StandardMetadataManager implements RunMetadataManager {
this.store = nextStore;
}
public update(metadata: Record<string, DeserializedJson>): void {
const runId = taskContext.ctx?.run.id;
public appendKey(key: string, value: DeserializedJson) {
if (!this.runId) {
return;
}
if (!runId) {
let nextStore: Record<string, DeserializedJson> | undefined = this.store
? structuredClone(this.store)
: {};
if (key.startsWith("$.")) {
const path = new JSONHeroPath(key);
const currentValue = path.first(nextStore);
if (currentValue === undefined) {
// Initialize as array with single item
path.set(nextStore, [value]);
} else if (Array.isArray(currentValue)) {
// Append to existing array
path.set(nextStore, [...currentValue, value]);
} else {
// Convert to array if not already
path.set(nextStore, [currentValue, value]);
}
} else {
const currentValue = nextStore[key];
if (currentValue === undefined) {
// Initialize as array with single item
nextStore[key] = [value];
} else if (Array.isArray(currentValue)) {
// Append to existing array
nextStore[key] = [...currentValue, value];
} else {
// Convert to array if not already
nextStore[key] = [currentValue, value];
}
}
if (!dequal(this.store, nextStore)) {
this.hasChanges = true;
}
this.store = nextStore;
}
public removeFromKey(key: string, value: DeserializedJson) {
if (!this.runId) {
return;
}
let nextStore: Record<string, DeserializedJson> | undefined = this.store
? structuredClone(this.store)
: {};
if (key.startsWith("$.")) {
const path = new JSONHeroPath(key);
const currentValue = path.first(nextStore);
if (Array.isArray(currentValue)) {
// Remove the value from array using deep equality check
const newArray = currentValue.filter((item) => !dequal(item, value));
path.set(nextStore, newArray);
}
} else {
const currentValue = nextStore[key];
if (Array.isArray(currentValue)) {
// Remove the value from array using deep equality check
nextStore[key] = currentValue.filter((item) => !dequal(item, value));
}
}
if (!dequal(this.store, nextStore)) {
this.hasChanges = true;
}
this.store = nextStore;
}
public incrementKey(key: string, increment: number = 1) {
if (!this.runId) {
return;
}
let nextStore = this.store ? structuredClone(this.store) : {};
let currentValue = key.startsWith("$.")
? new JSONHeroPath(key).first(nextStore)
: nextStore[key];
const newValue = (typeof currentValue === "number" ? currentValue : 0) + increment;
if (key.startsWith("$.")) {
new JSONHeroPath(key).set(nextStore, newValue);
} else {
nextStore[key] = newValue;
}
if (!dequal(this.store, nextStore)) {
this.hasChanges = true;
this.store = nextStore;
}
}
public decrementKey(key: string, decrement: number = 1) {
this.incrementKey(key, -decrement);
}
public update(metadata: Record<string, DeserializedJson>): void {
if (!this.runId) {
return;
}
@@ -86,10 +199,93 @@ export class StandardMetadataManager implements RunMetadataManager {
this.store = metadata;
}
public async flush(requestOptions?: ApiRequestOptions): Promise<void> {
const runId = taskContext.ctx?.run.id;
public async stream<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
signal?: AbortSignal
): Promise<AsyncIterable<T>> {
const $value = value as AsyncIterable<T>;
if (!runId) {
if (!this.runId) {
return $value;
}
// Check to make sure we haven't exceeded the max number of active streams
if (this.activeStreams.size >= MAXIMUM_ACTIVE_STREAMS) {
console.warn(
`Exceeded the maximum number of active streams (${MAXIMUM_ACTIVE_STREAMS}). The "${key}" stream will be ignored.`
);
return $value;
}
// Check to make sure we haven't exceeded the max number of total streams
const streams = (this.store?.$$streams ?? []) as string[];
if (streams.length >= MAXIMUM_TOTAL_STREAMS) {
console.warn(
`Exceeded the maximum number of total streams (${MAXIMUM_TOTAL_STREAMS}). The "${key}" stream will be ignored.`
);
return $value;
}
try {
// Add the key to the special stream metadata object
this.appendKey(`$$streams`, key);
await this.flush();
const streamInstance = new MetadataStream({
key,
runId: this.runId,
iterator: $value[Symbol.asyncIterator](),
baseUrl: this.streamsBaseUrl,
signal,
});
this.activeStreams.set(key, streamInstance);
// Clean up when stream completes
streamInstance.wait().finally(() => this.activeStreams.delete(key));
return streamInstance;
} catch (error) {
// Clean up metadata key if stream creation fails
this.deleteKey(`$$stream.${key}`);
throw error;
}
}
public hasActiveStreams(): boolean {
return this.activeStreams.size > 0;
}
// Waits for all the streams to finish
public async waitForAllStreams(timeout: number = 60_000): Promise<void> {
if (this.activeStreams.size === 0) {
return;
}
const promises = Array.from(this.activeStreams.values());
try {
await Promise.race([
Promise.allSettled(promises),
new Promise<void>((resolve, _) => setTimeout(() => resolve(), timeout)),
]);
} catch (error) {
console.error("Error waiting for streams to finish:", error);
// If we time out, abort all remaining streams
for (const [key, promise] of this.activeStreams.entries()) {
// We can add abort logic here if needed
this.activeStreams.delete(key);
}
throw error;
}
}
public async flush(requestOptions?: ApiRequestOptions): Promise<void> {
if (!this.runId) {
return;
}
@@ -101,11 +297,9 @@ export class StandardMetadataManager implements RunMetadataManager {
return;
}
const apiClient = apiClientManager.clientOrThrow();
try {
this.hasChanges = false;
await apiClient.updateRunMetadata(runId, { metadata: this.store }, requestOptions);
await this.apiClient.updateRunMetadata(this.runId, { metadata: this.store }, requestOptions);
} catch (error) {
this.hasChanges = true;
throw error;
@@ -0,0 +1,84 @@
export type MetadataOptions<T> = {
baseUrl: string;
runId: string;
key: string;
iterator: AsyncIterator<T>;
signal?: AbortSignal;
};
export class MetadataStream<T> {
private controller = new AbortController();
private serverQueue: Array<Promise<IteratorResult<T>>> = [];
private consumerQueue: Array<Promise<IteratorResult<T>>> = [];
private serverIterator: AsyncIterator<T>;
private consumerIterator: AsyncIterator<T>;
private streamPromise: Promise<void | Response>;
constructor(private options: MetadataOptions<T>) {
const { serverIterator, consumerIterator } = this.createTeeIterators();
this.serverIterator = serverIterator;
this.consumerIterator = consumerIterator;
this.streamPromise = this.initializeServerStream();
}
private createTeeIterators() {
const teeIterator = (queue: Array<Promise<IteratorResult<T>>>): AsyncIterator<T> => ({
next: () => {
if (queue.length === 0) {
const result = this.options.iterator.next();
this.serverQueue.push(result);
this.consumerQueue.push(result);
}
return queue.shift()!;
},
});
return {
serverIterator: teeIterator(this.serverQueue),
consumerIterator: teeIterator(this.consumerQueue),
};
}
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 {
const { value, done } = await serverIterator.next();
if (done) {
controller.close();
return;
}
controller.enqueue(JSON.stringify(value) + "\n");
} catch (err) {
controller.error(err);
}
},
cancel: () => this.controller.abort(),
});
return fetch(
`${this.options.baseUrl}/realtime/v1/streams/${this.options.runId}/${this.options.key}`,
{
method: "POST",
headers: {},
body: serverStream,
// @ts-expect-error
duplex: "half",
signal: this.controller.signal,
}
);
}
public async wait(): Promise<void> {
return this.streamPromise.then(() => void 0);
}
public [Symbol.asyncIterator]() {
return this.consumerIterator;
}
}
@@ -3,6 +3,21 @@ import { ApiRequestOptions } from "../zodfetch.js";
import type { RunMetadataManager } from "./types.js";
export class NoopRunMetadataManager implements RunMetadataManager {
appendKey(key: string, value: DeserializedJson): void {
throw new Error("Method not implemented.");
}
removeFromKey(key: string, value: DeserializedJson): void {
throw new Error("Method not implemented.");
}
incrementKey(key: string, value: number): void {
throw new Error("Method not implemented.");
}
decrementKey(key: string, value: number): void {
throw new Error("Method not implemented.");
}
stream<T>(key: string, value: AsyncIterable<T>): Promise<AsyncIterable<T>> {
throw new Error("Method not implemented.");
}
flush(requestOptions?: ApiRequestOptions): Promise<void> {
throw new Error("Method not implemented.");
}
@@ -8,6 +8,15 @@ export interface RunMetadataManager {
getKey(key: string): DeserializedJson | undefined;
setKey(key: string, value: DeserializedJson): void;
deleteKey(key: string): void;
appendKey(key: string, value: DeserializedJson): void;
removeFromKey(key: string, value: DeserializedJson): void;
incrementKey(key: string, value: number): void;
decrementKey(key: string, value: number): void;
update(metadata: Record<string, DeserializedJson>): void;
flush(requestOptions?: ApiRequestOptions): Promise<void>;
stream<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
signal?: AbortSignal
): Promise<AsyncIterable<T>>;
}
@@ -1,11 +1,10 @@
import {
BatchTaskRunExecutionResult,
TaskRunContext,
TaskRunExecution,
TaskRunExecutionResult,
} from "../schemas/index.js";
import { RuntimeManager } from "./manager.js";
import { unboundedTimeout } from "../utils/timers.js";
import { RuntimeManager } from "./manager.js";
export class DevRuntimeManager implements RuntimeManager {
_taskWaits: Map<string, { resolve: (value: TaskRunExecutionResult) => void }> = new Map();
@@ -4,6 +4,7 @@ import { MachineConfig } from "./common.js";
export const TaskResource = z.object({
id: z.string(),
description: z.string().optional(),
filePath: z.string(),
exportName: z.string(),
queue: QueueOptions.optional(),
+1
View File
@@ -149,6 +149,7 @@ export const ScheduleMetadata = z.object({
const taskMetadata = {
id: z.string(),
description: z.string().optional(),
queue: QueueOptions.optional(),
retry: RetryOptions.optional(),
machine: MachineConfig.optional(),
+1
View File
@@ -5,6 +5,7 @@ import { Prettify } from "./utils.js";
export * from "./utils.js";
export * from "./tasks.js";
export * from "./idempotencyKeys.js";
export * from "./tools.js";
type ResolveEnvironmentVariablesOptions = {
variables: Record<string, string> | Array<{ name: string; value: string }>;
+42 -7
View File
@@ -1,7 +1,8 @@
import type { Schema as AISchema } from "ai";
import { z } from "zod";
import { SerializableJson } from "../../schemas/json.js";
import { TriggerApiRequestOptions } from "../apiClient/index.js";
import { RunTags } from "../schemas/api.js";
import { QueueOptions } from "../schemas/schemas.js";
import { IdempotencyKey } from "./idempotencyKeys.js";
import {
MachineCpu,
MachineMemory,
@@ -9,9 +10,11 @@ import {
TaskMetadata,
TaskRunContext,
} from "../schemas/index.js";
import { QueueOptions } from "../schemas/schemas.js";
import { IdempotencyKey } from "./idempotencyKeys.js";
import { AnySchemaParseFn, inferSchemaIn, inferSchemaOut, Schema } from "./schemas.js";
import { Prettify } from "./utils.js";
import { AnySchemaParseFn, inferSchemaOut, Schema } from "./schemas.js";
import { TriggerApiRequestOptions } from "../apiClient/index.js";
import { inferToolParameters, ToolTaskParameters } from "./tools.js";
type RequireOne<T, K extends keyof T> = {
[X in Exclude<keyof T, K>]?: T[X];
@@ -150,6 +153,8 @@ type CommonTaskOptions<
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
description?: string;
/** The retry settings when an uncaught error is thrown.
*
* If omitted it will use the values in your `trigger.config.ts` file.
@@ -337,6 +342,15 @@ export type TaskWithSchemaOptions<
schema?: TSchema;
};
export type TaskWithToolOptions<
TIdentifier extends string,
TParameters extends ToolTaskParameters,
TOutput = unknown,
TInitOutput extends InitOutput = any,
> = CommonTaskOptions<TIdentifier, inferToolParameters<TParameters>, TOutput, TInitOutput> & {
parameters: TParameters;
};
declare const __output: unique symbol;
declare const __payload: unique symbol;
type BrandRun<P, O> = { [__output]: O; [__payload]: P };
@@ -404,15 +418,16 @@ export type BatchResult<TOutput = any> = {
runs: TaskRunResult<TOutput>[];
};
export type BatchItem<TInput> = TInput extends void
? { payload?: TInput; options?: TaskRunOptions }
: { payload: TInput; options?: TaskRunOptions };
export type BatchItem<TInput> = { payload: TInput; options?: TaskRunOptions };
export interface Task<TIdentifier extends string, TInput = void, TOutput = any> {
/**
* The id of the task.
*/
id: TIdentifier;
description?: string;
/**
* Trigger a task with the given payload, and continue without waiting for the result. If you want to wait for the result, use `triggerAndWait`. Returns the id of the triggered task run.
* @param payload
@@ -479,6 +494,26 @@ export interface Task<TIdentifier extends string, TInput = void, TOutput = any>
batchTriggerAndWait: (items: Array<BatchItem<TInput>>) => Promise<BatchResult<TOutput>>;
}
export interface TaskWithSchema<
TIdentifier extends string,
TSchema extends TaskSchema | undefined = undefined,
TOutput = any,
> extends Task<TIdentifier, inferSchemaIn<TSchema>, TOutput> {
schema?: TSchema;
}
export interface ToolTask<
TIdentifier extends string,
TParameters extends ToolTaskParameters,
TOutput = any,
> extends Task<TIdentifier, inferToolParameters<TParameters>, TOutput> {
tool: {
parameters: TParameters;
description?: string;
execute: (args: inferToolParameters<TParameters>) => Promise<TOutput>;
};
}
export type AnyTask = Task<string, any, any>;
export type TaskPayload<TTask extends AnyTask> = TTask extends Task<string, infer TInput, any>
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod";
import type { Schema as AISchema } from "ai";
import { Schema } from "./schemas.js";
export type ToolTaskParameters = z.ZodTypeAny | AISchema<any>;
export type inferToolParameters<PARAMETERS extends ToolTaskParameters> =
PARAMETERS extends AISchema<any>
? PARAMETERS["_type"]
: PARAMETERS extends z.ZodTypeAny
? z.infer<PARAMETERS>
: never;
export function convertToolParametersToSchema<TToolParameters extends ToolTaskParameters>(
toolParameters: TToolParameters
): Schema {
return toolParameters instanceof z.ZodSchema
? toolParameters
: convertAISchemaToTaskSchema(toolParameters);
}
function convertAISchemaToTaskSchema(schema: AISchema<any>): Schema {
return (payload: unknown) => {
const result = schema.validate?.(payload);
if (!result) {
throw new Error("Invalid payload");
}
if (!result.success) {
throw result.error;
}
return result.value;
};
}
+3 -3
View File
@@ -1,10 +1,10 @@
export function getEnvVar(name: string): string | undefined {
export function getEnvVar(name: string, defaultValue?: string): string | undefined {
// This could run in a non-Node.js environment (Bun, Deno, CF Worker, etc.), so don't just assume process.env is a thing
if (typeof process !== "undefined" && typeof process.env === "object" && process.env !== null) {
return process.env[name];
return process.env[name] ?? defaultValue;
}
return;
return defaultValue;
}
export function getNumberEnvVar(name: string, defaultValue?: number): number | undefined {
+2
View File
@@ -7,6 +7,7 @@ import { TaskCatalog } from "../task-catalog/catalog.js";
import { TaskContext } from "../taskContext/types.js";
import { TimeoutManager } from "../timeout/types.js";
import { UsageManager } from "../usage/types.js";
import { WaitUntilManager } from "../waitUntil/types.js";
import { _globalThis } from "./platform.js";
const GLOBAL_TRIGGER_DOT_DEV_KEY = Symbol.for(`dev.trigger.ts.api`);
@@ -59,4 +60,5 @@ type TriggerDotDevGlobalAPI = {
["api-client"]?: ApiClientConfiguration;
["run-metadata"]?: RunMetadataManager;
["timeout"]?: TimeoutManager;
["wait-until"]?: WaitUntilManager;
};
+58 -36
View File
@@ -7,6 +7,7 @@ import { apiClientManager } from "../apiClientManager-api.js";
import { zodfetch } from "../zodfetch.js";
import { z } from "zod";
import type { RetryOptions } from "../schemas/index.js";
import { ApiClient } from "../apiClient/index.js";
export type IOPacket = {
data?: string | undefined;
@@ -36,8 +37,11 @@ export async function parsePacket(value: IOPacket): Promise<any> {
}
}
export async function conditionallyImportAndParsePacket(value: IOPacket): Promise<any> {
const importedPacket = await conditionallyImportPacket(value);
export async function conditionallyImportAndParsePacket(
value: IOPacket,
client?: ApiClient
): Promise<any> {
const importedPacket = await conditionallyImportPacket(value, undefined, client);
return await parsePacket(importedPacket);
}
@@ -159,19 +163,20 @@ async function exportPacket(packet: IOPacket, pathPrefix: string): Promise<IOPac
export async function conditionallyImportPacket(
packet: IOPacket,
tracer?: TriggerTracer
tracer?: TriggerTracer,
client?: ApiClient
): Promise<IOPacket> {
if (packet.dataType !== "application/store") {
return packet;
}
if (!tracer) {
return await importPacket(packet);
return await importPacket(packet, undefined, client);
} else {
const result = await tracer.startActiveSpan(
"store.downloadPayload",
async (span) => {
return await importPacket(packet, span);
return await importPacket(packet, span, client);
},
{
attributes: {
@@ -209,16 +214,18 @@ export async function resolvePresignedPacketUrl(
}
}
async function importPacket(packet: IOPacket, span?: Span): Promise<IOPacket> {
async function importPacket(packet: IOPacket, span?: Span, client?: ApiClient): Promise<IOPacket> {
if (!packet.data) {
return packet;
}
if (!apiClientManager.client) {
const $client = client ?? apiClientManager.client;
if (!$client) {
return packet;
}
const presignedResponse = await apiClientManager.client.getPayloadUrl(packet.data);
const presignedResponse = await $client.getPayloadUrl(packet.data);
const response = await zodfetch(z.any(), presignedResponse.presignedUrl, undefined, {
retry: ioRetryOptions,
@@ -264,7 +271,7 @@ export async function createPacketAttributes(
try {
const parsed = parse(packet.data) as any;
const jsonified = JSON.parse(JSON.stringify(parsed, safeReplacer));
const jsonified = JSON.parse(JSON.stringify(parsed, makeSafeReplacer()));
const result = {
...flattenAttributes(jsonified, dataKey),
@@ -312,7 +319,7 @@ export async function createPacketAttributesAsJson(
const { deserialize } = await loadSuperJSON();
const deserialized = deserialize(data) as any;
const jsonify = safeJsonParse(JSON.stringify(deserialized, safeReplacer));
const jsonify = safeJsonParse(JSON.stringify(deserialized, makeSafeReplacer()));
return imposeAttributeLimits(flattenAttributes(jsonify, undefined));
case "application/store":
@@ -322,7 +329,11 @@ export async function createPacketAttributesAsJson(
}
}
export async function prettyPrintPacket(rawData: any, dataType?: string): Promise<string> {
export async function prettyPrintPacket(
rawData: any,
dataType?: string,
options?: ReplacerOptions
): Promise<string> {
if (rawData === undefined) {
return "";
}
@@ -340,42 +351,53 @@ export async function prettyPrintPacket(rawData: any, dataType?: string): Promis
if (typeof rawData === "string") {
rawData = safeJsonParse(rawData);
}
return JSON.stringify(rawData, safeReplacer, 2);
return JSON.stringify(rawData, makeSafeReplacer(options), 2);
}
if (typeof rawData === "string") {
return rawData;
}
return JSON.stringify(rawData, safeReplacer, 2);
return JSON.stringify(rawData, makeSafeReplacer(options), 2);
}
function safeReplacer(key: string, value: any) {
// If it is a BigInt
if (typeof value === "bigint") {
return value.toString(); // Convert to string
}
interface ReplacerOptions {
filteredKeys?: string[];
}
// if it is a Regex
if (value instanceof RegExp) {
return value.toString(); // Convert to string
}
function makeSafeReplacer(options?: ReplacerOptions) {
return function replacer(key: string, value: any) {
// Check if the key should be filtered out
if (options?.filteredKeys?.includes(key)) {
return undefined;
}
// if it is a Set
if (value instanceof Set) {
return Array.from(value); // Convert to array
}
// If it is a BigInt
if (typeof value === "bigint") {
return value.toString();
}
// if it is a Map, convert it to an object
if (value instanceof Map) {
const obj: Record<string, any> = {};
value.forEach((v, k) => {
obj[k] = v;
});
return obj;
}
// if it is a Regex
if (value instanceof RegExp) {
return value.toString();
}
return value; // Otherwise return the value as is
// if it is a Set
if (value instanceof Set) {
return Array.from(value);
}
// if it is a Map, convert it to an object
if (value instanceof Map) {
const obj: Record<string, any> = {};
value.forEach((v, k) => {
obj[k] = v;
});
return obj;
}
return value;
};
}
function getPacketExtension(outputType: string): string {
@@ -396,7 +418,7 @@ async function loadSuperJSON() {
superjson.registerCustom<Buffer, number[]>(
{
isApplicable: (v): v is Buffer => v instanceof Buffer,
isApplicable: (v): v is Buffer => typeof Buffer === "function" && Buffer.isBuffer(v),
serialize: (v) => [...v],
deserialize: (v) => Buffer.from(v),
},
+5
View File
@@ -0,0 +1,5 @@
// Split module-level variable definition into separate files to allow
// tree-shaking on each api instance.
import { WaitUntilAPI } from "./waitUntil/index.js";
export const waitUntil = WaitUntilAPI.getInstance();
+54
View File
@@ -0,0 +1,54 @@
import { getGlobal, registerGlobal } from "../utils/globals.js";
import { MaybeDeferredPromise, WaitUntilManager } from "./types.js";
const API_NAME = "wait-until";
class NoopManager implements WaitUntilManager {
register(promise: MaybeDeferredPromise): void {
// noop
}
blockUntilSettled(timeout: number): Promise<void> {
return Promise.resolve();
}
requiresResolving(): boolean {
return false;
}
}
const NOOP_MANAGER = new NoopManager();
export class WaitUntilAPI implements WaitUntilManager {
private static _instance?: WaitUntilAPI;
private constructor() {}
public static getInstance(): WaitUntilAPI {
if (!this._instance) {
this._instance = new WaitUntilAPI();
}
return this._instance;
}
setGlobalManager(manager: WaitUntilManager): boolean {
return registerGlobal(API_NAME, manager);
}
#getManager(): WaitUntilManager {
return getGlobal(API_NAME) ?? NOOP_MANAGER;
}
register(promise: MaybeDeferredPromise): void {
return this.#getManager().register(promise);
}
blockUntilSettled(timeout: number): Promise<void> {
return this.#getManager().blockUntilSettled(timeout);
}
requiresResolving(): boolean {
return this.#getManager().requiresResolving();
}
}
+34
View File
@@ -0,0 +1,34 @@
import { MaybeDeferredPromise, WaitUntilManager } from "./types.js";
export class StandardWaitUntilManager implements WaitUntilManager {
private maybeDeferredPromises: Set<MaybeDeferredPromise> = new Set();
register(promise: MaybeDeferredPromise): void {
this.maybeDeferredPromises.add(promise);
}
async blockUntilSettled(timeout: number): Promise<void> {
if (this.promisesRequringResolving.length === 0) {
return;
}
const promises = this.promisesRequringResolving.map((p) =>
typeof p.promise === "function" ? p.promise() : p.promise
);
await Promise.race([
Promise.allSettled(promises),
new Promise<void>((resolve, _) => setTimeout(() => resolve(), timeout)),
]);
this.maybeDeferredPromises.clear();
}
requiresResolving(): boolean {
return this.promisesRequringResolving.length > 0;
}
private get promisesRequringResolving(): MaybeDeferredPromise[] {
return Array.from(this.maybeDeferredPromises).filter((p) => p.requiresResolving());
}
}
+10
View File
@@ -0,0 +1,10 @@
export type MaybeDeferredPromise = {
requiresResolving(): boolean;
promise: Promise<any> | (() => Promise<any>);
};
export interface WaitUntilManager {
register(promise: MaybeDeferredPromise): void;
blockUntilSettled(timeout: number): Promise<void>;
requiresResolving(): boolean;
}
+1
View File
@@ -15,3 +15,4 @@ export { DevUsageManager } from "../usage/devUsageManager.js";
export { ProdUsageManager, type ProdUsageManagerOptions } from "../usage/prodUsageManager.js";
export { UsageTimeoutManager } from "../timeout/usageTimeoutManager.js";
export { StandardMetadataManager } from "../runMetadata/manager.js";
export { StandardWaitUntilManager } from "../waitUntil/manager.js";
+20 -1
View File
@@ -3,7 +3,7 @@ import { VERSION } from "../../version.js";
import { ApiError, RateLimitError } from "../apiClient/errors.js";
import { ConsoleInterceptor } from "../consoleInterceptor.js";
import { parseError, sanitizeError, TaskPayloadParsedError } from "../errors.js";
import { runMetadata, TriggerConfig } from "../index.js";
import { runMetadata, TriggerConfig, waitUntil } from "../index.js";
import { recordSpanException, TracingSDK } from "../otel/index.js";
import {
ServerBackgroundWorker,
@@ -223,6 +223,7 @@ export class TaskExecutor {
}
} finally {
await this.#callTaskCleanup(parsedPayload, ctx, initOutput, signal);
await this.#blockForWaitUntil();
}
});
},
@@ -494,6 +495,24 @@ export class TaskExecutor {
});
}
async #blockForWaitUntil() {
if (!waitUntil.requiresResolving()) {
return;
}
return this._tracer.startActiveSpan(
"waitUntil",
async (span) => {
return await waitUntil.blockUntilSettled(60_000);
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "clock",
},
}
);
}
async #handleError(
execution: TaskRunExecution,
error: unknown,
+605
View File
@@ -0,0 +1,605 @@
import { describe, it, expect } from "vitest";
import {
AnyRunShape,
RunSubscription,
StreamSubscription,
StreamSubscriptionFactory,
type RunShapeProvider,
} from "../src/v3/apiClient/runStream.js";
import type { SubscribeRunRawShape } from "../src/v3/schemas/api.js";
// Test implementations
class TestStreamSubscription implements StreamSubscription {
constructor(private chunks: unknown[]) {}
async subscribe(onChunk: (chunk: unknown) => Promise<void>): Promise<() => void> {
for (const chunk of this.chunks) {
await onChunk(chunk);
}
return () => {};
}
}
class TestStreamSubscriptionFactory implements StreamSubscriptionFactory {
private streams = new Map<string, unknown[]>();
setStreamChunks(runId: string, streamKey: string, chunks: unknown[]) {
this.streams.set(`${runId}:${streamKey}`, chunks);
}
createSubscription(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;
};
}
}
// 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;
}
await callback(this.shapes[this.currentShapeIndex++]!);
}, 100);
return () => {
this.unsubscribed = true;
clearInterval(interval);
};
}
}
describe("RunSubscription", () => {
it("should handle basic run subscription", async () => {
const shapes = [
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED_SUCCESSFULLY",
createdAt: new Date(),
updatedAt: new Date(),
completedAt: new Date(),
number: 1,
usageDurationMs: 100,
costInCents: 0,
baseCostInCents: 0,
isTest: false,
runTags: [],
},
];
const subscription = new RunSubscription({
provider: new TestShapeProvider(shapes),
streamFactory: new TestStreamSubscriptionFactory(),
closeOnComplete: true,
});
const results = await convertAsyncIterableToArray(subscription);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
id: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED",
});
});
it("should handle payload and outputs", async () => {
const shapes: SubscribeRunRawShape[] = [
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED_SUCCESSFULLY",
createdAt: new Date(),
updatedAt: new Date(),
completedAt: new Date(),
number: 1,
usageDurationMs: 100,
costInCents: 0,
baseCostInCents: 0,
isTest: false,
runTags: [],
payload: JSON.stringify({ test: "payload" }),
payloadType: "application/json",
output: JSON.stringify({ test: "output" }),
outputType: "application/json",
},
];
const subscription = new RunSubscription({
provider: new TestShapeProvider(shapes),
streamFactory: new TestStreamSubscriptionFactory(),
closeOnComplete: true,
});
const results = await convertAsyncIterableToArray(subscription);
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
id: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED",
payload: { test: "payload" },
output: { test: "output" },
});
});
it("should keep stream open when closeOnComplete is false", async () => {
const shapes: SubscribeRunRawShape[] = [
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "test-task",
status: "EXECUTING",
createdAt: new Date(),
updatedAt: new Date(),
completedAt: new Date(),
number: 1,
usageDurationMs: 100,
costInCents: 0,
baseCostInCents: 0,
isTest: false,
runTags: [],
},
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED_SUCCESSFULLY",
createdAt: new Date(),
updatedAt: new Date(),
completedAt: new Date(),
number: 1,
usageDurationMs: 200,
costInCents: 0,
baseCostInCents: 0,
isTest: false,
runTags: [],
},
];
const subscription = new RunSubscription({
provider: new DelayedTestShapeProvider(shapes),
streamFactory: new TestStreamSubscriptionFactory(),
closeOnComplete: false,
});
// Collect 2 results
const results = await collectNResults(subscription, 2);
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
id: "run_123",
taskIdentifier: "test-task",
status: "EXECUTING",
});
expect(results[1]).toMatchObject({
id: "run_123",
taskIdentifier: "test-task",
status: "COMPLETED",
});
});
it("should handle stream data", async () => {
const streamFactory = new TestStreamSubscriptionFactory();
// Set up test chunks
streamFactory.setStreamChunks("run_123", "openai", [
{ id: "chunk1", content: "Hello" },
{ id: "chunk2", content: "World" },
]);
const shapes = [
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "openai-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",
},
];
const subscription = new RunSubscription({
provider: new TestShapeProvider(shapes),
streamFactory,
});
const results = await collectNResults(
subscription.withStreams<{ openai: { id: string; content: string } }>(),
3 // 1 run + 2 stream chunks
);
expect(results).toHaveLength(3);
expect(results[0]).toMatchObject({
type: "run",
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
});
expect(results[1]).toMatchObject({
type: "openai",
chunk: { id: "chunk1", content: "Hello" },
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
});
expect(results[2]).toMatchObject({
type: "openai",
chunk: { id: "chunk2", content: "World" },
run: { id: "run_123", taskIdentifier: "openai-streaming", status: "EXECUTING" },
});
});
it("should only create one stream for multiple runs of the same id", async () => {
const streamFactory = new TestStreamSubscriptionFactory();
let streamCreationCount = 0;
// Override createSubscription to count calls
const originalCreate = streamFactory.createSubscription.bind(streamFactory);
streamFactory.createSubscription = (runId: string, streamKey: string) => {
streamCreationCount++;
return originalCreate(runId, streamKey);
};
// Set up test chunks
streamFactory.setStreamChunks("run_123", "openai", [
{ id: "chunk1", content: "Hello" },
{ id: "chunk2", content: "World" },
]);
const shapes = [
// First run update
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "openai-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 with same stream key
{
id: "123",
friendlyId: "run_123",
taskIdentifier: "openai-streaming",
status: "EXECUTING",
createdAt: new Date(),
updatedAt: new Date(),
number: 1,
usageDurationMs: 200, // Different to show it's a new update
costInCents: 0,
baseCostInCents: 0,
isTest: false,
runTags: [],
metadata: JSON.stringify({
$$streams: ["openai"],
}),
metadataType: "application/json",
},
];
const subscription = new RunSubscription({
provider: new TestShapeProvider(shapes),
streamFactory,
});
const results = await collectNResults(
subscription.withStreams<{ openai: { id: string; content: string } }>(),
4 // 2 runs + 2 stream chunks
);
// Verify we only created one stream
expect(streamCreationCount).toBe(1);
// Verify we got all the expected events
expect(results).toHaveLength(4);
expect(results[0]).toMatchObject({
type: "run",
run: {
id: "run_123",
taskIdentifier: "openai-streaming",
status: "EXECUTING",
durationMs: 100,
},
});
expect(results[1]).toMatchObject({
type: "openai",
chunk: { id: "chunk1", content: "Hello" },
run: { id: "run_123", durationMs: 100 },
});
expect(results[2]).toMatchObject({
type: "openai",
chunk: { id: "chunk2", content: "World" },
run: { id: "run_123", durationMs: 100 },
});
expect(results[3]).toMatchObject({
type: "run",
run: {
id: "run_123",
taskIdentifier: "openai-streaming",
status: "EXECUTING",
durationMs: 200,
},
});
});
it("should handle multiple streams simultaneously", 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 = [
{
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", "anthropic"],
}),
metadataType: "application/json",
},
];
const subscription = new RunSubscription({
provider: new TestShapeProvider(shapes),
streamFactory,
});
const results = await collectNResults(
subscription.withStreams<{
openai: { id: string; content: string };
anthropic: { id: string; message: string };
}>(),
5 // 1 run + 2 openai chunks + 2 anthropic chunks
);
expect(results).toHaveLength(5);
expect(results[0]).toMatchObject({
type: "run",
run: { id: "run_123", taskIdentifier: "multi-streaming", status: "EXECUTING" },
});
// Filter and verify openai chunks
const openaiChunks = results.filter((r) => r.type === "openai");
expect(openaiChunks).toHaveLength(2);
expect(openaiChunks[0]).toMatchObject({
type: "openai",
chunk: { id: "openai1", content: "Hello" },
run: { id: "run_123" },
});
expect(openaiChunks[1]).toMatchObject({
type: "openai",
chunk: { id: "openai2", content: "World" },
run: { id: "run_123" },
});
// Filter and verify anthropic chunks
const anthropicChunks = results.filter((r) => r.type === "anthropic");
expect(anthropicChunks).toHaveLength(2);
expect(anthropicChunks[0]).toMatchObject({
type: "anthropic",
chunk: { id: "claude1", message: "Hi" },
run: { id: "run_123" },
});
expect(anthropicChunks[1]).toMatchObject({
type: "anthropic",
chunk: { id: "claude2", message: "There" },
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[]> {
const result: T[] = [];
for await (const item of iterable) {
result.push(item);
}
return result;
}
async function collectNResults<T>(
iterable: AsyncIterable<T>,
count: number,
timeoutMs: number = 1000
): Promise<T[]> {
const results: T[] = [];
const promise = new Promise<T[]>((resolve) => {
(async () => {
for await (const result of iterable) {
results.push(result);
if (results.length === count) {
resolve(results);
break;
}
}
})();
});
return Promise.race([
promise,
new Promise<T[]>((_, reject) =>
setTimeout(
() => reject(new Error(`Timeout waiting for ${count} results after ${timeoutMs}ms`)),
timeoutMs
)
),
]);
}
@@ -0,0 +1,295 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { createTestHttpServer } from "@epic-web/test-server/http";
import { StandardMetadataManager } from "../src/v3/runMetadata/manager.js";
import { ApiClient } from "../src/v3/apiClient/index.js";
describe("StandardMetadataManager", () => {
const runId = "test-run-id";
let server: Awaited<ReturnType<typeof createTestHttpServer>>;
let metadataUpdates: Array<Record<string, any>> = [];
let manager: StandardMetadataManager;
beforeEach(async () => {
metadataUpdates = [];
server = await createTestHttpServer({
defineRoutes(router) {
router.put("/api/v1/runs/:runId/metadata", async ({ req }) => {
const body = await req.json();
metadataUpdates.push(body);
return Response.json({ metadata: body.metadata });
});
},
});
const apiClient = new ApiClient(server.http.url().origin, "tr-123");
manager = new StandardMetadataManager(apiClient, server.http.url().origin);
manager.runId = runId;
});
afterEach(async () => {
await server.close();
});
test("should initialize with empty store", () => {
expect(manager.current()).toBeUndefined();
});
test("should set and get simple keys", () => {
manager.setKey("test", "value");
expect(manager.getKey("test")).toBe("value");
});
test("should handle JSON path keys", () => {
manager.setKey("nested", { foo: "bar" });
manager.setKey("$.nested.path", "value");
expect(manager.current()).toEqual({
nested: {
foo: "bar",
path: "value",
},
});
});
test("should flush changes to server", async () => {
manager.setKey("test", "value");
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
expect(metadataUpdates[0]).toEqual({
metadata: {
test: "value",
},
});
});
test("should only flush to server when data has actually changed", async () => {
// Initial set and flush
manager.setKey("test", "value");
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
// Same value set again
manager.setKey("test", "value");
await manager.flush();
// Should not trigger another update since value hasn't changed
expect(metadataUpdates).toHaveLength(1);
// Different value set
manager.setKey("test", "new value");
await manager.flush();
// Should trigger new update
expect(metadataUpdates).toHaveLength(2);
});
test("should only flush to server when nested data has actually changed", async () => {
// Initial nested object
manager.setKey("nested", { foo: "bar" });
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
// Same nested value
manager.setKey("nested", { foo: "bar" });
await manager.flush();
// Should not trigger another update
expect(metadataUpdates).toHaveLength(1);
// Different nested value
manager.setKey("nested", { foo: "baz" });
await manager.flush();
// Should trigger new update
expect(metadataUpdates).toHaveLength(2);
});
test("should append to list with simple key", () => {
// First append creates the array
manager.appendKey("myList", "first");
expect(manager.getKey("myList")).toEqual(["first"]);
// Second append adds to existing array
manager.appendKey("myList", "second");
expect(manager.getKey("myList")).toEqual(["first", "second"]);
});
test("should append to list with JSON path", () => {
// First create nested structure
manager.setKey("nested", { items: [] });
// Append to empty array
manager.appendKey("$.nested.items", "first");
expect(manager.current()).toEqual({
nested: {
items: ["first"],
},
});
// Append another item
manager.appendKey("$.nested.items", "second");
expect(manager.current()).toEqual({
nested: {
items: ["first", "second"],
},
});
});
test("should convert non-array values to array when appending", () => {
// Set initial non-array value
manager.setKey("value", "initial");
// Append should convert to array
manager.appendKey("value", "second");
expect(manager.getKey("value")).toEqual(["initial", "second"]);
});
test("should convert non-array values to array when appending with JSON path", () => {
// Set initial nested non-array value
manager.setKey("nested", { value: "initial" });
// Append should convert to array
manager.appendKey("$.nested.value", "second");
expect(manager.current()).toEqual({
nested: {
value: ["initial", "second"],
},
});
});
test("should trigger server update when appending to list", async () => {
manager.appendKey("myList", "first");
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
expect(metadataUpdates[0]).toEqual({
metadata: {
myList: ["first"],
},
});
manager.appendKey("myList", "second");
await manager.flush();
expect(metadataUpdates).toHaveLength(2);
expect(metadataUpdates[1]).toEqual({
metadata: {
myList: ["first", "second"],
},
});
});
test("should not trigger server update when appending same value", async () => {
manager.appendKey("myList", "first");
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
// Append same value
manager.appendKey("myList", "first");
await manager.flush();
// Should still be only one update
expect(metadataUpdates).toHaveLength(2);
});
test("should increment and decrement keys", () => {
manager.incrementKey("counter");
expect(manager.getKey("counter")).toBe(1);
manager.incrementKey("counter", 5);
expect(manager.getKey("counter")).toBe(6);
manager.decrementKey("counter");
expect(manager.getKey("counter")).toBe(5);
manager.decrementKey("counter", 3);
expect(manager.getKey("counter")).toBe(2);
});
test("should remove value from array with simple key", () => {
// Setup initial array
manager.setKey("myList", ["first", "second", "third"]);
// Remove a value
manager.removeFromKey("myList", "second");
expect(manager.getKey("myList")).toEqual(["first", "third"]);
});
test("should remove value from array with JSON path", () => {
// Setup initial nested array
manager.setKey("nested", { items: ["first", "second", "third"] });
// Remove a value
manager.removeFromKey("$.nested.items", "second");
expect(manager.current()).toEqual({
nested: {
items: ["first", "third"],
},
});
});
test("should handle removing non-existent value", () => {
// Setup initial array
manager.setKey("myList", ["first", "second"]);
// Try to remove non-existent value
manager.removeFromKey("myList", "third");
expect(manager.getKey("myList")).toEqual(["first", "second"]);
});
test("should handle removing from non-array values", () => {
// Setup non-array value
manager.setKey("value", "string");
// Try to remove from non-array
manager.removeFromKey("value", "something");
expect(manager.getKey("value")).toBe("string");
});
test("should remove object from array using deep equality", () => {
// Setup array with objects
manager.setKey("objects", [
{ id: 1, name: "first" },
{ id: 2, name: "second" },
{ id: 3, name: "third" },
]);
// Remove object
manager.removeFromKey("objects", { id: 2, name: "second" });
expect(manager.getKey("objects")).toEqual([
{ id: 1, name: "first" },
{ id: 3, name: "third" },
]);
});
test("should trigger server update when removing from array", async () => {
// Setup initial array
manager.setKey("myList", ["first", "second", "third"]);
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
// Remove value
manager.removeFromKey("myList", "second");
await manager.flush();
expect(metadataUpdates).toHaveLength(2);
expect(metadataUpdates[1]).toEqual({
metadata: {
myList: ["first", "third"],
},
});
});
test("should not trigger server update when removing non-existent value", async () => {
// Setup initial array
manager.setKey("myList", ["first", "second"]);
await manager.flush();
expect(metadataUpdates).toHaveLength(1);
// Try to remove non-existent value
manager.removeFromKey("myList", "third");
await manager.flush();
// Should not trigger new update since nothing changed
expect(metadataUpdates).toHaveLength(1);
});
});
+2 -2
View File
@@ -4,7 +4,7 @@ import React from "react";
import { createContextAndHook } from "./utils/createContextAndHook.js";
import type { ApiClientConfiguration } from "@trigger.dev/core/v3";
const [TriggerAuthContext, useTriggerAuthContext] =
const [TriggerAuthContext, useTriggerAuthContext, useTriggerAuthContextOptional] =
createContextAndHook<ApiClientConfiguration>("TriggerAuthContext");
export { TriggerAuthContext, useTriggerAuthContext };
export { TriggerAuthContext, useTriggerAuthContext, useTriggerAuthContextOptional };
+46 -8
View File
@@ -1,16 +1,54 @@
"use client";
import { ApiClient } from "@trigger.dev/core/v3";
import { useTriggerAuthContext } from "../contexts.js";
import { ApiClient, ApiRequestOptions } from "@trigger.dev/core/v3";
import { useTriggerAuthContextOptional } from "../contexts.js";
export function useApiClient() {
const auth = useTriggerAuthContext();
/**
* Configuration options for creating an API client instance.
*/
export type UseApiClientOptions = {
/** Optional access token for authentication */
accessToken?: string;
/** Optional base URL for the API endpoints */
baseURL?: string;
/** Optional additional request configuration */
requestOptions?: ApiRequestOptions;
};
const baseUrl = auth.baseURL ?? "https://api.trigger.dev";
/**
* Hook to create an API client instance using authentication context or provided options.
*
* @param {UseApiClientOptions} [options] - Configuration options for the API client
* @returns {ApiClient} An initialized API client instance
* @throws {Error} When no access token is available in either context or options
*
* @example
* ```ts
* // Using context authentication
* const apiClient = useApiClient();
*
* // Using custom options
* const apiClient = useApiClient({
* accessToken: "your-access-token",
* baseURL: "https://api.my-trigger.com",
* requestOptions: { retry: { maxAttempts: 10 } }
* });
* ```
*/
export function useApiClient(options?: UseApiClientOptions): ApiClient {
const auth = useTriggerAuthContextOptional();
if (!auth.accessToken) {
throw new Error("Missing accessToken in TriggerAuthContext");
const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev";
const accessToken = options?.accessToken ?? auth?.accessToken;
if (!accessToken) {
throw new Error("Missing accessToken in TriggerAuthContext or useApiClient options");
}
return new ApiClient(baseUrl, auth.accessToken, auth.requestOptions);
const requestOptions: ApiRequestOptions = {
...auth?.requestOptions,
...options?.requestOptions,
};
return new ApiClient(baseUrl, accessToken, requestOptions);
}
@@ -0,0 +1,587 @@
"use client";
import { AnyTask, ApiClient, InferRunTypes, RealtimeRun } from "@trigger.dev/core/v3";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { KeyedMutator, useSWR } from "../utils/trigger-swr.js";
import { useApiClient, UseApiClientOptions } from "./useApiClient.js";
import { createThrottledQueue } from "../utils/throttle.js";
export type UseRealtimeRunOptions = UseApiClientOptions & {
id?: string;
enabled?: boolean;
experimental_throttleInMs?: number;
};
export type UseRealtimeRunInstance<TTask extends AnyTask = AnyTask> = {
run: RealtimeRun<TTask> | undefined;
error: Error | undefined;
/**
* Abort the current request immediately.
*/
stop: () => void;
};
/**
* Hook to subscribe to realtime updates of a task run.
*
* @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
* @returns {UseRealtimeRunInstance<TTask>} An object containing the current state of the run, error handling, and control methods
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { run, error } = useRealtimeRun<typeof myTask>('run-id-123');
* ```
*/
export function useRealtimeRun<TTask extends AnyTask>(
runId?: string,
options?: UseRealtimeRunOptions
): UseRealtimeRunInstance<TTask> {
const hookId = useId();
const idKey = options?.id ?? hookId;
// 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
);
// Abort controller to cancel the current API call.
const abortControllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const apiClient = useApiClient(options);
const triggerRequest = useCallback(async () => {
try {
if (!runId) {
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
await processRealtimeRun(runId, apiClient, mutateRun, abortControllerRef);
} catch (err) {
// Ignore abort errors as they are expected.
if ((err as any).name === "AbortError") {
abortControllerRef.current = null;
return;
}
setError(err as Error);
} finally {
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}
}, [runId, mutateRun, abortControllerRef, apiClient, setError]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
if (!runId) {
return;
}
triggerRequest().finally(() => {});
return () => {
stop();
};
}, [runId, stop, options?.enabled]);
return { run, error, stop };
}
export type StreamResults<TStreams extends Record<string, any>> = {
[K in keyof TStreams]: Array<TStreams[K]>;
};
export type UseRealtimeRunWithStreamsInstance<
TTask extends AnyTask = AnyTask,
TStreams extends Record<string, any> = Record<string, any>,
> = {
run: RealtimeRun<TTask> | undefined;
streams: StreamResults<TStreams>;
error: Error | undefined;
/**
* Abort the current request immediately, keep the generated tokens if any.
*/
stop: () => void;
};
/**
* Hook to subscribe to realtime updates of a task run with associated data streams.
*
* @template TTask - The type of the task
* @template TStreams - The type of the streams data
* @param {string} [runId] - The unique identifier of the run to subscribe to
* @param {UseRealtimeRunOptions} [options] - Configuration options for the subscription
* @returns {UseRealtimeRunWithStreamsInstance<TTask, TStreams>} An object containing the current state of the run, streams data, and error handling
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { run, streams, error } = useRealtimeRunWithStreams<typeof myTask, {
* output: string;
* }>('run-id-123');
* ```
*/
export function useRealtimeRunWithStreams<
TTask extends AnyTask = AnyTask,
TStreams extends Record<string, any> = Record<string, any>,
>(
runId?: string,
options?: UseRealtimeRunOptions
): UseRealtimeRunWithStreamsInstance<TTask, TStreams> {
const hookId = useId();
const idKey = options?.id ?? hookId;
const [initialStreamsFallback] = useState({} as StreamResults<TStreams>);
// Store the streams state in SWR, using the idKey as the key to share states.
const { data: streams, mutate: mutateStreams } = useSWR<StreamResults<TStreams>>(
[idKey, "streams"],
null,
{
fallbackData: initialStreamsFallback,
}
);
// Keep the latest streams in a ref.
const streamsRef = useRef<StreamResults<TStreams>>(streams ?? ({} as StreamResults<TStreams>));
useEffect(() => {
streamsRef.current = streams || ({} as StreamResults<TStreams>);
}, [streams]);
// 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
);
// Abort controller to cancel the current API call.
const abortControllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const apiClient = useApiClient(options);
const triggerRequest = useCallback(async () => {
try {
if (!runId) {
return;
}
const abortController = new AbortController();
abortControllerRef.current = abortController;
await processRealtimeRunWithStreams(
runId,
apiClient,
mutateRun,
mutateStreams,
streamsRef,
abortControllerRef,
options?.experimental_throttleInMs
);
} catch (err) {
// Ignore abort errors as they are expected.
if ((err as any).name === "AbortError") {
abortControllerRef.current = null;
return;
}
setError(err as Error);
} finally {
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}
}, [runId, mutateRun, mutateStreams, streamsRef, abortControllerRef, apiClient, setError]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
if (!runId) {
return;
}
triggerRequest().finally(() => {});
return () => {
stop();
};
}, [runId, stop, options?.enabled]);
return { run, streams: streams ?? initialStreamsFallback, error, stop };
}
export type UseRealtimeRunsInstance<TTask extends AnyTask = AnyTask> = {
runs: RealtimeRun<TTask>[];
error: Error | undefined;
/**
* Abort the current request immediately.
*/
stop: () => void;
};
/**
* Hook to subscribe to realtime updates of task runs filtered by tag(s).
*
* @template TTask - The type of the task
* @param {string | string[]} tag - The tag or array of tags to filter runs by
* @param {UseRealtimeRunOptions} [options] - Configuration options for the subscription
* @returns {UseRealtimeRunsInstance<TTask>} An object containing the current state of the runs and any error encountered
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { runs, error } = useRealtimeRunsWithTag<typeof myTask>('my-tag');
* // Or with multiple tags
* const { runs, error } = useRealtimeRunsWithTag<typeof myTask>(['tag1', 'tag2']);
* ```
*/
export function useRealtimeRunsWithTag<TTask extends AnyTask>(
tag: string | string[],
options?: UseRealtimeRunOptions
): UseRealtimeRunsInstance<TTask> {
const hookId = useId();
const idKey = options?.id ?? hookId;
// Store the streams state in SWR, using the idKey as the key to share states.
const { data: runs, mutate: mutateRuns } = useSWR<RealtimeRun<TTask>[]>([idKey, "run"], null, {
fallbackData: [],
});
// Keep the latest streams in a ref.
const runsRef = useRef<RealtimeRun<TTask>[]>([]);
useEffect(() => {
runsRef.current = runs ?? [];
}, [runs]);
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
[idKey, "error"],
null
);
// Abort controller to cancel the current API call.
const abortControllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const apiClient = useApiClient(options);
const triggerRequest = useCallback(async () => {
try {
const abortController = new AbortController();
abortControllerRef.current = abortController;
await processRealtimeRunsWithTag(tag, apiClient, mutateRuns, runsRef, abortControllerRef);
} catch (err) {
// Ignore abort errors as they are expected.
if ((err as any).name === "AbortError") {
abortControllerRef.current = null;
return;
}
setError(err as Error);
} finally {
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}
}, [tag, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
triggerRequest().finally(() => {});
return () => {
stop();
};
}, [tag, stop, options?.enabled]);
return { runs: runs ?? [], error, stop };
}
/**
* Hook to subscribe to realtime updates of a batch of task runs.
*
* @template TTask - The type of the task
* @param {string} batchId - The unique identifier of the batch to subscribe to
* @param {UseRealtimeRunOptions} [options] - Configuration options for the subscription
* @returns {UseRealtimeRunsInstance<TTask>} An object containing the current state of the runs, error handling, and control methods
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { runs, error } = useRealtimeBatch<typeof myTask>('batch-id-123');
* ```
*/
export function useRealtimeBatch<TTask extends AnyTask>(
batchId: string,
options?: UseRealtimeRunOptions
): UseRealtimeRunsInstance<TTask> {
const hookId = useId();
const idKey = options?.id ?? hookId;
// Store the streams state in SWR, using the idKey as the key to share states.
const { data: runs, mutate: mutateRuns } = useSWR<RealtimeRun<TTask>[]>([idKey, "run"], null, {
fallbackData: [],
});
// Keep the latest streams in a ref.
const runsRef = useRef<RealtimeRun<TTask>[]>([]);
useEffect(() => {
runsRef.current = runs ?? [];
}, [runs]);
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
[idKey, "error"],
null
);
// Abort controller to cancel the current API call.
const abortControllerRef = useRef<AbortController | null>(null);
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
const apiClient = useApiClient(options);
const triggerRequest = useCallback(async () => {
try {
const abortController = new AbortController();
abortControllerRef.current = abortController;
await processRealtimeBatch(batchId, apiClient, mutateRuns, runsRef, abortControllerRef);
} catch (err) {
// Ignore abort errors as they are expected.
if ((err as any).name === "AbortError") {
abortControllerRef.current = null;
return;
}
setError(err as Error);
} finally {
if (abortControllerRef.current) {
abortControllerRef.current = null;
}
}
}, [batchId, mutateRuns, runsRef, abortControllerRef, apiClient, setError]);
useEffect(() => {
if (typeof options?.enabled === "boolean" && !options.enabled) {
return;
}
triggerRequest().finally(() => {});
return () => {
stop();
};
}, [batchId, stop, options?.enabled]);
return { runs: runs ?? [], error, stop };
}
async function processRealtimeBatch<TTask extends AnyTask = AnyTask>(
batchId: string,
apiClient: ApiClient,
mutateRunsData: KeyedMutator<RealtimeRun<TTask>[]>,
existingRunsRef: React.MutableRefObject<RealtimeRun<TTask>[]>,
abortControllerRef: React.MutableRefObject<AbortController | null>
) {
const subscription = apiClient.subscribeToBatch<InferRunTypes<TTask>>(batchId, {
signal: abortControllerRef.current?.signal,
});
for await (const part of subscription) {
mutateRunsData(insertRunShapeInOrder(existingRunsRef.current, part));
}
}
// Inserts and then orders by the run number, and ensures that the run is not duplicated
function insertRunShapeInOrder<TTask extends AnyTask>(
previousRuns: RealtimeRun<TTask>[],
run: RealtimeRun<TTask>
) {
const existingRun = previousRuns.find((r) => r.id === run.id);
if (existingRun) {
return previousRuns.map((r) => (r.id === run.id ? run : r));
}
const runNumber = run.number;
const index = previousRuns.findIndex((r) => r.number > runNumber);
if (index === -1) {
return [...previousRuns, run];
}
return [...previousRuns.slice(0, index), run, ...previousRuns.slice(index)];
}
async function processRealtimeRunsWithTag<TTask extends AnyTask = AnyTask>(
tag: string | string[],
apiClient: ApiClient,
mutateRunsData: KeyedMutator<RealtimeRun<TTask>[]>,
existingRunsRef: React.MutableRefObject<RealtimeRun<TTask>[]>,
abortControllerRef: React.MutableRefObject<AbortController | null>
) {
const subscription = apiClient.subscribeToRunsWithTag<InferRunTypes<TTask>>(tag, {
signal: abortControllerRef.current?.signal,
});
for await (const part of subscription) {
mutateRunsData(insertRunShape(existingRunsRef.current, part));
}
}
// Replaces or inserts a run shape, ordered by the createdAt timestamp
function insertRunShape<TTask extends AnyTask>(
previousRuns: RealtimeRun<TTask>[],
run: RealtimeRun<TTask>
) {
const existingRun = previousRuns.find((r) => r.id === run.id);
if (existingRun) {
return previousRuns.map((r) => (r.id === run.id ? run : r));
}
const createdAt = run.createdAt;
const index = previousRuns.findIndex((r) => r.createdAt > createdAt);
if (index === -1) {
return [...previousRuns, run];
}
return [...previousRuns.slice(0, index), run, ...previousRuns.slice(index)];
}
async function processRealtimeRunWithStreams<
TTask extends AnyTask = AnyTask,
TStreams extends Record<string, any> = Record<string, any>,
>(
runId: string,
apiClient: ApiClient,
mutateRunData: KeyedMutator<RealtimeRun<TTask>>,
mutateStreamData: KeyedMutator<StreamResults<TStreams>>,
existingDataRef: React.MutableRefObject<StreamResults<TStreams>>,
abortControllerRef: React.MutableRefObject<AbortController | null>,
throttleInMs?: number
) {
const subscription = apiClient.subscribeToRun<InferRunTypes<TTask>>(runId, {
signal: abortControllerRef.current?.signal,
});
type StreamUpdate = {
type: keyof TStreams;
chunk: any;
};
const streamQueue = createThrottledQueue<StreamUpdate>(async (updates) => {
const nextStreamData = { ...existingDataRef.current };
// Group updates by type
const updatesByType = updates.reduce(
(acc, update) => {
if (!acc[update.type]) {
acc[update.type] = [];
}
acc[update.type].push(update.chunk);
return acc;
},
{} as Record<keyof TStreams, any[]>
);
// Apply all updates
for (const [type, chunks] of Object.entries(updatesByType)) {
// @ts-ignore
nextStreamData[type] = [...(existingDataRef.current[type] || []), ...chunks];
}
await mutateStreamData(nextStreamData);
}, throttleInMs);
for await (const part of subscription.withStreams<TStreams>()) {
if (part.type === "run") {
mutateRunData(part.run);
} else {
streamQueue.add({
type: part.type,
// @ts-ignore
chunk: part.chunk,
});
}
}
}
async function processRealtimeRun<TTask extends AnyTask = AnyTask>(
runId: string,
apiClient: ApiClient,
mutateRunData: KeyedMutator<RealtimeRun<TTask>>,
abortControllerRef: React.MutableRefObject<AbortController | null>
) {
const subscription = apiClient.subscribeToRun<InferRunTypes<TTask>>(runId, {
signal: abortControllerRef.current?.signal,
});
for await (const part of subscription) {
mutateRunData(part);
}
}
@@ -1,66 +0,0 @@
"use client";
import { AnyTask, InferRunTypes, TaskRunShape } from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
import { useApiClient } from "./useApiClient.js";
/**
* hook to subscribe to realtime updates of a batch of task runs.
*
* @template TTask - The type of the task.
* @param {string} batchId - The unique identifier of the batch to subscribe to.
* @returns {{ runs: TaskRunShape<TTask>[], error: Error | null }} An object containing the current state of the runs and any error encountered.
*
* @example
*
* ```ts
* import type { myTask } from './path/to/task';
* const { runs, error } = useRealtimeBatch<typeof myTask>('batch-id-123');
* ```
*/
export function useRealtimeBatch<TTask extends AnyTask>(batchId: string) {
const [runShapes, setRunShapes] = useState<TaskRunShape<TTask>[]>([]);
const [error, setError] = useState<Error | null>(null);
const apiClient = useApiClient();
useEffect(() => {
const subscription = apiClient.subscribeToBatch<InferRunTypes<TTask>>(batchId);
async function iterateUpdates() {
for await (const run of subscription) {
setRunShapes((prevRuns) => {
return insertRunShapeInOrder(prevRuns, run);
});
}
}
iterateUpdates().catch((err) => {
setError(err);
});
return () => {
subscription.unsubscribe();
};
}, [batchId]);
return { runs: runShapes, error };
}
// Inserts and then orders by the run number, and ensures that the run is not duplicated
function insertRunShapeInOrder<TTask extends AnyTask>(
previousRuns: TaskRunShape<TTask>[],
run: TaskRunShape<TTask>
) {
const existingRun = previousRuns.find((r) => r.id === run.id);
if (existingRun) {
return previousRuns.map((r) => (r.id === run.id ? run : r));
}
const runNumber = run.number;
const index = previousRuns.findIndex((r) => r.number > runNumber);
if (index === -1) {
return [...previousRuns, run];
}
return [...previousRuns.slice(0, index), run, ...previousRuns.slice(index)];
}
@@ -1,46 +0,0 @@
"use client";
import { AnyTask, InferRunTypes, TaskRunShape } from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
import { useApiClient } from "./useApiClient.js";
/**
* hook to subscribe to realtime updates of a task run.
*
* @template TTask - The type of the task.
* @param {string} runId - The unique identifier of the run to subscribe to.
* @returns {{ run: TaskRunShape<TTask> | undefined, error: Error | null }} An object containing the current state of the run and any error encountered.
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { run, error } = useRealtimeRun<typeof myTask>('run-id-123');
* ```
*/
export function useRealtimeRun<TTask extends AnyTask>(
runId: string
): { run: TaskRunShape<TTask> | undefined; error: Error | null } {
const [runShape, setRunShape] = useState<TaskRunShape<TTask> | undefined>(undefined);
const [error, setError] = useState<Error | null>(null);
const apiClient = useApiClient();
useEffect(() => {
const subscription = apiClient.subscribeToRun<InferRunTypes<TTask>>(runId);
async function iterateUpdates() {
for await (const run of subscription) {
setRunShape(run);
}
}
iterateUpdates().catch((err) => {
setError(err);
});
return () => {
subscription.unsubscribe();
};
}, [runId]);
return { run: runShape, error };
}
@@ -1,58 +0,0 @@
"use client";
import { AnyTask, InferRunTypes, TaskRunShape } from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
import { useApiClient } from "./useApiClient.js";
export function useRealtimeRunsWithTag<TTask extends AnyTask>(tag: string | string[]) {
const [runShapes, setRunShapes] = useState<TaskRunShape<TTask>[]>([]);
const [error, setError] = useState<Error | null>(null);
const apiClient = useApiClient();
useEffect(() => {
const subscription = apiClient.subscribeToRunsWithTag<InferRunTypes<TTask>>(tag);
async function iterateUpdates() {
for await (const run of subscription) {
setRunShapes((prevRuns) => {
return insertRunShape(prevRuns, run);
});
}
}
iterateUpdates().catch((err) => {
setError(err);
});
return () => {
subscription.unsubscribe();
};
}, [tag]);
return { runs: runShapes, error };
}
function stableSortTags(tag: string | string[]) {
return Array.isArray(tag) ? tag.slice().sort() : [tag];
}
// Replaces or inserts a run shape, ordered by the createdAt timestamp
function insertRunShape<TTask extends AnyTask>(
previousRuns: TaskRunShape<TTask>[],
run: TaskRunShape<TTask>
) {
const existingRun = previousRuns.find((r) => r.id === run.id);
if (existingRun) {
return previousRuns.map((r) => (r.id === run.id ? run : r));
}
const createdAt = run.createdAt;
const index = previousRuns.findIndex((r) => r.createdAt > createdAt);
if (index === -1) {
return [...previousRuns, run];
}
return [...previousRuns.slice(0, index), run, ...previousRuns.slice(index)];
}
@@ -0,0 +1,211 @@
"use client";
import {
type AnyTask,
type TaskIdentifier,
type TaskPayload,
InferRunTypes,
makeIdempotencyKey,
RunHandleFromTypes,
stringifyIO,
TaskRunOptions,
} from "@trigger.dev/core/v3";
import useSWRMutation from "swr/mutation";
import { useApiClient, UseApiClientOptions } from "./useApiClient.js";
import {
useRealtimeRun,
UseRealtimeRunInstance,
useRealtimeRunWithStreams,
UseRealtimeRunWithStreamsInstance,
} from "./useRealtime.js";
/**
* Base interface for task trigger instances.
*
* @template TTask - The type of the task
*/
export interface TriggerInstance<TTask extends AnyTask> {
/** Function to submit the task with a payload */
submit: (payload: TaskPayload<TTask>) => void;
/** Whether the task is currently being submitted */
isLoading: boolean;
/** The handle returned after successful task submission */
handle?: RunHandleFromTypes<InferRunTypes<TTask>>;
/** Any error that occurred during submission */
error?: Error;
}
export type UseTaskTriggerOptions = UseApiClientOptions;
/**
* Hook to trigger a task and manage its initial execution state.
*
* @template TTask - The type of the task
* @param {TaskIdentifier<TTask>} id - The identifier of the task to trigger
* @param {UseTaskTriggerOptions} [options] - Configuration options for the task trigger
* @returns {TriggerInstance<TTask>} An object containing the submit function, loading state, handle, and any errors
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { submit, isLoading, handle, error } = useTaskTrigger<typeof myTask>('my-task-id');
*
* // Submit the task with payload
* submit({ foo: 'bar' });
* ```
*/
export function useTaskTrigger<TTask extends AnyTask>(
id: TaskIdentifier<TTask>,
options?: UseTaskTriggerOptions
): TriggerInstance<TTask> {
const apiClient = useApiClient(options);
async function triggerTask(
id: string,
{
arg: { payload, options },
}: { arg: { payload: TaskPayload<TTask>; options?: TaskRunOptions } }
) {
const payloadPacket = await stringifyIO(payload);
const handle = await apiClient.triggerTask(id, {
payload: payloadPacket.data,
options: {
queue: options?.queue,
concurrencyKey: options?.concurrencyKey,
payloadType: payloadPacket.dataType,
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
delay: options?.delay,
ttl: options?.ttl,
tags: options?.tags,
maxAttempts: options?.maxAttempts,
metadata: options?.metadata,
maxDuration: options?.maxDuration,
},
});
return { ...handle, taskIdentifier: id };
}
const mutation = useSWRMutation(id as string, triggerTask);
return {
submit: (payload) => {
// trigger the task with the given payload
mutation.trigger({ payload });
},
isLoading: mutation.isMutating,
handle: mutation.data as RunHandleFromTypes<InferRunTypes<TTask>>,
error: mutation.error,
};
}
/**
* Configuration options for task triggers with realtime updates.
*/
export type UseRealtimeTaskTriggerOptions = UseTaskTriggerOptions & {
/** Whether the realtime subscription is enabled */
enabled?: boolean;
/** Optional throttle time in milliseconds for stream updates */
experimental_throttleInMs?: number;
};
export type RealtimeTriggerInstanceWithStreams<
TTask extends AnyTask,
TStreams extends Record<string, any> = Record<string, any>,
> = UseRealtimeRunWithStreamsInstance<TTask, TStreams> & {
submit: (payload: TaskPayload<TTask>) => void;
isLoading: boolean;
handle?: RunHandleFromTypes<InferRunTypes<TTask>>;
};
/**
* Hook to trigger a task and subscribe to its realtime updates including stream data.
*
* @template TTask - The type of the task
* @template TStreams - The type of the streams data
* @param {TaskIdentifier<TTask>} id - The identifier of the task to trigger
* @param {UseRealtimeTaskTriggerOptions} [options] - Configuration options for the task trigger and realtime updates
* @returns {RealtimeTriggerInstanceWithStreams<TTask, TStreams>} An object containing the submit function, loading state,
* handle, run state, streams data, and error handling
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { submit, run, streams, error } = useRealtimeTaskTriggerWithStreams<
* typeof myTask,
* { output: string }
* >('my-task-id');
*
* // Submit and monitor the task with streams
* submit({ foo: 'bar' });
* ```
*/
export function useRealtimeTaskTriggerWithStreams<
TTask extends AnyTask,
TStreams extends Record<string, any> = Record<string, any>,
>(
id: TaskIdentifier<TTask>,
options?: UseRealtimeTaskTriggerOptions
): RealtimeTriggerInstanceWithStreams<TTask, TStreams> {
const triggerInstance = useTaskTrigger<TTask>(id, options);
const realtimeInstance = useRealtimeRunWithStreams<TTask, TStreams>(triggerInstance.handle?.id, {
...options,
id: triggerInstance.handle?.id,
accessToken: triggerInstance.handle?.publicAccessToken ?? options?.accessToken,
});
return {
...realtimeInstance,
...triggerInstance,
};
}
export type RealtimeTriggerInstance<TTask extends AnyTask> = UseRealtimeRunInstance<TTask> & {
submit: (payload: TaskPayload<TTask>) => void;
isLoading: boolean;
handle?: RunHandleFromTypes<InferRunTypes<TTask>>;
};
/**
* Hook to trigger a task and subscribe to its realtime updates.
*
* @template TTask - The type of the task
* @param {TaskIdentifier<TTask>} id - The identifier of the task to trigger
* @param {UseRealtimeTaskTriggerOptions} [options] - Configuration options for the task trigger and realtime updates
* @returns {RealtimeTriggerInstance<TTask>} An object containing the submit function, loading state,
* handle, run state, and error handling
*
* @example
* ```ts
* import type { myTask } from './path/to/task';
* const { submit, run, error, stop } = useRealtimeTaskTrigger<typeof myTask>('my-task-id');
*
* // Submit and monitor the task
* submit({ foo: 'bar' });
*
* // Stop monitoring when needed
* stop();
* ```
*/
export function useRealtimeTaskTrigger<TTask extends AnyTask>(
id: TaskIdentifier<TTask>,
options?: UseRealtimeTaskTriggerOptions
): RealtimeTriggerInstance<TTask> {
const triggerInstance = useTaskTrigger<TTask>(id, options);
const realtimeInstance = useRealtimeRun<TTask>(triggerInstance.handle?.id, {
...options,
id: triggerInstance.handle?.id,
accessToken: triggerInstance.handle?.publicAccessToken ?? options?.accessToken,
});
return {
submit: triggerInstance.submit,
isLoading: triggerInstance.isLoading,
handle: triggerInstance.handle,
run: realtimeInstance.run,
error: realtimeInstance.error ?? triggerInstance.error,
stop: realtimeInstance.stop,
};
}
+2 -3
View File
@@ -1,6 +1,5 @@
export * from "./contexts.js";
export * from "./hooks/useApiClient.js";
export * from "./hooks/useRun.js";
export * from "./hooks/useRealtimeRun.js";
export * from "./hooks/useRealtimeRunsWithTag.js";
export * from "./hooks/useRealtimeBatch.js";
export * from "./hooks/useRealtime.js";
export * from "./hooks/useTaskTrigger.js";
@@ -0,0 +1,58 @@
// Reusable throttle utility
export type ThrottledQueue<T> = {
add: (item: T) => void;
flush: () => Promise<void>;
isEmpty: () => boolean;
};
export function createThrottledQueue<T>(
onFlush: (items: T[]) => Promise<void>,
throttleInMs?: number
): ThrottledQueue<T> {
let queue: T[] = [];
let lastFlushTime = 0;
let flushPromise: Promise<void> | null = null;
const scheduleFlush = async () => {
// If no throttle specified or there's already a flush in progress, return
if (!throttleInMs) {
// Immediately flush when no throttling is specified
const itemsToFlush = [...queue];
queue = [];
await onFlush(itemsToFlush);
return;
}
if (queue.length === 0 || flushPromise) return;
const now = Date.now();
const timeUntilNextFlush = Math.max(0, lastFlushTime + throttleInMs - now);
if (timeUntilNextFlush === 0) {
const itemsToFlush = [...queue];
queue = [];
lastFlushTime = now;
flushPromise = onFlush(itemsToFlush).finally(() => {
flushPromise = null;
// Check if more items accumulated during flush
scheduleFlush();
});
} else {
setTimeout(scheduleFlush, timeUntilNextFlush);
}
};
return {
add: (item: T) => {
queue.push(item);
scheduleFlush();
},
flush: async () => {
if (queue.length === 0) return;
const itemsToFlush = [...queue];
queue = [];
await onFlush(itemsToFlush);
},
isEmpty: () => queue.length === 0,
};
}
+1
View File
@@ -0,0 +1 @@
# @trigger.dev/rsc
+1
View File
@@ -0,0 +1 @@
## trigger.dev rsc
+76
View File
@@ -0,0 +1,76 @@
{
"name": "@trigger.dev/rsc",
"version": "3.1.2",
"description": "trigger.dev rsc",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/rsc"
},
"type": "module",
"files": [
"dist"
],
"tshy": {
"selfLink": false,
"main": true,
"module": true,
"project": "./tsconfig.json",
"exports": {
"./package.json": "./package.json",
".": "./src/index.ts"
},
"sourceDialects": [
"@triggerdotdev/source"
]
},
"scripts": {
"clean": "rimraf dist",
"build": "tshy && pnpm run update-version",
"dev": "tshy --watch",
"typecheck": "tsc --noEmit",
"update-version": "tsx ../../scripts/updateVersion.ts",
"check-exports": "attw --pack ."
},
"dependencies": {
"@trigger.dev/core": "workspace:^3.1.2",
"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.1.2",
"@types/node": "^20.14.14",
"@types/react": "*",
"@types/react-dom": "*",
"rimraf": "^3.0.2",
"tshy": "^3.0.2",
"tsx": "4.17.0",
"typescript": "^5.5.4"
},
"engines": {
"node": ">=18.20.0"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"@triggerdotdev/source": "./src/index.ts",
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/commonjs/index.d.ts",
"default": "./dist/commonjs/index.js"
}
}
},
"main": "./dist/commonjs/index.js",
"types": "./dist/commonjs/index.d.ts",
"module": "./dist/esm/index.js"
}
+118
View File
@@ -0,0 +1,118 @@
import { BuildExtension } from "@trigger.dev/core/v3/build";
import { sourceDir } from "./sourceDir.js";
export type RSCExtensionOptions = {
resolveDir?: string;
reactDomEnvironment?: "node" | "worker" | "bun";
};
export function rscExtension(options?: RSCExtensionOptions): BuildExtension {
return {
name: "rsc",
onBuildStart(context) {
context.addLayer({
id: "rsc",
conditions: ["react-server"],
});
const srcDir = options?.resolveDir ?? sourceDir;
context.config.build.conditions ??= [];
context.config.build.conditions.push("react-server");
context.registerPlugin({
name: "rsc",
async setup(build) {
const { resolvePathSync: esmResolveSync } = await import("mlly");
build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, (args) => {
context.logger.debug("Resolving jsx-dev-runtime", { args });
try {
const resolvedPath = esmResolveSync(args.path, {
url: srcDir,
conditions: ["react-server"],
});
context.logger.debug("Resolved jsx-dev-runtime", { resolvedPath });
return {
path: resolvedPath,
};
} catch (error) {
context.logger.debug("Failed to resolve jsx-dev-runtime", { error });
}
return undefined;
});
build.onResolve({ filter: /^react\/jsx-runtime$/ }, (args) => {
context.logger.debug("Resolving jsx-runtime", { args });
try {
const resolvedPath = esmResolveSync(args.path, {
url: srcDir,
conditions: ["react-server"],
});
context.logger.debug("Resolved jsx-runtime", { resolvedPath });
return {
path: resolvedPath,
};
} catch (error) {
context.logger.debug("Failed to resolve jsx-runtime", { error });
}
return undefined;
});
build.onResolve({ filter: /^(react|react-dom)$/ }, (args) => {
context.logger.debug("Resolving react", { args });
try {
const resolvedPath = esmResolveSync(args.path, {
url: srcDir,
conditions: ["react-server"],
});
context.logger.debug("Resolved react", { resolvedPath });
return {
path: resolvedPath,
};
} catch (error) {
context.logger.debug("Failed to resolve react", { error });
}
return undefined;
});
build.onResolve({ filter: /^react-dom\/server$/ }, (args) => {
const condition =
context.config.runtime === "bun" ? "bun" : options?.reactDomEnvironment ?? "node";
context.logger.debug("Resolving react-dom/server", { args, condition });
try {
const resolvedPath = esmResolveSync(args.path, {
url: srcDir,
conditions: [condition],
});
context.logger.debug("Resolved react-dom/server", { resolvedPath });
return {
path: resolvedPath,
};
} catch (error) {
context.logger.debug("Failed to resolve react-dom/server", { error });
}
return undefined;
});
},
});
},
};
}
+1
View File
@@ -0,0 +1 @@
export * from "./build.js";
+3
View File
@@ -0,0 +1,3 @@
import { pathToFileURL } from "node:url";
//@ts-ignore - Have to ignore because TSC thinks this is ESM
export const sourceDir = pathToFileURL(__dirname).toString();
+3
View File
@@ -0,0 +1,3 @@
import { fileURLToPath } from "node:url";
//@ts-ignore
export const sourceDir = fileURLToPath(new URL(".", import.meta.url));
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../.configs/tsconfig.base.json",
"compilerOptions": {
"isolatedDeclarations": false,
"composite": true,
"sourceMap": true,
"stripInternal": true
},
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
}
+7 -3
View File
@@ -57,8 +57,7 @@
"terminal-link": "^3.0.0",
"ulid": "^2.3.0",
"uuid": "^9.0.0",
"ws": "^8.11.0",
"zod": "3.22.3"
"ws": "^8.11.0"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.15.4",
@@ -67,12 +66,17 @@
"@types/slug": "^5.0.3",
"@types/uuid": "^9.0.0",
"@types/ws": "^8.5.3",
"ai": "^3.4.33",
"encoding": "^0.1.13",
"rimraf": "^3.0.2",
"tshy": "^3.0.2",
"tsx": "4.17.0",
"typed-emitter": "^2.1.0",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"zod": "3.22.3"
},
"peerDependencies": {
"zod": "^3.0.0"
},
"engines": {
"node": ">=18.20.0"
+3 -1
View File
@@ -28,7 +28,7 @@ export const auth = {
withAuth,
};
type PublicTokenPermissionAction = "read"; // Add more actions as needed
type PublicTokenPermissionAction = "read" | "write"; // Add more actions as needed
type PublicTokenPermissionProperties = {
/**
@@ -150,6 +150,8 @@ function flattenScopes(permissions: PublicTokenPermissions): string[] {
}
} else if (typeof value === "string") {
flattenedPermissions.push(`${action}:${property}:${value}`);
} else if (typeof value === "boolean" && value) {
flattenedPermissions.push(`${action}:${property}`);
}
}
}
+1 -85
View File
@@ -1,91 +1,7 @@
import { type IdempotencyKey, taskContext } from "@trigger.dev/core/v3";
import { createIdempotencyKey, type IdempotencyKey } from "@trigger.dev/core/v3";
export const idempotencyKeys = {
create: createIdempotencyKey,
};
export type { IdempotencyKey };
export function isIdempotencyKey(
value: string | string[] | IdempotencyKey
): value is IdempotencyKey {
// Cannot check the brand at runtime because it doesn't exist (it's a TypeScript-only construct)
return typeof value === "string" && value.length === 64;
}
/**
* Creates a deterministic idempotency key based on the provided key material.
*
* If running inside a task, the task run ID is automatically included in the key material, giving you a unique key per task run.
* This ensures that a given child task is only triggered once per task run, even if the parent task is retried.
*
* @param {string | string[]} key The key material to create the idempotency key from.
* @param {object} [options] Additional options.
* @param {"run" | "attempt" | "global"} [options.scope="run"] The scope of the idempotency key.
*
* @returns {Promise<IdempotencyKey>} The idempotency key as a branded string.
*
* @example
*
* ```typescript
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
*
* export const myTask = task({
* id: "my-task",
* run: async (payload: any) => {
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
*
* // Use the idempotency key when triggering child tasks
* await childTask.triggerAndWait(payload, { idempotencyKey });
* }
* });
* ```
*
* You can also use the `scope` parameter to create a key that is unique per task run, task run attempts (retries of the same run), or globally:
*
* ```typescript
* await idempotencyKeys.create("my-task-key", { scope: "attempt" }); // Creates a key that is unique per task run attempt
* await idempotencyKeys.create("my-task-key", { scope: "global" }); // Skips including the task run ID
* ```
*/
async function createIdempotencyKey(
key: string | string[],
options?: { scope?: "run" | "attempt" | "global" }
): Promise<IdempotencyKey> {
const idempotencyKey = await generateIdempotencyKey(
[...(Array.isArray(key) ? key : [key])].concat(injectScope(options?.scope ?? "run"))
);
return idempotencyKey as IdempotencyKey;
}
function injectScope(scope: "run" | "attempt" | "global"): string[] {
switch (scope) {
case "run": {
if (taskContext?.ctx) {
return [taskContext.ctx.run.id];
}
break;
}
case "attempt": {
if (taskContext?.ctx) {
return [taskContext.ctx.attempt.id];
}
break;
}
}
return [];
}
async function generateIdempotencyKey(keyMaterial: string[]) {
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(keyMaterial.join("-"))
);
// Return a hex string, using cross-runtime compatible methods
return Array.from(new Uint8Array(hash))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
+3
View File
@@ -4,11 +4,13 @@ export { retry, type RetryOptions } from "./retry.js";
export { queue } from "./shared.js";
export * from "./tasks.js";
export * from "./wait.js";
export * from "./waitUntil.js";
export * from "./usage.js";
export * from "./idempotencyKeys.js";
export * from "./tags.js";
export * from "./metadata.js";
export * from "./timeout.js";
export * from "./waitUntil.js";
export type { Context };
import type { Context } from "./shared.js";
@@ -37,6 +39,7 @@ export {
type RunShape,
type AnyRunShape,
type TaskRunShape,
type RealtimeRun,
type RetrieveRunResult,
type AnyRetrieveRunResult,
} from "./runs.js";
+74
View File
@@ -25,6 +25,11 @@ export const metadata = {
save: saveMetadata,
replace: replaceMetadata,
flush: flushMetadata,
stream: stream,
append: appendMetadataKey,
remove: removeMetadataKey,
increment: incrementMetadataKey,
decrement: decrementMetadataKey,
};
export type RunMetadata = Record<string, DeserializedJson>;
@@ -105,6 +110,67 @@ function saveMetadata(metadata: RunMetadata): void {
runMetadata.update(metadata);
}
/**
* Increments a numeric value in the metadata of the current run by the specified amount.
* This function allows you to atomically increment a numeric metadata value.
*
* @param {string} key - The key of the numeric value to increment.
* @param {number} value - The amount to increment the value by.
*
* @example
* metadata.increment("counter", 1); // Increments counter by 1
* metadata.increment("score", 10); // Increments score by 10
*/
function incrementMetadataKey(key: string, value: number = 1) {
runMetadata.incrementKey(key, value);
}
/**
* Decrements a numeric value in the metadata of the current run by the specified amount.
* This function allows you to atomically decrement a numeric metadata value.
*
* @param {string} key - The key of the numeric value to decrement.
* @param {number} value - The amount to decrement the value by.
*
* @example
* metadata.decrement("counter", 1); // Decrements counter by 1
* metadata.decrement("score", 5); // Decrements score by 5
*/
function decrementMetadataKey(key: string, value: number = 1) {
runMetadata.decrementKey(key, value);
}
/**
* Appends a value to an array in the metadata of the current run.
* If the key doesn't exist, it creates a new array with the value.
* If the key exists but isn't an array, it converts the existing value to an array.
*
* @param {string} key - The key of the array in metadata.
* @param {DeserializedJson} value - The value to append to the array.
*
* @example
* metadata.append("logs", "User logged in");
* metadata.append("events", { type: "click", timestamp: Date.now() });
*/
function appendMetadataKey(key: string, value: DeserializedJson) {
runMetadata.appendKey(key, value);
}
/**
* Removes a value from an array in the metadata of the current run.
*
* @param {string} key - The key of the array in metadata.
* @param {DeserializedJson} value - The value to remove from the array.
*
* @example
*
* metadata.remove("logs", "User logged in");
* metadata.remove("events", { type: "click", timestamp: Date.now() });
*/
function removeMetadataKey(key: string, value: DeserializedJson) {
runMetadata.removeFromKey(key, value);
}
/**
* Flushes metadata to the Trigger.dev instance
*
@@ -123,3 +189,11 @@ async function flushMetadata(requestOptions?: ApiRequestOptions): Promise<void>
await runMetadata.flush($requestOptions);
}
async function stream<T>(
key: string,
value: AsyncIterable<T> | ReadableStream<T>,
signal?: AbortSignal
): Promise<AsyncIterable<T>> {
return runMetadata.stream(key, value, signal);
}
+9 -2
View File
@@ -8,8 +8,8 @@ import type {
RescheduleRunRequestBody,
RetrieveRunResult,
RunShape,
RealtimeRun,
RunSubscription,
SubscribeToRunsQueryParams,
TaskRunShape,
} from "@trigger.dev/core/v3";
import {
@@ -29,7 +29,14 @@ import { resolvePresignedPacketUrl } from "@trigger.dev/core/v3/utils/ioSerializ
import { AnyRunHandle, AnyTask } from "./shared.js";
import { tracer } from "./tracer.js";
export type { AnyRetrieveRunResult, AnyRunShape, RetrieveRunResult, RunShape, TaskRunShape };
export type {
AnyRetrieveRunResult,
AnyRunShape,
RetrieveRunResult,
RunShape,
TaskRunShape,
RealtimeRun,
};
export const runs = {
replay: replayRun,
+44 -21
View File
@@ -11,11 +11,13 @@ import {
ApiRequestOptions,
BatchTaskRunExecutionResult,
conditionallyImportPacket,
convertToolParametersToSchema,
createErrorTaskError,
defaultRetryOptions,
getSchemaParseFn,
InitOutput,
logger,
makeIdempotencyKey,
parsePacket,
Queue,
QueueOptions,
@@ -29,7 +31,6 @@ import {
TaskRunExecutionResult,
TaskRunPromise,
} from "@trigger.dev/core/v3";
import { IdempotencyKey, idempotencyKeys, isIdempotencyKey } from "./idempotencyKeys.js";
import { PollOptions, runs } from "./runs.js";
import { tracer } from "./tracer.js";
@@ -43,6 +44,7 @@ import type {
BatchRunHandleFromTypes,
InferRunTypes,
inferSchemaIn,
inferToolParameters,
RetrieveRunResult,
RunHandle,
RunHandleFromTypes,
@@ -60,9 +62,14 @@ import type {
TaskRunOptions,
TaskRunResult,
TaskSchema,
TaskWithSchema,
TaskWithSchemaOptions,
TaskWithToolOptions,
ToolTask,
ToolTaskParameters,
TriggerApiRequestOptions,
} from "@trigger.dev/core/v3";
import { z } from "zod";
export type {
AnyRunHandle,
@@ -111,6 +118,7 @@ export function createTask<
const task: Task<TIdentifier, TInput, TOutput> = {
id: params.id,
description: params.description,
trigger: async (payload, options) => {
const taskMetadata = taskCatalog.getTaskManifest(params.id);
@@ -183,6 +191,7 @@ export function createTask<
taskCatalog.registerTaskMetadata({
id: params.id,
description: params.description,
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: params.machine,
@@ -205,6 +214,31 @@ export function createTask<
return task;
}
export function createToolTask<
TIdentifier extends string,
TParameters extends ToolTaskParameters,
TOutput = unknown,
TInitOutput extends InitOutput = any,
>(
params: TaskWithToolOptions<TIdentifier, TParameters, TOutput, TInitOutput>
): ToolTask<TIdentifier, TParameters, TOutput> {
const task = createSchemaTask({
...params,
schema: convertToolParametersToSchema(params.parameters),
});
return {
...task,
tool: {
parameters: params.parameters,
description: params.description,
execute: async (args: inferToolParameters<TParameters>) => {
return task.triggerAndWait(args).unwrap();
},
},
};
}
export function createSchemaTask<
TIdentifier extends string,
TSchema extends TaskSchema | undefined = undefined,
@@ -212,7 +246,7 @@ export function createSchemaTask<
TInitOutput extends InitOutput = any,
>(
params: TaskWithSchemaOptions<TIdentifier, TSchema, TOutput, TInitOutput>
): Task<TIdentifier, inferSchemaIn<TSchema>, TOutput> {
): TaskWithSchema<TIdentifier, TSchema, TOutput> {
const customQueue = params.queue
? queue({
name: params.queue?.name ?? `task/${params.id}`,
@@ -224,8 +258,10 @@ export function createSchemaTask<
? getSchemaParseFn<inferSchemaIn<TSchema>>(params.schema)
: undefined;
const task: Task<TIdentifier, inferSchemaIn<TSchema>, TOutput> = {
const task: TaskWithSchema<TIdentifier, TSchema, TOutput> = {
id: params.id,
description: params.description,
schema: params.schema,
trigger: async (payload, options, requestOptions) => {
const taskMetadata = taskCatalog.getTaskManifest(params.id);
@@ -299,6 +335,7 @@ export function createSchemaTask<
taskCatalog.registerTaskMetadata({
id: params.id,
description: params.description,
queue: params.queue,
retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined,
machine: params.machine,
@@ -497,7 +534,7 @@ async function trigger_internal<TRunTypes extends AnyRunTypes>(
concurrencyKey: options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: payloadPacket.dataType,
idempotencyKey: await makeKey(options?.idempotencyKey),
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
delay: options?.delay,
ttl: options?.ttl,
tags: options?.tags,
@@ -560,7 +597,7 @@ async function batchTrigger_internal<TRunTypes extends AnyRunTypes>(
concurrencyKey: item.options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: payloadPacket.dataType,
idempotencyKey: await makeKey(item.options?.idempotencyKey),
idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey),
delay: item.options?.delay,
ttl: item.options?.ttl,
tags: item.options?.tags,
@@ -630,7 +667,7 @@ async function triggerAndWait_internal<TPayload, TOutput>(
concurrencyKey: options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: payloadPacket.dataType,
idempotencyKey: await makeKey(options?.idempotencyKey),
idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey),
delay: options?.delay,
ttl: options?.ttl,
tags: options?.tags,
@@ -727,7 +764,7 @@ async function batchTriggerAndWait_internal<TPayload, TOutput>(
concurrencyKey: item.options?.concurrencyKey,
test: taskContext.ctx?.run.isTest,
payloadType: payloadPacket.dataType,
idempotencyKey: await makeKey(item.options?.idempotencyKey),
idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey),
delay: item.options?.delay,
ttl: item.options?.ttl,
tags: item.options?.tags,
@@ -892,17 +929,3 @@ async function handleTaskRunExecutionResult<TOutput>(
};
}
}
async function makeKey(
idempotencyKey?: IdempotencyKey | string | string[]
): Promise<IdempotencyKey | undefined> {
if (!idempotencyKey) {
return;
}
if (isIdempotencyKey(idempotencyKey)) {
return idempotencyKey;
}
return await idempotencyKeys.create(idempotencyKey, { scope: "global" });
}
+3
View File
@@ -3,6 +3,7 @@ import {
batchTriggerAndWait,
createTask,
createSchemaTask,
createToolTask,
SubtaskUnwrapError,
trigger,
triggerAndPoll,
@@ -65,6 +66,8 @@ export const task = createTask;
export const schemaTask = createSchemaTask;
export const toolTask = createToolTask;
export const tasks = {
trigger,
triggerAndPoll,
+13
View File
@@ -0,0 +1,13 @@
import { waitUntil as core_waitUntil } from "@trigger.dev/core/v3";
/**
* waitUntil extends the lifetime of a task run until the provided promise settles.
* You can use this function to ensure that a task run does not complete until the promise resolves or rejects.
*
* Useful if you need to make sure something happens but you wait to continue doing other work in the task run.
*
* @param promise - The promise to wait for.
*/
export function waitUntil(promise: Promise<any>) {
return core_waitUntil.register({ promise, requiresResolving: () => true });
}
+539 -47
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('next').NextConfig} */
import NextBundleAnalyzer from "@next/bundle-analyzer";
const nextConfig = {
images: {
remotePatterns: [
@@ -27,4 +29,7 @@ const nextConfig = {
},
};
export default nextConfig;
export default NextBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
openAnalyzer: true,
})(nextConfig);
+6
View File
@@ -10,17 +10,21 @@
"dev:trigger": "trigger dev"
},
"dependencies": {
"@ai-sdk/openai": "^1.0.1",
"@fal-ai/serverless-client": "^0.15.0",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-scroll-area": "^1.2.0",
"@radix-ui/react-slot": "^1.1.0",
"@trigger.dev/react-hooks": "workspace:^3",
"@trigger.dev/sdk": "workspace:^3",
"@uploadthing/react": "^7.0.3",
"ai": "^4.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.451.0",
"next": "14.2.15",
"openai": "^4.68.4",
"react": "^18",
"react-dom": "^18",
"tailwind-merge": "^2.5.3",
@@ -29,6 +33,8 @@
"zod": "3.22.3"
},
"devDependencies": {
"@next/bundle-analyzer": "^15.0.2",
"@trigger.dev/rsc": "workspace:^3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
@@ -0,0 +1,83 @@
"use client";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import type { openaiStreaming, STREAMS } from "@/trigger/ai";
import { useRealtimeRunWithStreams } from "@trigger.dev/react-hooks";
function AiRunDetailsWrapper({ runId, accessToken }: { runId: string; accessToken: string }) {
const { run, streams, error } = useRealtimeRunWithStreams<typeof openaiStreaming, STREAMS>(
runId,
{
accessToken,
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
}
);
if (error) {
return (
<div className="w-full min-h-screen bg-gray-900 p-4">
<Card className="w-full bg-gray-800 shadow-md">
<CardContent className="pt-6">
<p className="text-red-600">Error: {error.message}</p>
</CardContent>
</Card>
</div>
);
}
if (!run) {
return (
<div className="w-full min-h-screen bg-gray-900 py-4 px-6 grid place-items-center">
<Card className="w-fit bg-gray-800 border border-gray-700 shadow-md">
<CardContent className="pt-6">
<p className="text-gray-200">Loading run details</p>
</CardContent>
</Card>
</div>
);
}
const toolCall = streams.openai?.find(
(stream) => stream.type === "tool-call" && stream.toolName === "getWeather"
);
const toolResult = streams.openai?.find((stream) => stream.type === "tool-result");
const textDeltas = streams.openai?.filter((stream) => stream.type === "text-delta");
const text = textDeltas?.map((delta) => delta.textDelta).join("");
const weatherLocation = toolCall ? toolCall.args.location : undefined;
const weather = toolResult ? toolResult.result.temperature : undefined;
return (
<div className="flex items-center justify-center min-h-screen bg-gray-100 p-4">
<Card className="w-full max-w-3xl">
<CardContent className="p-6">
<div className="h-[calc(100vh-12rem)] overflow-y-auto">
{weather ? (
<p className="text-lg leading-relaxed">{text || "Preparing weather report..."}</p>
) : (
<p className="text-lg">Fetching weather data...</p>
)}
</div>
</CardContent>
{weather && (
<CardFooter className="bg-muted p-4">
<p className="text-sm">
<span className="font-semibold">Tool Call:</span> The current temperature in{" "}
{weatherLocation} is {weather}.
</p>
</CardFooter>
)}
</Card>
</div>
);
}
export default function ClientAiDetails({
runId,
publicAccessToken,
}: {
runId: string;
publicAccessToken: string;
}) {
return <AiRunDetailsWrapper runId={runId} accessToken={publicAccessToken} />;
}
@@ -0,0 +1,15 @@
import ClientAiDetails from "./ClientAiDetails";
export default async function DetailsPage({
params,
searchParams,
}: {
params: { id: string };
searchParams: { publicAccessToken: string };
}) {
return (
<main className="flex min-h-screen items-center justify-center p-4 bg-gray-900">
<ClientAiDetails runId={params.id} publicAccessToken={searchParams.publicAccessToken} />
</main>
);
}

Some files were not shown because too many files have changed in this diff Show More