feat: realtime (#1402)

* Denormalize run tags, increase character limit to 128

* WIP realtime subscribing to runs

* extracted the stream stuff into core, made it more reusable

* WIP tags

* Remove tags for now because it’s not support in electric

* Support async iterables, readable stream, and callback style subscription styles

* Remove tags streaming endpoint

* Add realtime rate limits and scope them to the /realtime path

* WIP rate limt per org

* Introduce per org rate limits

* WIP JWT auth

* Move migrations into new internal db package

* Resolve pnpm lock file

* Authenticating to the realtime API with JWTs are working

* realtime in the client

* Created react-hooks package and starting to move stuff in there

* Improve types for hooks

* schema tasks

* Added useBatch hook

* build uploadthing/fal demo and change how run metadata is synced to the server

* tweaks

* WIL realtime concurrency tracking

* Implement test for realtime client using testcontainers

also updated electric to latest version

* Allow customizing the expiration time of the automatic JWT created after triggering a task

* Add support for subscribing to run tags

* Improve auth types and API

* finalize the realtime API

* Fixed some example stuff

* Allow up to 10 run tags

* Remove core from docker-provider tsconfig paths to prevent it from being typechecked

* do the same for the kubernetes provider

* Fixing some typecheck errors

* Fix webapp type errors

* Update @trigger.dev/platform to 1.0.13

* Fix attw error

* Remove from/to in subscribeToRuns query params

* Add tests for the rate limit middleware and add custom JWT rate limits

* turn off webapp test parallelism

* Finish renaming jwt -> publicAccessToken and automatically give the JWT read access to the tags when using trigger

* Add changeset

* Attempt to fix unit tests in CI

* Skip running the auth rate limit middleware tests for now

* Try a beefier machine

* Try and run webapp tests separately

* Setup env vars

* Make sliding window test more reliabile
This commit is contained in:
Eric Allam
2024-10-21 15:07:08 +01:00
committed by GitHub
parent 67542a54e8
commit 2d8a41b18b
153 changed files with 7925 additions and 1526 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@trigger.dev/react-hooks": minor
"@trigger.dev/sdk": minor
"@trigger.dev/core": minor
---
Access run status updates in realtime, from your server or from your frontend
+1 -1
View File
@@ -17,7 +17,7 @@ concurrency:
jobs:
release:
name: 🦋 Changesets Release
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: ubuntu-latest
if: github.repository == 'triggerdotdev/trigger.dev'
outputs:
published: ${{ steps.changesets.outputs.published }}
+13 -3
View File
@@ -6,7 +6,7 @@ on:
jobs:
unitTests:
name: "🧪 Unit Tests"
runs-on: buildjet-8vcpu-ubuntu-2204
runs-on: buildjet-16vcpu-ubuntu-2204
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v4
@@ -30,5 +30,15 @@ jobs:
- name: 📀 Generate Prisma Client
run: pnpm run generate
- name: 🧪 Run Unit Tests
run: pnpm run test
- name: 🧪 Run Webapp Unit Tests
run: pnpm run test --filter webapp
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
SESSION_SECRET: "secret"
MAGIC_LINK_SECRET: "secret"
ENCRYPTION_KEY: "secret"
- name: 🧪 Run Internal Unit Tests
run: pnpm run test --filter "@internal/*"
+2 -1
View File
@@ -1,2 +1,3 @@
link-workspace-packages=false
public-hoist-pattern[]=*prisma*
public-hoist-pattern[]=*prisma*
prefer-workspace-packages=true
+2 -6
View File
@@ -1,8 +1,4 @@
{
"recommendations": [
"denoland.vscode-deno"
],
"unwantedRecommendations": [
]
"recommendations": ["bierner.comment-tagged-templates"],
"unwantedRecommendations": []
}
+1 -5
View File
@@ -6,10 +6,6 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"]
}
"skipLibCheck": true
}
}
+1 -5
View File
@@ -6,10 +6,6 @@
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"]
}
"skipLibCheck": true
}
}
+20 -1
View File
@@ -31,7 +31,7 @@ const EnvironmentSchema = z.object({
REMIX_APP_PORT: z.string().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
APP_ORIGIN: z.string().default("http://localhost:3030"),
ELECTRIC_ORIGIN: z.string(),
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"),
SECRET_STORE: SecretStoreOptionsSchema.default("DATABASE"),
@@ -103,6 +103,25 @@ const EnvironmentSchema = z.object({
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
API_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
API_RATE_LIMIT_JWT_WINDOW: z.string().default("1m"),
API_RATE_LIMIT_JWT_TOKENS: z.coerce.number().int().default(60),
//Realtime rate limiting
/**
* @example "60s"
* @example "1m"
* @example "1h"
* @example "1d"
* @example "1000ms"
* @example "1000s"
*/
REALTIME_RATE_LIMIT_WINDOW: z.string().default("1m"),
REALTIME_RATE_LIMIT_TOKENS: z.coerce.number().int().default(100),
REALTIME_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
REALTIME_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
REALTIME_RATE_LIMIT_LIMITER_LOGS_ENABLED: z.string().default("0"),
//Ingesting event rate limit
INGEST_EVENT_RATE_LIMIT_WINDOW: z.string().default("60s"),
+1 -1
View File
@@ -1,7 +1,7 @@
import { prisma } from "~/db.server";
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
export const MAX_TAGS_PER_RUN = 5;
export const MAX_TAGS_PER_RUN = 10;
export async function createTag({ tag, projectId }: { tag: string; projectId: string }) {
if (tag.trim().length === 0) return;
@@ -62,8 +62,7 @@ type CommonRelatedRun = Prisma.Result<
export class ApiRetrieveRunPresenter extends BasePresenter {
public async call(
friendlyId: string,
env: AuthenticatedEnvironment,
showSecretDetails: boolean
env: AuthenticatedEnvironment
): Promise<RetrieveRunResponse | undefined> {
return this.traceWithEnv("call", env, async (span) => {
const taskRun = await this._replica.taskRun.findFirst({
@@ -72,11 +71,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
runtimeEnvironmentId: env.id,
},
include: {
attempts: {
orderBy: {
createdAt: "desc",
},
},
attempts: true,
lockedToVersion: true,
schedule: true,
tags: true,
@@ -111,50 +106,48 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
let $output: any;
let $outputPresignedUrl: string | undefined;
if (showSecretDetails) {
const payloadPacket = await conditionallyImportPacket({
data: taskRun.payload,
dataType: taskRun.payloadType,
});
const payloadPacket = await conditionallyImportPacket({
data: taskRun.payload,
dataType: taskRun.payloadType,
});
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
$payloadPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
} else {
$payload = await parsePacket(payloadPacket);
}
if (
payloadPacket.dataType === "application/store" &&
typeof payloadPacket.data === "string"
) {
$payloadPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
payloadPacket.data,
"GET"
);
} else {
$payload = await parsePacket(payloadPacket);
}
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const completedAttempt = taskRun.attempts.find(
(a) => a.status === "COMPLETED" && typeof a.output !== null
);
if (taskRun.status === "COMPLETED_SUCCESSFULLY") {
const completedAttempt = taskRun.attempts.find(
(a) => a.status === "COMPLETED" && typeof a.output !== null
);
if (completedAttempt && completedAttempt.output) {
const outputPacket = await conditionallyImportPacket({
data: completedAttempt.output,
dataType: completedAttempt.outputType,
});
if (completedAttempt && completedAttempt.output) {
const outputPacket = await conditionallyImportPacket({
data: completedAttempt.output,
dataType: completedAttempt.outputType,
});
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
$outputPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
} else {
$output = await parsePacket(outputPacket);
}
if (
outputPacket.dataType === "application/store" &&
typeof outputPacket.data === "string"
) {
$outputPresignedUrl = await generatePresignedUrl(
env.project.externalRef,
env.slug,
outputPacket.data,
"GET"
);
} else {
$output = await parsePacket(outputPacket);
}
}
}
@@ -165,6 +158,7 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
payloadPresignedUrl: $payloadPresignedUrl,
output: $output,
outputPresignedUrl: $outputPresignedUrl,
error: ApiRetrieveRunPresenter.apiErrorFromError(taskRun.error),
schedule: taskRun.schedule
? {
id: taskRun.schedule.friendlyId,
@@ -179,17 +173,9 @@ export class ApiRetrieveRunPresenter extends BasePresenter {
},
}
: undefined,
attempts: !showSecretDetails
? []
: taskRun.attempts.map((a) => ({
id: a.friendlyId,
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
createdAt: a.createdAt ?? undefined,
updatedAt: a.updatedAt ?? undefined,
startedAt: a.startedAt ?? undefined,
completedAt: a.completedAt ?? undefined,
error: ApiRetrieveRunPresenter.apiErrorFromError(a.error),
})),
// We're removing attempts from the API
attemptCount: taskRun.attempts.length,
attempts: [],
relatedRuns: {
root: taskRun.rootTaskRun
? await createCommonRunStructure(taskRun.rootTaskRun)
@@ -29,7 +29,7 @@ const CoercedDate = z.preprocess((arg) => {
return arg;
}, z.date().optional());
const SearchParamsSchema = z.object({
export const ApiRunListSearchParams = z.object({
"page[size]": z.coerce.number().int().positive().min(1).max(100).optional(),
"page[after]": z.string().optional(),
"page[before]": z.string().optional(),
@@ -121,45 +121,31 @@ const SearchParamsSchema = z.object({
"filter[createdAt][period]": z.string().optional(),
});
type SearchParamsSchema = z.infer<typeof SearchParamsSchema>;
type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
export class ApiRunListPresenter extends BasePresenter {
public async call(
project: Project,
searchParams: URLSearchParams,
searchParams: ApiRunListSearchParams,
environment?: RuntimeEnvironment
): Promise<ListRunResponse> {
return this.trace("call", async (span) => {
const rawSearchParams = Object.fromEntries(searchParams.entries());
const $searchParams = SearchParamsSchema.safeParse(rawSearchParams);
if (!$searchParams.success) {
logger.error("Invalid search params", {
searchParams: rawSearchParams,
errors: $searchParams.error.errors,
});
throw fromZodError($searchParams.error);
}
logger.debug("Valid search params", { searchParams: $searchParams.data });
const options: RunListOptions = {
projectId: project.id,
};
// pagination
if ($searchParams.data["page[size]"]) {
options.pageSize = $searchParams.data["page[size]"];
if (searchParams["page[size]"]) {
options.pageSize = searchParams["page[size]"];
}
if ($searchParams.data["page[after]"]) {
options.cursor = $searchParams.data["page[after]"];
if (searchParams["page[after]"]) {
options.cursor = searchParams["page[after]"];
options.direction = "forward";
}
if ($searchParams.data["page[before]"]) {
options.cursor = $searchParams.data["page[before]"];
if (searchParams["page[before]"]) {
options.cursor = searchParams["page[before]"];
options.direction = "backward";
}
@@ -167,12 +153,12 @@ export class ApiRunListPresenter extends BasePresenter {
if (environment) {
options.environments = [environment.id];
} else {
if ($searchParams.data["filter[env]"]) {
if (searchParams["filter[env]"]) {
const environments = await this._prisma.runtimeEnvironment.findMany({
where: {
projectId: project.id,
slug: {
in: $searchParams.data["filter[env]"],
in: searchParams["filter[env]"],
},
},
});
@@ -181,46 +167,46 @@ export class ApiRunListPresenter extends BasePresenter {
}
}
if ($searchParams.data["filter[status]"]) {
options.statuses = $searchParams.data["filter[status]"].flatMap((status) =>
if (searchParams["filter[status]"]) {
options.statuses = searchParams["filter[status]"].flatMap((status) =>
ApiRunListPresenter.apiStatusToRunStatuses(status)
);
}
if ($searchParams.data["filter[taskIdentifier]"]) {
options.tasks = $searchParams.data["filter[taskIdentifier]"];
if (searchParams["filter[taskIdentifier]"]) {
options.tasks = searchParams["filter[taskIdentifier]"];
}
if ($searchParams.data["filter[version]"]) {
options.versions = $searchParams.data["filter[version]"];
if (searchParams["filter[version]"]) {
options.versions = searchParams["filter[version]"];
}
if ($searchParams.data["filter[tag]"]) {
options.tags = $searchParams.data["filter[tag]"];
if (searchParams["filter[tag]"]) {
options.tags = searchParams["filter[tag]"];
}
if ($searchParams.data["filter[bulkAction]"]) {
options.bulkId = $searchParams.data["filter[bulkAction]"];
if (searchParams["filter[bulkAction]"]) {
options.bulkId = searchParams["filter[bulkAction]"];
}
if ($searchParams.data["filter[schedule]"]) {
options.scheduleId = $searchParams.data["filter[schedule]"];
if (searchParams["filter[schedule]"]) {
options.scheduleId = searchParams["filter[schedule]"];
}
if ($searchParams.data["filter[createdAt][from]"]) {
options.from = $searchParams.data["filter[createdAt][from]"].getTime();
if (searchParams["filter[createdAt][from]"]) {
options.from = searchParams["filter[createdAt][from]"].getTime();
}
if ($searchParams.data["filter[createdAt][to]"]) {
options.to = $searchParams.data["filter[createdAt][to]"].getTime();
if (searchParams["filter[createdAt][to]"]) {
options.to = searchParams["filter[createdAt][to]"].getTime();
}
if ($searchParams.data["filter[createdAt][period]"]) {
options.period = $searchParams.data["filter[createdAt][period]"];
if (searchParams["filter[createdAt][period]"]) {
options.period = searchParams["filter[createdAt][period]"];
}
if (typeof $searchParams.data["filter[isTest]"] === "boolean") {
options.isTest = $searchParams.data["filter[isTest]"];
if (typeof searchParams["filter[isTest]"] === "boolean") {
options.isTest = searchParams["filter[isTest]"];
}
const presenter = new RunListPresenter();
@@ -10,7 +10,7 @@ import { useTypedMatchesData } from "~/hooks/useTypedMatchData";
import { useUser } from "~/hooks/useUser";
import { OrganizationsPresenter } from "~/presenters/OrganizationsPresenter.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { getCurrentPlan, getUsage } from "~/services/platform.v3.server";
import { getCachedUsage, getCurrentPlan, getUsage } from "~/services/platform.v3.server";
import { requireUserId } from "~/services/session.server";
import { telemetry } from "~/services/telemetry.server";
import { organizationPath } from "~/utils/pathBuilder";
@@ -29,6 +29,27 @@ export function useCurrentPlan(matches?: UIMatch[]) {
return data?.currentPlan;
}
export const shouldRevalidate: ShouldRevalidateFunction = (params) => {
const { currentParams, nextParams } = params;
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
// This prevents revalidation when there are search params changes
// IMPORTANT: If the loader function depends on search params, this should be updated
return params.currentUrl.pathname !== params.nextUrl.pathname;
};
// IMPORTANT: Make sure to update shouldRevalidate if this loader depends on search params
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const impersonationId = await getImpersonationId(request);
@@ -50,11 +71,17 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const firstDayOfMonth = new Date();
firstDayOfMonth.setUTCDate(1);
firstDayOfMonth.setUTCHours(0, 0, 0, 0);
const tomorrow = new Date();
tomorrow.setUTCDate(tomorrow.getDate() + 1);
// Using the 1st day of next month means we get the usage for the current month
// and the cache key for getCachedUsage is stable over the month
const firstDayOfNextMonth = new Date();
firstDayOfNextMonth.setUTCMonth(firstDayOfNextMonth.getUTCMonth() + 1);
firstDayOfNextMonth.setUTCDate(1);
firstDayOfNextMonth.setUTCHours(0, 0, 0, 0);
const [plan, usage] = await Promise.all([
getCurrentPlan(organization.id),
getUsage(organization.id, { from: firstDayOfMonth, to: tomorrow }),
getCachedUsage(organization.id, { from: firstDayOfMonth, to: firstDayOfNextMonth }),
]);
let hasExceededFreeTier = false;
@@ -103,23 +130,3 @@ export function ErrorBoundary() {
<RouteErrorDisplay button={{ title: "Home", to: "/" }} />
);
}
export const shouldRevalidate: ShouldRevalidateFunction = ({
defaultShouldRevalidate,
currentParams,
nextParams,
}) => {
const current = ParamsSchema.safeParse(currentParams);
const next = ParamsSchema.safeParse(nextParams);
if (current.success && next.success) {
if (current.data.organizationSlug !== next.data.organizationSlug) {
return true;
}
if (current.data.projectParam !== next.data.projectParam) {
return true;
}
}
return defaultShouldRevalidate;
};
@@ -0,0 +1,19 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const claims = {
sub: authenticationResult.environment.id,
pub: true,
};
return json(claims);
}
+46
View File
@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { z } from "zod";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
const RequestBodySchema = z.object({
claims: z
.object({
scopes: z.array(z.string()).default([]),
})
.optional(),
expirationTime: z.union([z.number(), z.string()]).optional(),
});
export async function action({ request }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const parsedBody = RequestBodySchema.safeParse(await request.json());
if (!parsedBody.success) {
return json(
{ error: "Invalid request body", issues: parsedBody.error.issues },
{ status: 400 }
);
}
const claims = {
sub: authenticationResult.environment.id,
pub: true,
...parsedBody.data.claims,
};
const jwt = await internal_generateJWT({
secretKey: authenticationResult.apiKey,
payload: claims,
expirationTime: parsedBody.data.expirationTime ?? "1h",
});
return json({ token: jwt });
}
@@ -33,7 +33,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
id: parsedParams.data.connectionId,
integration: {
slug: parsedParams.data.integrationSlug,
organization: authenticatedEnv.organization,
organizationId: authenticatedEnv.organization.id,
},
},
include: {
@@ -1,62 +1,36 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ValidationError } from "zod-validation-error";
import { findProjectByRef } from "~/models/project.server";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { apiCors } from "~/utils/apiCors";
import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderPATApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
export const loader = createLoaderPATApiRoute(
{
params: ParamsSchema,
searchParams: ApiRunListSearchParams,
corsStrategy: "all",
},
async ({ searchParams, params, authentication }) => {
const project = await findProjectByRef(params.projectRef, authentication.userId);
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const $params = ParamsSchema.safeParse(params);
if (!$params.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
const project = await findProjectByRef($params.data.projectRef, authenticationResult.userId);
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const url = new URL(request.url);
const presenter = new ApiRunListPresenter();
try {
const result = await presenter.call(project, url.searchParams);
const presenter = new ApiRunListPresenter();
const result = await presenter.call(project, searchParams);
if (!result) {
return apiCors(request, json({ data: [] }));
return json({ data: [] });
}
return apiCors(request, json(result));
} catch (error) {
if (error instanceof ValidationError) {
return apiCors(
request,
json({ error: "Query Error", details: error.details }, { status: 400 })
);
} else {
return apiCors(
request,
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
);
}
return json(result);
}
}
);
@@ -89,6 +89,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
tags: {
connect: tagIds.map((id) => ({ id })),
},
runTags: {
push: newTags,
},
},
});
@@ -62,11 +62,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
}
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(
updatedRun.friendlyId,
authenticationResult.environment,
true
);
const result = await presenter.call(updatedRun.friendlyId, authenticationResult.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
+23 -46
View File
@@ -1,52 +1,29 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { ValidationError } from "zod-validation-error";
import { ApiRunListPresenter } from "~/presenters/v3/ApiRunListPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
import {
ApiRunListPresenter,
ApiRunListSearchParams,
} from "~/presenters/v3/ApiRunListPresenter.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: false,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const url = new URL(request.url);
const presenter = new ApiRunListPresenter();
try {
export const loader = createLoaderApiRoute(
{
searchParams: ApiRunListSearchParams,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ searchParams, authentication }) => {
const presenter = new ApiRunListPresenter();
const result = await presenter.call(
authenticatedEnv.project,
url.searchParams,
authenticatedEnv
authentication.environment.project,
searchParams,
authentication.environment
);
if (!result) {
return apiCors(request, json({ data: [] }));
}
return apiCors(request, json(result));
} catch (error) {
if (error instanceof ValidationError) {
return apiCors(
request,
json({ error: "Query Error", details: error.details }, { status: 400 })
);
} else {
return apiCors(
request,
json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 })
);
}
return json(result);
}
}
);
@@ -104,10 +104,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Task not found" }, { status: 404 });
}
return json({
batchId: result.batch.friendlyId,
runs: result.runs,
});
return json(
{
batchId: result.batch.friendlyId,
runs: result.runs,
},
{
headers: {
"x-trigger-jwt-claims": JSON.stringify({
sub: authenticationResult.environment.id,
pub: true,
}),
},
}
);
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
@@ -30,6 +30,8 @@ export async function action({ request, params }: ActionFunctionArgs) {
return { status: 405, body: "Method Not Allowed" };
}
logger.debug("TriggerTask action", { headers: Object.fromEntries(request.headers) });
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
@@ -105,9 +107,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
return json({ error: "Task not found" }, { status: 404 });
}
return json({
id: run.friendlyId,
});
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 });
+22 -35
View File
@@ -1,44 +1,31 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { apiCors } from "~/utils/apiCors";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const ParamsSchema = z.object({
runId: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
if (request.method.toUpperCase() === "OPTIONS") {
return apiCors(request, json({}));
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 }) => {
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(params.runId, authentication.environment);
if (!result) {
return json({ error: "Run not found" }, { status: 404 });
}
return json(result);
}
const authenticationResult = await authenticateApiRequest(request, {
allowPublicKey: true,
});
if (!authenticationResult) {
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
}
const authenticatedEnv = authenticationResult.environment;
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
}
const { runId } = parsed.data;
const showSecretDetails = authenticationResult.type === "PRIVATE";
const presenter = new ApiRetrieveRunPresenter();
const result = await presenter.call(runId, authenticatedEnv, showSecretDetails);
if (!result) {
return apiCors(request, json({ error: "Run not found" }, { status: 404 }));
}
return apiCors(request, json(result));
}
);
@@ -0,0 +1,36 @@
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";
const ParamsSchema = z.object({
batchId: z.string(),
});
export const loader = createLoaderApiRoute(
{
params: ParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (params) => ({ batch: params.batchId }),
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ params, authentication, request }) => {
const batchRun = await $replica.batchTaskRun.findFirst({
where: {
friendlyId: params.batchId,
runtimeEnvironmentId: authentication.environment.id,
},
});
if (!batchRun) {
return json({ error: "Batch not found" }, { status: 404 });
}
return realtimeClient.streamBatch(request.url, authentication.environment, batchRun.id);
}
);
@@ -0,0 +1,36 @@
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";
const ParamsSchema = z.object({
runId: z.string(),
});
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 json({ error: "Run not found" }, { status: 404 });
}
return realtimeClient.streamRun(request.url, authentication.environment, run.id);
}
);
@@ -0,0 +1,28 @@
import { z } from "zod";
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
import { createLoaderApiRoute } from "~/services/routeBuiilders/apiBuilder.server";
const SearchParamsSchema = z.object({
tags: z
.string()
.optional()
.transform((value) => {
return value ? value.split(",") : undefined;
}),
});
export const loader = createLoaderApiRoute(
{
searchParams: SearchParamsSchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "read",
resource: (_, searchParams) => searchParams,
superScopes: ["read:runs", "read:all", "admin"],
},
},
async ({ searchParams, authentication, request }) => {
return realtimeClient.streamRuns(request.url, authentication.environment, searchParams);
}
);
+75 -28
View File
@@ -1,52 +1,56 @@
import { json } from "@remix-run/server-runtime";
import { Prettify } from "@trigger.dev/core";
import { SignJWT, errors, jwtVerify } from "jose";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import { findProjectByRef } from "~/models/project.server";
import {
RuntimeEnvironment,
findEnvironmentByApiKey,
findEnvironmentByPublicApiKey,
} from "~/models/runtimeEnvironment.server";
import { logger } from "./logger.server";
import {
PersonalAccessTokenAuthenticationResult,
authenticateApiRequestWithPersonalAccessToken,
isPersonalAccessToken,
} from "./personalAccessToken.server";
import { prisma } from "~/db.server";
import { json } from "@remix-run/server-runtime";
import { findProjectByRef } from "~/models/project.server";
import { SignJWT, jwtVerify, errors } from "jose";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
const ClaimsSchema = z.object({
scopes: z.array(z.string()).optional(),
});
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
export type AuthenticatedEnvironment = Optional<
NonNullable<Awaited<ReturnType<typeof findEnvironmentByApiKey>>>,
"orgMember"
>;
type ApiAuthenticationResult = {
export type ApiAuthenticationResult = {
apiKey: string;
type: "PUBLIC" | "PRIVATE";
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
environment: AuthenticatedEnvironment;
scopes?: string[];
};
export async function authenticateApiRequest(
request: Request,
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromRequest(request);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey });
return authenticateApiKey(apiKey, options);
}
export async function authenticateApiKey(
apiKey: string,
{ allowPublicKey = false }: { allowPublicKey?: boolean } = {}
options: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const result = getApiKeyResult(apiKey);
@@ -54,14 +58,12 @@ export async function authenticateApiKey(
return;
}
//if it's a public API key and we don't allow public keys, return
if (!allowPublicKey) {
const environment = await findEnvironmentByApiKey(result.apiKey);
if (!environment) return;
return {
...result,
environment,
};
if (!options.allowPublicKey && result.type === "PUBLIC") {
return;
}
if (!options.allowJWT && result.type === "PUBLIC_JWT") {
return;
}
switch (result.type) {
@@ -81,27 +83,72 @@ export async function authenticateApiKey(
environment,
};
}
case "PUBLIC_JWT": {
const validationResults = await validatePublicJwtKey(result.apiKey);
if (!validationResults) {
return;
}
const parsedClaims = ClaimsSchema.safeParse(validationResults.claims);
return {
...result,
environment: validationResults.environment,
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
};
}
}
}
export async function authenticateAuthorizationHeader(
authorization: string,
{
allowPublicKey = false,
allowJWT = false,
}: { allowPublicKey?: boolean; allowJWT?: boolean } = {}
): Promise<ApiAuthenticationResult | undefined> {
const apiKey = getApiKeyFromHeader(authorization);
if (!apiKey) {
return;
}
return authenticateApiKey(apiKey, { allowPublicKey, allowJWT });
}
export function isPublicApiKey(key: string) {
return key.startsWith("pk_");
}
export function getApiKeyFromRequest(request: Request) {
const rawAuthorization = request.headers.get("Authorization");
export function isSecretApiKey(key: string) {
return key.startsWith("tr_");
}
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
if (!authorization.success) {
export function getApiKeyFromRequest(request: Request) {
return getApiKeyFromHeader(request.headers.get("Authorization"));
}
export function getApiKeyFromHeader(authorization?: string | null) {
if (typeof authorization !== "string" || !authorization) {
return;
}
const apiKey = authorization.data.replace(/^Bearer /, "");
const apiKey = authorization.replace(/^Bearer /, "");
return apiKey;
}
export function getApiKeyResult(apiKey: string) {
const type = isPublicApiKey(apiKey) ? ("PUBLIC" as const) : ("PRIVATE" as const);
export function getApiKeyResult(apiKey: string): {
apiKey: string;
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
} {
const type = isPublicApiKey(apiKey)
? "PUBLIC"
: isSecretApiKey(apiKey)
? "PRIVATE"
: isPublicJWT(apiKey)
? "PUBLIC_JWT"
: "PRIVATE"; // Fallback to private key
return { apiKey, type };
}
+35 -143
View File
@@ -1,150 +1,40 @@
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { Duration, Limiter, RateLimiter, createRedisRateLimitClient } from "./rateLimiter.server";
type Options = {
redis?: RedisOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
limiter: Limiter;
log?: {
requests?: boolean;
rejections?: boolean;
};
};
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
limiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
}: Options) {
const rateLimiter = new RateLimiter({
redis,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const { success, pending, limit, reset, remaining } = await rateLimiter.limit(
hashedAuthorizationValue
);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
import { authenticateAuthorizationHeader } from "./apiAuth.server";
import { authorizationRateLimitMiddleware } from "./authorizationRateLimitMiddleware.server";
import { Duration } from "./rateLimiter.server";
export const apiRateLimiter = authorizationRateLimitMiddleware({
keyPrefix: "api",
limiter: Ratelimit.tokenBucket(
env.API_RATE_LIMIT_REFILL_RATE,
env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
env.API_RATE_LIMIT_MAX
),
defaultLimiter: {
type: "tokenBucket",
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
maxTokens: env.API_RATE_LIMIT_MAX,
},
limiterCache: {
fresh: 60_000 * 10, // Data is fresh for 10 minutes
stale: 60_000 * 20, // Date is stale after 20 minutes
},
limiterConfigOverride: async (authorizationValue) => {
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
allowPublicKey: true,
allowJWT: true,
});
if (!authenticatedEnv) {
return;
}
if (authenticatedEnv.type === "PUBLIC_JWT") {
return {
type: "fixedWindow",
window: env.API_RATE_LIMIT_JWT_WINDOW,
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
};
} else {
return authenticatedEnv.environment.organization.apiRateLimiterConfig;
}
},
pathMatchers: [/^\/api/],
// Allow /api/v1/tasks/:id/callback/:secret
pathWhiteList: [
@@ -159,11 +49,13 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
/^\/api\/v1\/endpoints\/[^\/]+\/[^\/]+\/index\/[^\/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
"/api/v1/timezones",
"/api/v1/usage/ingest",
"/api/v1/auth/jwt/claims",
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
],
log: {
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
requests: env.API_RATE_LIMIT_REQUEST_LOGS_ENABLED === "1",
limiter: env.API_RATE_LIMIT_LIMITER_LOGS_ENABLED === "1",
},
});
@@ -0,0 +1,103 @@
export type AuthorizationAction = "read"; // Add more actions as needed
const ResourceTypes = ["tasks", "tags", "runs", "batch"] as const;
export type AuthorizationResources = {
[key in (typeof ResourceTypes)[number]]?: string | string[];
};
export type AuthorizationEntity = {
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
scopes?: string[];
};
/**
* Checks if the given entity is authorized to perform a specific action on a resource.
*
* @param entity - The entity requesting authorization.
* @param action - The action the entity wants to perform.
* @param resource - The resource on which the action is to be performed.
* @param superScopes - An array of super scopes that can bypass the normal authorization checks.
*
* @example
*
* ```typescript
* import { checkAuthorization } from "./authorization.server";
*
* const entity = {
* type: "PUBLIC",
* scope: ["read:runs:run_1234", "read:tasks"]
* };
*
* checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true
* checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false
* checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
* ```
*/
export function checkAuthorization(
entity: AuthorizationEntity,
action: AuthorizationAction,
resource: AuthorizationResources,
superScopes?: string[]
) {
// "PRIVATE" is a secret key and has access to everything
if (entity.type === "PRIVATE") {
return true;
}
// "PUBLIC" is a deprecated key and has no access
if (entity.type === "PUBLIC") {
return false;
}
// If the entity has no permissions, deny access
if (!entity.scopes || entity.scopes.length === 0) {
return false;
}
// If the resource object is empty, deny access
if (Object.keys(resource).length === 0) {
return false;
}
// Check for any of the super scopes
if (superScopes && superScopes.length > 0) {
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
return true;
}
}
const filteredResource = Object.keys(resource).reduce((acc, key) => {
if (ResourceTypes.includes(key)) {
acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources];
}
return acc;
}, {} as AuthorizationResources);
// Check each resource type
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
let resourceAuthorized = false;
for (const value of resourceValues) {
// Check for specific resource permission
const specificPermission = `${action}:${resourceType}:${value}`;
// Check for general resource type permission
const generalPermission = `${action}:${resourceType}`;
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
resourceAuthorized = true;
break;
}
}
// If any resource is not authorized, return false
if (!resourceAuthorized) {
return false;
}
}
// All resources are authorized
return true;
}
@@ -0,0 +1,301 @@
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { Ratelimit } from "@upstash/ratelimit";
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
import { RedisOptions } from "ioredis";
import { createHash } from "node:crypto";
import { z } from "zod";
import { env } from "~/env.server";
import { logger } from "./logger.server";
import { createRedisRateLimitClient, Duration, RateLimiter } from "./rateLimiter.server";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
const DurationSchema = z.custom<Duration>((value) => {
if (typeof value !== "string") {
throw new Error("Duration must be a string");
}
return value as Duration;
});
export const RateLimitFixedWindowConfig = z.object({
type: z.literal("fixedWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitFixedWindowConfig = z.infer<typeof RateLimitFixedWindowConfig>;
export const RateLimitSlidingWindowConfig = z.object({
type: z.literal("slidingWindow"),
window: DurationSchema,
tokens: z.number(),
});
export type RateLimitSlidingWindowConfig = z.infer<typeof RateLimitSlidingWindowConfig>;
export const RateLimitTokenBucketConfig = z.object({
type: z.literal("tokenBucket"),
refillRate: z.number(),
interval: DurationSchema,
maxTokens: z.number(),
});
export type RateLimitTokenBucketConfig = z.infer<typeof RateLimitTokenBucketConfig>;
export const RateLimiterConfig = z.discriminatedUnion("type", [
RateLimitFixedWindowConfig,
RateLimitSlidingWindowConfig,
RateLimitTokenBucketConfig,
]);
export type RateLimiterConfig = z.infer<typeof RateLimiterConfig>;
type LimitConfigOverrideFunction = (authorizationValue: string) => Promise<unknown>;
type Options = {
redis?: RedisOptions;
keyPrefix: string;
pathMatchers: (RegExp | string)[];
pathWhiteList?: (RegExp | string)[];
defaultLimiter: RateLimiterConfig;
limiterConfigOverride?: LimitConfigOverrideFunction;
limiterCache?: {
fresh: number;
stale: number;
};
log?: {
requests?: boolean;
rejections?: boolean;
limiter?: boolean;
};
};
async function resolveLimitConfig(
authorizationValue: string,
hashedAuthorizationValue: string,
defaultLimiter: RateLimiterConfig,
cache: UnkeyCache<{ limiter: RateLimiterConfig }>,
logsEnabled: boolean,
limiterConfigOverride?: LimitConfigOverrideFunction
): Promise<RateLimiterConfig> {
if (!limiterConfigOverride) {
return defaultLimiter;
}
if (logsEnabled) {
logger.info("RateLimiter: checking for override", {
authorizationValue: hashedAuthorizationValue,
defaultLimiter,
});
}
const cacheResult = await cache.limiter.swr(hashedAuthorizationValue, async (key) => {
const override = await limiterConfigOverride(authorizationValue);
if (!override) {
if (logsEnabled) {
logger.info("RateLimiter: no override found", {
authorizationValue,
defaultLimiter,
});
}
return defaultLimiter;
}
const parsedOverride = RateLimiterConfig.safeParse(override);
if (!parsedOverride.success) {
logger.error("Error parsing rate limiter override", {
override,
errors: parsedOverride.error.errors,
});
return defaultLimiter;
}
if (logsEnabled && parsedOverride.data) {
logger.info("RateLimiter: override found", {
authorizationValue,
defaultLimiter,
override: parsedOverride.data,
});
}
return parsedOverride.data;
});
return cacheResult.val ?? defaultLimiter;
}
//returns an Express middleware that rate limits using the Bearer token in the Authorization header
export function authorizationRateLimitMiddleware({
redis,
keyPrefix,
defaultLimiter,
pathMatchers,
pathWhiteList = [],
log = {
rejections: true,
requests: true,
},
limiterCache,
limiterConfigOverride,
}: Options) {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
...redis,
},
});
// This cache holds the rate limit configuration for each org, so we don't have to fetch it every request
const cache = createCache({
limiter: new Namespace<RateLimiterConfig>(ctx, {
stores: [memory, redisCacheStore],
fresh: limiterCache?.fresh ?? 30_000,
stale: limiterCache?.stale ?? 60_000,
}),
});
const redisClient = createRedisRateLimitClient(
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: {} }),
}
);
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): request to ${req.path}`);
}
// allow OPTIONS requests
if (req.method.toUpperCase() === "OPTIONS") {
return next();
}
//first check if any of the pathMatchers match the request path
const path = req.path;
if (
!pathMatchers.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): didn't match ${req.path}`);
}
return next();
}
// Check if the path matches any of the whitelisted paths
if (
pathWhiteList.some((matcher) =>
matcher instanceof RegExp ? matcher.test(path) : path === matcher
)
) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): whitelisted ${req.path}`);
}
return next();
}
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): matched ${req.path}`);
}
const authorizationValue = req.headers.authorization;
if (!authorizationValue) {
if (log.requests) {
logger.info(`RateLimiter (${keyPrefix}): no key`, { headers: req.headers, url: req.url });
}
res.setHeader("Content-Type", "application/problem+json");
return res.status(401).send(
JSON.stringify(
{
title: "Unauthorized",
status: 401,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401",
detail: "No authorization header provided",
error: "No authorization header provided",
},
null,
2
)
);
}
const hash = createHash("sha256");
hash.update(authorizationValue);
const hashedAuthorizationValue = hash.digest("hex");
const limiterConfig = await resolveLimitConfig(
authorizationValue,
hashedAuthorizationValue,
defaultLimiter,
cache,
typeof log.limiter === "boolean" ? log.limiter : false,
limiterConfigOverride
);
const limiter =
limiterConfig.type === "fixedWindow"
? Ratelimit.fixedWindow(limiterConfig.tokens, limiterConfig.window)
: limiterConfig.type === "tokenBucket"
? Ratelimit.tokenBucket(
limiterConfig.refillRate,
limiterConfig.interval,
limiterConfig.maxTokens
)
: Ratelimit.slidingWindow(limiterConfig.tokens, limiterConfig.window);
const rateLimiter = new RateLimiter({
redisClient,
keyPrefix,
limiter,
logSuccess: log.requests,
logFailure: log.rejections,
});
const { success, limit, reset, remaining } = await rateLimiter.limit(hashedAuthorizationValue);
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
res.set("x-ratelimit-limit", limit.toString());
res.set("x-ratelimit-remaining", $remaining.toString());
res.set("x-ratelimit-reset", reset.toString());
if (success) {
return next();
}
res.setHeader("Content-Type", "application/problem+json");
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
return res.status(429).send(
JSON.stringify(
{
title: "Rate Limit Exceeded",
status: 429,
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
reset,
limit,
remaining,
secondsUntilReset,
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
},
null,
2
)
);
};
}
export type RateLimitMiddleware = ReturnType<typeof authorizationRateLimitMiddleware>;
+94 -25
View File
@@ -1,5 +1,13 @@
import { BillingClient, Limits, SetPlanBody, UsageSeriesParams } from "@trigger.dev/platform/v3";
import { Organization, Project } from "@trigger.dev/database";
import {
BillingClient,
Limits,
SetPlanBody,
UsageSeriesParams,
UsageResult,
} from "@trigger.dev/platform/v3";
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
import { MemoryStore } from "@unkey/cache/stores";
import { redirect } from "remix-typedjson";
import { $replica } from "~/db.server";
import { env } from "~/env.server";
@@ -7,10 +15,61 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { createEnvironment } from "~/models/organization.server";
import { logger } from "~/services/logger.server";
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
import { singleton } from "~/utils/singleton";
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
function initializeClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`🤑 Billing client initialized: ${process.env.BILLING_API_URL}`);
return client;
} else {
console.log(`🤑 Billing client not initialized`);
}
}
const client = singleton("billingClient", initializeClient);
function initializePlatformCache() {
const ctx = new DefaultStatefulContext();
const memory = new MemoryStore({ persistentMap: new Map() });
const redisCacheStore = new RedisCacheStore({
connection: {
keyPrefix: "tr:cache:platform:v3",
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
});
// This cache holds the limits fetched from the platform service
const cache = createCache({
limits: new Namespace<number>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
usage: new Namespace<UsageResult>(ctx, {
stores: [memory, redisCacheStore],
fresh: 60_000 * 5, // 5 minutes
stale: 60_000 * 10, // 10 minutes
}),
});
return cache;
}
const platformCache = singleton("platformCache", initializePlatformCache);
export async function getCurrentPlan(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
@@ -60,8 +119,8 @@ export async function getCurrentPlan(orgId: string) {
}
export async function getLimits(orgId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.currentPlan(orgId);
if (!result.success) {
@@ -87,9 +146,15 @@ export async function getLimit(orgId: string, limit: keyof Limits, fallback: num
return fallback;
}
export async function getCachedLimit(orgId: string, limit: keyof Limits, fallback: number) {
return platformCache.limits.swr(`${orgId}:${limit}`, async () => {
return getLimit(orgId, limit, fallback);
});
}
export async function customerPortalUrl(orgId: string, orgSlug: string) {
const client = getClient();
if (!client) return undefined;
try {
return client.createPortalSession(orgId, {
returnUrl: `${env.APP_ORIGIN}${organizationBillingPath({ slug: orgSlug })}`,
@@ -101,8 +166,8 @@ export async function customerPortalUrl(orgId: string, orgSlug: string) {
}
export async function getPlans() {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.plans();
if (!result.success) {
@@ -122,7 +187,6 @@ export async function setPlan(
callerPath: string,
plan: SetPlanBody
) {
const client = getClient();
if (!client) {
throw redirectWithErrorMessage(callerPath, request, "Error setting plan");
}
@@ -178,8 +242,8 @@ export async function setPlan(
}
export async function getUsage(organizationId: string, { from, to }: { from: Date; to: Date }) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.usage(organizationId, { from, to });
if (!result.success) {
@@ -193,9 +257,27 @@ export async function getUsage(organizationId: string, { from, to }: { from: Dat
}
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
const client = getClient();
export async function getCachedUsage(
organizationId: string,
{ from, to }: { from: Date; to: Date }
) {
if (!client) return undefined;
const result = await platformCache.usage.swr(
`${organizationId}:${from.toISOString()}:${to.toISOString()}`,
async () => {
const usageResponse = await getUsage(organizationId, { from, to });
return usageResponse;
}
);
return result.val;
}
export async function getUsageSeries(organizationId: string, params: UsageSeriesParams) {
if (!client) return undefined;
try {
const result = await client.usageSeries(organizationId, params);
if (!result.success) {
@@ -214,8 +296,8 @@ export async function reportInvocationUsage(
costInCents: number,
additionalData?: Record<string, any>
) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.reportInvocationUsage({
organizationId,
@@ -234,8 +316,8 @@ export async function reportInvocationUsage(
}
export async function reportComputeUsage(request: Request) {
const client = getClient();
if (!client) return undefined;
return fetch(`${process.env.BILLING_API_URL}/api/v1/usage/ingest/compute`, {
method: "POST",
headers: request.headers,
@@ -244,8 +326,8 @@ export async function reportComputeUsage(request: Request) {
}
export async function getEntitlement(organizationId: string) {
const client = getClient();
if (!client) return undefined;
try {
const result = await client.getEntitlement(organizationId);
if (!result.success) {
@@ -275,19 +357,6 @@ export async function projectCreated(organization: Organization, project: Projec
}
}
function getClient() {
if (isCloud() && process.env.BILLING_API_URL && process.env.BILLING_API_KEY) {
const client = new BillingClient({
url: process.env.BILLING_API_URL,
apiKey: process.env.BILLING_API_KEY,
});
console.log(`Billing client initialized: ${process.env.BILLING_API_URL}`);
return client;
} else {
console.log(`Billing client not initialized`);
}
}
function isCloud(): boolean {
const acceptableHosts = [
"https://cloud.trigger.dev",
+16 -19
View File
@@ -5,6 +5,7 @@ import { logger } from "./logger.server";
type Options = {
redis?: RedisOptions;
redisClient?: RateLimiterRedisClient;
keyPrefix: string;
limiter: Limiter;
logSuccess?: boolean;
@@ -14,34 +15,32 @@ type Options = {
export type Limiter = ConstructorParameters<typeof Ratelimit>[0]["limiter"];
export type Duration = Parameters<typeof Ratelimit.slidingWindow>[1];
export type RateLimitResponse = Awaited<ReturnType<Ratelimit["limit"]>>;
export type RateLimiterRedisClient = ConstructorParameters<typeof Ratelimit>[0]["redis"];
export class RateLimiter {
#ratelimit: Ratelimit;
constructor(private readonly options: Options) {
const { redis, keyPrefix, limiter } = options;
const { redis, redisClient, keyPrefix, limiter } = options;
const prefix = `ratelimit:${keyPrefix}`;
this.#ratelimit = new Ratelimit({
redis: createRedisRateLimitClient(
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: {} }),
}
),
redis:
redisClient ??
createRedisRateLimitClient(
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: {} }),
}
),
limiter,
ephemeralCache: new Map(),
analytics: false,
prefix,
});
logger.info(`RateLimiter (${keyPrefix}): initialized`, {
keyPrefix,
redisKeyspace: prefix,
});
}
async limit(identifier: string, rate = 1): Promise<RateLimitResponse> {
@@ -71,9 +70,7 @@ export class RateLimiter {
}
}
export function createRedisRateLimitClient(
redisOptions: RedisOptions
): ConstructorParameters<typeof Ratelimit>[0]["redis"] {
export function createRedisRateLimitClient(redisOptions: RedisOptions): RateLimiterRedisClient {
const redis = new Redis(redisOptions);
return {
@@ -0,0 +1,85 @@
import { validateJWT } from "@trigger.dev/core/v3/jwt";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
export async function validatePublicJwtKey(token: string) {
// Get the sub claim from the token
// Use the sub claim to find the environment
// Validate the token against the environment.apiKey
// Once that's done, return the environment and the claims
const sub = extractJWTSub(token);
if (!sub) {
return;
}
const environment = await findEnvironmentById(sub);
if (!environment) {
return;
}
const claims = await validateJWT(token, environment.apiKey);
if (!claims) {
return;
}
return {
environment,
claims,
};
}
export function isPublicJWT(token: string): boolean {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return false;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return false;
// Check for the pub: true claim
return "pub" in payload && payload.pub === true;
} catch (error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return false;
}
}
function extractJWTSub(token: string): string | undefined {
// Split the token
const parts = token.split(".");
if (parts.length !== 3) return;
try {
// Decode the payload (second part)
const payload = JSON.parse(decodeBase64Url(parts[1]));
if (payload === null || typeof payload !== "object") return;
// Check for the pub: true claim
return "sub" in payload && typeof payload.sub === "string" ? payload.sub : undefined;
} catch (error) {
// If there's any error in decoding or parsing, it's not a valid JWT
return;
}
}
function decodeBase64Url(str: string): string {
// Replace URL-safe characters and add padding
str = str.replace(/-/g, "+").replace(/_/g, "/");
switch (str.length % 4) {
case 2:
str += "==";
break;
case 3:
str += "=";
break;
}
// Decode using Node.js Buffer
return Buffer.from(str, "base64").toString("utf8");
}
@@ -0,0 +1,253 @@
import { json } from "@remix-run/server-runtime";
import Redis, { Callback, Result, type RedisOptions } from "ioredis";
import { randomUUID } from "node:crypto";
import { longPollingFetch } from "~/utils/longPollingFetch";
import { logger } from "./logger.server";
export interface CachedLimitProvider {
getCachedLimit: (organizationId: string, defaultValue: number) => Promise<number | undefined>;
}
export type RealtimeClientOptions = {
electricOrigin: string;
redis: RedisOptions;
cachedLimitProvider: CachedLimitProvider;
keyPrefix: string;
expiryTimeInSeconds?: number;
};
export type RealtimeEnvironment = {
id: string;
organizationId: string;
};
export type RealtimeRunsParams = {
tags?: string[];
};
export class RealtimeClient {
private redis: Redis;
private expiryTimeInSeconds: number;
private cachedLimitProvider: CachedLimitProvider;
constructor(private options: RealtimeClientOptions) {
this.redis = new Redis(options.redis);
this.expiryTimeInSeconds = options.expiryTimeInSeconds ?? 60 * 5; // default to 5 minutes
this.cachedLimitProvider = options.cachedLimitProvider;
this.#registerCommands();
}
async streamRun(url: URL | string, environment: RealtimeEnvironment, runId: string) {
return this.#streamRunsWhere(url, environment, `id='${runId}'`);
}
async streamBatch(url: URL | string, environment: RealtimeEnvironment, batchId: string) {
return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`);
}
async streamRuns(
url: URL | string,
environment: RealtimeEnvironment,
params: RealtimeRunsParams
) {
const whereClauses: string[] = [`"runtimeEnvironmentId"='${environment.id}'`];
if (params.tags) {
whereClauses.push(`"runTags" @> ARRAY[${params.tags.map((t) => `'${t}'`).join(",")}]`);
}
const whereClause = whereClauses.join(" AND ");
return this.#streamRunsWhere(url, environment, whereClause);
}
async #streamRunsWhere(url: URL | string, environment: RealtimeEnvironment, whereClause: string) {
const electricUrl = this.#constructElectricUrl(url, whereClause);
return this.#performElectricRequest(electricUrl, environment);
}
#constructElectricUrl(url: URL | string, whereClause: string): URL {
const $url = new URL(url.toString());
const electricUrl = new URL(`${this.options.electricOrigin}/v1/shape/public."TaskRun"`);
// 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);
return electricUrl;
}
async #performElectricRequest(url: URL, environment: RealtimeEnvironment) {
const shapeId = extractShapeId(url);
logger.debug("[realtimeClient] request", {
url: url.toString(),
});
if (!shapeId) {
// If the shapeId is not present, we're just getting the initial value
return longPollingFetch(url.toString());
}
const isLive = isLiveRequestUrl(url);
if (!isLive) {
return longPollingFetch(url.toString());
}
const requestId = randomUUID();
// We now need to wrap the longPollingFetch in a concurrency tracker
const concurrencyLimit = await this.cachedLimitProvider.getCachedLimit(
environment.organizationId,
100_000
);
if (!concurrencyLimit) {
logger.error("Failed to get concurrency limit", {
organizationId: environment.organizationId,
});
return json({ error: "Failed to get concurrency limit" }, { status: 500 });
}
logger.debug("[realtimeClient] increment and check", {
concurrencyLimit,
shapeId,
requestId,
environment: {
id: environment.id,
organizationId: environment.organizationId,
},
});
const canProceed = await this.#incrementAndCheck(environment.id, requestId, concurrencyLimit);
if (!canProceed) {
logger.debug("[realtimeClient] too many concurrent requests", {
requestId,
environmentId: environment.id,
});
return json({ error: "Too many concurrent requests" }, { status: 429 });
}
try {
// ... (rest of your existing code for the long polling request)
const response = await longPollingFetch(url.toString());
// Decrement the counter after the long polling request is complete
await this.#decrementConcurrency(environment.id, requestId);
return response;
} catch (error) {
// Decrement the counter if the request fails
await this.#decrementConcurrency(environment.id, requestId);
throw error;
}
}
async #incrementAndCheck(environmentId: string, requestId: string, limit: number) {
const key = this.#getKey(environmentId);
const now = Date.now();
const result = await this.redis.incrementAndCheckConcurrency(
key,
now.toString(),
requestId,
this.expiryTimeInSeconds.toString(), // expiry time
(now - this.expiryTimeInSeconds * 1000).toString(), // cutoff time
limit.toString()
);
return result === 1;
}
async #decrementConcurrency(environmentId: string, requestId: string) {
logger.debug("[realtimeClient] decrement", {
requestId,
environmentId,
});
const key = this.#getKey(environmentId);
await this.redis.zrem(key, requestId);
}
#getKey(environmentId: string): string {
return `${this.options.keyPrefix}:${environmentId}`;
}
#registerCommands() {
this.redis.defineCommand("incrementAndCheckConcurrency", {
numberOfKeys: 1,
lua: /* lua */ `
local concurrencyKey = KEYS[1]
local timestamp = tonumber(ARGV[1])
local requestId = ARGV[2]
local expiryTime = tonumber(ARGV[3])
local cutoffTime = tonumber(ARGV[4])
local limit = tonumber(ARGV[5])
-- Remove expired entries
redis.call('ZREMRANGEBYSCORE', concurrencyKey, '-inf', cutoffTime)
-- Add the new request to the sorted set
redis.call('ZADD', concurrencyKey, timestamp, requestId)
-- Set the expiry time on the key
redis.call('EXPIRE', concurrencyKey, expiryTime)
-- Get the total number of concurrent requests
local totalRequests = redis.call('ZCARD', concurrencyKey)
-- Check if the limit has been exceeded
if totalRequests > limit then
-- Remove the request we just added
redis.call('ZREM', concurrencyKey, requestId)
return 0
end
-- Return 1 to indicate success
return 1
`,
});
}
}
function extractShapeId(url: URL) {
return url.searchParams.get("shape_id");
}
function isLiveRequestUrl(url: URL) {
return url.searchParams.has("live") && url.searchParams.get("live") === "true";
}
declare module "ioredis" {
interface RedisCommander<Context> {
incrementAndCheckConcurrency(
key: string,
timestamp: string,
requestId: string,
expiryTime: string,
cutoffTime: string,
limit: string,
callback?: Callback<number>
): Result<number, Context>;
}
}
@@ -0,0 +1,32 @@
import { env } from "~/env.server";
import { singleton } from "~/utils/singleton";
import { RealtimeClient } from "./realtimeClient.server";
import { getCachedLimit } from "./platform.v3.server";
function initializeRealtimeClient() {
return new RealtimeClient({
electricOrigin: env.ELECTRIC_ORIGIN,
keyPrefix: "tr:realtime:concurrency",
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: {} }),
},
cachedLimitProvider: {
async getCachedLimit(organizationId, defaultValue) {
const result = await getCachedLimit(
organizationId,
"realtimeConcurrentConnections",
defaultValue
);
return result.val;
},
},
});
}
export const realtimeClient = singleton("realtimeClient", initializeRealtimeClient);
@@ -0,0 +1,260 @@
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,97 @@
import { Err, Ok, type Result } from "@unkey/error";
import type { Entry, Store } from "@unkey/cache/stores";
import type { RedisOptions } from "ioredis";
import { Redis } from "ioredis";
import { CacheError } from "@unkey/cache";
export type RedisCacheStoreConfig = {
connection: RedisOptions;
};
export class RedisCacheStore<TNamespace extends string, TValue = any>
implements Store<TNamespace, TValue>
{
public readonly name = "redis";
private readonly redis: Redis;
constructor(config: RedisCacheStoreConfig) {
this.redis = new Redis(config.connection);
}
private buildCacheKey(namespace: TNamespace, key: string): string {
return [namespace, key].join("::");
}
public async get(
namespace: TNamespace,
key: string
): Promise<Result<Entry<TValue> | undefined, CacheError>> {
let raw: string | null;
try {
raw = await this.redis.get(this.buildCacheKey(namespace, key));
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
if (!raw) {
return Promise.resolve(Ok(undefined));
}
try {
const superjson = await import("superjson");
const entry = superjson.parse(raw) as Entry<TValue>;
return Ok(entry);
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
public async set(
namespace: TNamespace,
key: string,
entry: Entry<TValue>
): Promise<Result<void, CacheError>> {
const cacheKey = this.buildCacheKey(namespace, key);
try {
const superjson = await import("superjson");
await this.redis.set(cacheKey, superjson.stringify(entry), "PXAT", entry.staleUntil);
return Ok();
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
public async remove(namespace: TNamespace, key: string): Promise<Result<void, CacheError>> {
try {
const cacheKey = this.buildCacheKey(namespace, key);
await this.redis.del(cacheKey);
return Promise.resolve(Ok());
} catch (err) {
return Err(
new CacheError({
tier: this.name,
key,
message: (err as Error).message,
})
);
}
}
}
+16 -1
View File
@@ -10,10 +10,25 @@ type CorsOptions = {
credentials?: boolean;
};
export function apiCors(
export async function apiCors(
request: Request,
response: Response,
options: CorsOptions = { maxAge: 5 * 60 }
): Promise<Response> {
if (hasCorsHeaders(response)) {
return response;
}
return cors(request, response, options);
}
export function makeApiCors(
request: Request,
options: CorsOptions = { maxAge: 5 * 60 }
): (response: Response) => Promise<Response> {
return (response: Response) => apiCors(request, response, options);
}
function hasCorsHeaders(response: Response) {
return response.headers.has("access-control-allow-origin");
}
+4 -11
View File
@@ -10,23 +10,16 @@ export async function longPollingFetch(url: string, options?: RequestInit) {
try {
let response = await fetch(url, options);
// Check if the response is ok (status in the range 200-299)
if (!response.ok) {
const body = await response.text();
throw new Error(`HTTP error! status: ${response.status}. ${body}`);
}
if (response.headers.get(`content-encoding`)) {
if (response.headers.get("content-encoding")) {
const headers = new Headers(response.headers);
headers.delete(`content-encoding`);
headers.delete(`content-length`);
headers.delete("content-encoding");
headers.delete("content-length");
response = new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
headers,
});
}
return response;
} catch (error) {
if (error instanceof TypeError) {
@@ -378,6 +378,7 @@ export class TriggerTaskService extends BaseService {
maxDurationInSeconds: body.options?.maxDuration
? clampMaxDuration(body.options.maxDuration)
: undefined,
runTags: bodyTags,
},
});
+9 -3
View File
@@ -23,7 +23,7 @@
"clean:sourcemaps": "run-s clean:sourcemaps:*",
"clean:sourcemaps:public": "rimraf ./build/**/*.map",
"clean:sourcemaps:build": "rimraf ./public/build/**/*.map",
"test": "vitest"
"test": "vitest --no-file-parallelism"
},
"eslintIgnore": [
"/node_modules",
@@ -97,11 +97,13 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/platform": "1.0.12",
"@trigger.dev/platform": "1.0.13",
"@trigger.dev/sdk": "workspace:*",
"@trigger.dev/yalt": "npm:@trigger.dev/yalt",
"@types/pg": "8.6.6",
"@uiw/react-codemirror": "^4.19.5",
"@unkey/cache": "^1.5.0",
"@unkey/error": "^0.2.0",
"@upstash/ratelimit": "^1.1.3",
"@whatwg-node/fetch": "^0.9.14",
"assert-never": "^1.2.1",
@@ -183,6 +185,7 @@
"zod-validation-error": "^1.5.0"
},
"devDependencies": {
"@internal/testcontainers": "workspace:*",
"@remix-run/dev": "2.1.0",
"@remix-run/eslint-config": "2.1.0",
"@remix-run/testing": "^2.1.0",
@@ -212,6 +215,7 @@
"@types/seedrandom": "^3.0.8",
"@types/simple-oauth2": "^5.0.4",
"@types/slug": "^5.0.3",
"@types/supertest": "^6.0.2",
"@types/tar": "^6.1.4",
"@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^5.59.6",
@@ -236,14 +240,16 @@
"prop-types": "^15.8.1",
"rimraf": "^3.0.2",
"style-loader": "^3.3.4",
"supertest": "^7.0.0",
"tailwind-scrollbar": "^3.0.1",
"tailwindcss": "3.4.1",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.14.1",
"typescript": "^5.1.6",
"vite-tsconfig-paths": "^4.0.5",
"vitest": "^1.4.0"
},
"engines": {
"node": ">=16.0.0"
}
}
}
+2
View File
@@ -15,6 +15,8 @@ module.exports = {
"@trigger.dev/sdk",
"@trigger.dev/platform",
"@trigger.dev/yalt",
"@unkey/cache",
"@unkey/cache/stores",
"emails",
"highlight.run",
"random-words",
+9 -9
View File
@@ -1,16 +1,16 @@
import path from "path";
import express from "express";
import compression from "compression";
import morgan from "morgan";
import { createRequestHandler } from "@remix-run/express";
import { WebSocketServer } from "ws";
import { broadcastDevReady, logDevReady } from "@remix-run/server-runtime";
import type { Server as IoServer } from "socket.io";
import compression from "compression";
import type { Server as EngineServer } from "engine.io";
import { RegistryProxy } from "~/v3/registryProxy.server";
import { RateLimitMiddleware, apiRateLimiter } from "~/services/apiRateLimit.server";
import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server";
import express from "express";
import morgan from "morgan";
import { nanoid } from "nanoid";
import path from "path";
import type { Server as IoServer } from "socket.io";
import { WebSocketServer } from "ws";
import { RateLimitMiddleware } from "~/services/apiRateLimit.server";
import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server";
import { RegistryProxy } from "~/v3/registryProxy.server";
const app = express();
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import { checkAuthorization, AuthorizationEntity } from "../app/services/authorization.server";
describe("checkAuthorization", () => {
// Test entities
const privateEntity: AuthorizationEntity = { type: "PRIVATE" };
const publicEntity: AuthorizationEntity = { type: "PUBLIC" };
const publicJwtEntityWithPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"],
};
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);
});
});
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);
});
});
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 false for unauthorized specific resources", () => {
expect(checkAuthorization(publicJwtEntityWithPermissions, "read", { runs: "run_5678" })).toBe(
false
);
});
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 true if any resource in an array is authorized", () => {
expect(
checkAuthorization(publicJwtEntityWithPermissions, "read", {
tags: ["tag_1234", "tag_5678"],
})
).toBe(true);
});
it("should return true for nonexistent resource types", () => {
expect(
// @ts-expect-error
checkAuthorization(publicJwtEntityWithPermissions, "read", { nonexistent: "resource" })
).toBe(true);
});
});
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
);
});
});
describe("Edge cases", () => {
it("should handle empty resource objects", () => {
expect(checkAuthorization(publicJwtEntityWithPermissions, "read", {})).toBe(false);
});
it("should handle undefined scope", () => {
const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
expect(checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" })).toBe(
false
);
});
it("should handle empty scope array", () => {
const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] };
expect(checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" })).toBe(false);
});
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 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);
});
});
describe("Super scope", () => {
const entityWithSuperPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:all", "admin"],
};
const entityWithOneSuperPermission: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:all"],
};
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);
});
it("should grant access with one matching super permission", () => {
expect(
checkAuthorization(entityWithOneSuperPermission, "read", { runs: "run_5678" }, [
"read:all",
"admin",
])
).toBe(true);
});
it("should not grant access when no super scope match", () => {
expect(
checkAuthorization(entityWithOneSuperPermission, "read", { tasks: "task_1234" }, [
"write:all",
"admin",
])
).toBe(false);
});
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);
});
it("should fall back to specific scope when super scope are not provided", () => {
const entityWithSpecificPermissions: AuthorizationEntity = {
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
);
});
});
describe("Without super scope", () => {
const entityWithoutSuperPermissions: AuthorizationEntity = {
type: "PUBLIC_JWT",
scopes: ["read:tasks"],
};
it("should still grant access based on specific scope", () => {
expect(
checkAuthorization(entityWithoutSuperPermissions, "read", { tasks: "task_1234" }, [
"read:all",
"admin",
])
).toBe(true);
});
it("should deny access to resources not in scope", () => {
expect(
checkAuthorization(entityWithoutSuperPermissions, "read", { runs: "run_5678" }, [
"read:all",
"admin",
])
).toBe(false);
});
});
});
@@ -0,0 +1,416 @@
import { redisTest } from "@internal/testcontainers";
import { describe, expect, vi, beforeEach } from "vitest";
vi.setConfig({ testTimeout: 30_000 }); // 30 seconds timeout
// Mock the logger
vi.mock("./logger.server", () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
import express, { Express } from "express";
import request from "supertest";
import { authorizationRateLimitMiddleware } from "../app/services/authorizationRateLimitMiddleware.server.js";
describe("authorizationRateLimitMiddleware", () => {
let app: Express;
beforeEach(() => {
app = express();
});
redisTest("should allow requests within the rate limit", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
log: {
rejections: false,
requests: false,
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Success" });
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
expect(response.headers["x-ratelimit-remaining"]).toBeDefined();
expect(response.headers["x-ratelimit-reset"]).toBeDefined();
});
redisTest("should reject requests without an Authorization header", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
const response = await request(app).get("/api/test");
expect(response.status).toBe(401);
expect(response.body).toHaveProperty("title", "Unauthorized");
});
redisTest("should reject requests that exceed the rate limit", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// First request should succeed
await request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Second request should be rate limited
const response = await request(app).get("/api/test").set("Authorization", "Bearer test-token");
expect(response.status).toBe(429);
expect(response.body).toHaveProperty("title", "Rate Limit Exceeded");
});
redisTest("should not apply rate limiting to whitelisted paths", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
},
pathMatchers: [/^\/api/],
pathWhiteList: ["/api/whitelist"],
});
app.use(rateLimitMiddleware);
app.get("/api/whitelist", (req, res) => {
res.status(200).json({ message: "Whitelisted" });
});
const response = await request(app)
.get("/api/whitelist")
.set("Authorization", "Bearer test-token");
expect(response.status).toBe(200);
expect(response.body).toEqual({ message: "Whitelisted" });
expect(response.headers["x-ratelimit-limit"]).toBeUndefined();
});
redisTest(
"should apply different rate limits based on limiterConfigOverride",
async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
limiterConfigOverride: async (authorizationValue) => {
if (authorizationValue === "Bearer premium-token") {
return {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => {
res.status(200).json({ message: "Success" });
});
// Regular user should be rate limited after 1 request
await request(app).get("/api/test").set("Authorization", "Bearer regular-token");
const regularResponse = await request(app)
.get("/api/test")
.set("Authorization", "Bearer regular-token");
expect(regularResponse.status).toBe(429);
// Premium user should be able to make multiple requests
const premiumResponse1 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse1.status).toBe(200);
const premiumResponse2 = await request(app)
.get("/api/test")
.set("Authorization", "Bearer premium-token");
expect(premiumResponse2.status).toBe(200);
}
);
describe("Advanced Cases", () => {
// 1. Test different rate limit configurations
redisTest("should enforce fixed window rate limiting", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test-fixed",
defaultLimiter: {
type: "fixedWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for the window to reset
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
redisTest("should enforce sliding window rate limiting", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test-sliding",
defaultLimiter: {
type: "slidingWindow",
window: "10s",
tokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Should allow 3 requests
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// 4th request should be rate limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for part of the window to pass
await new Promise((resolve) => setTimeout(resolve, 1000));
// Should still be limited
const stillLimitedResponse = await makeRequest();
expect(stillLimitedResponse.status).toBe(429);
// Wait for the full window to pass
await new Promise((resolve) => setTimeout(resolve, 10000));
// Should allow requests again
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
});
// 2. Test edge cases around rate limit calculations
redisTest("should handle token refill correctly", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test-refill",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "5s",
maxTokens: 3,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// Use up all tokens
for (let i = 0; i < 3; i++) {
const response = await makeRequest();
expect(response.status).toBe(200);
}
// Next request should be limited
const limitedResponse = await makeRequest();
expect(limitedResponse.status).toBe(429);
// Wait for one token to be refilled
await new Promise((resolve) => setTimeout(resolve, 5000));
// Should allow one request
const newResponse = await makeRequest();
expect(newResponse.status).toBe(200);
// But the next one should be limited again
const limitedAgainResponse = await makeRequest();
expect(limitedAgainResponse.status).toBe(429);
});
redisTest("should handle near-zero remaining tokens correctly", async ({ redis }) => {
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test-near-zero",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1, // 1 token every 5 seconds
interval: "5s",
maxTokens: 1,
},
pathMatchers: [/^\/api/],
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer test-token");
// First request should succeed
const firstResponse = await makeRequest();
expect(firstResponse.status).toBe(200);
// Immediate second request should fail
const secondResponse = await makeRequest();
expect(secondResponse.status).toBe(429);
// Wait for almost one token to be refilled (4.9 seconds)
await new Promise((resolve) => setTimeout(resolve, 4900));
// This request should still fail as we're just shy of a full token
const thirdResponse = await makeRequest();
expect(thirdResponse.status).toBe(429);
// Wait for the full token to be refilled (additional 200ms)
await new Promise((resolve) => setTimeout(resolve, 200));
// This request should now succeed
const fourthResponse = await makeRequest();
expect(fourthResponse.status).toBe(200);
// Immediate next request should fail again
const fifthResponse = await makeRequest();
expect(fifthResponse.status).toBe(429);
});
// 3. Test the limiterCache functionality
redisTest("should use cached limiter configurations", async ({ redis }) => {
let configOverrideCalls = 0;
const rateLimitMiddleware = authorizationRateLimitMiddleware({
redis: redis.options,
keyPrefix: "test-cache",
defaultLimiter: {
type: "tokenBucket",
refillRate: 1,
interval: "1m",
maxTokens: 10,
},
pathMatchers: [/^\/api/],
limiterCache: {
fresh: 1000, // 1 second
stale: 2000, // 2 seconds
},
limiterConfigOverride: async (authorizationValue) => {
configOverrideCalls++;
if (authorizationValue === "Bearer premium-token") {
return {
type: "tokenBucket",
refillRate: 10,
interval: "1m",
maxTokens: 100,
};
}
return undefined;
},
});
app.use(rateLimitMiddleware);
app.get("/api/test", (req, res) => res.status(200).json({ message: "Success" }));
const makeRequest = () =>
request(app).get("/api/test").set("Authorization", "Bearer premium-token");
// First request should call the override
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Subsequent requests within 1 second should use the cache
await makeRequest();
await makeRequest();
expect(configOverrideCalls).toBe(1);
// Wait for the cache to become stale
await new Promise((resolve) => setTimeout(resolve, 1100));
// This should still use the cache, but also trigger a refresh
await makeRequest();
expect(configOverrideCalls).toBe(2);
// Wait for the cache to expire completely
await new Promise((resolve) => setTimeout(resolve, 1000));
// This should trigger a new override call
await makeRequest();
expect(configOverrideCalls).toBe(3);
});
});
});
-5
View File
@@ -1,5 +0,0 @@
describe("Placeholder", () => {
it("should pass", () => {
expect(true).toBe(true);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { containerWithElectricTest } from "@internal/testcontainers";
import { expect, describe } from "vitest";
import { RealtimeClient } from "../app/services/realtimeClient.server.js";
describe("RealtimeClient", () => {
containerWithElectricTest(
"Should only track concurrency for live requests",
{ 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
);
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);
}
);
containerWithElectricTest(
"Should support subscribing to a run tag",
{ 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,
runTags: ["test:tag:1234", "test:tag:5678"],
},
});
const response = await client.streamRuns("http://localhost:3000?offset=-1", environment, {
tags: ["test:tag:1234"],
});
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(shapeId).toBeDefined();
expect(chunkOffset).toBe("0_0");
}
);
});
+1
View File
@@ -7,6 +7,7 @@
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"module": "esnext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"target": "ES2019",
+4
View File
@@ -1,8 +1,12 @@
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
globals: true,
pool: "forks",
},
// @ts-ignore
plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })],
});
+2 -2
View File
@@ -60,10 +60,10 @@ services:
- 6379:6379
electric:
image: electricsql/electric
image: electricsql/electric:0.7.5
restart: always
environment:
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres
DATABASE_URL: postgresql://postgres:postgres@database:5432/postgres?sslmode=disable
networks:
- app_network
ports:
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "runTags" TEXT[];
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "apiRateLimiterConfig" JSONB,
ADD COLUMN "realtimeRateLimiterConfig" JSONB;
@@ -138,6 +138,9 @@ model Organization {
events EventRecord[]
jobRuns JobRun[]
apiRateLimiterConfig Json?
realtimeRateLimiterConfig Json?
projects Project[]
members OrgMember[]
invites OrgMemberInvite[]
@@ -1683,6 +1686,9 @@ model TaskRun {
attempts TaskRunAttempt[] @relation("attempts")
tags TaskRunTag[]
/// Denormized column that holds the raw tags
runTags String[]
checkpoints Checkpoint[]
startedAt DateTime?
@@ -14,6 +14,7 @@
"@testcontainers/postgresql": "^10.13.1",
"@testcontainers/redis": "^10.13.1",
"testcontainers": "^10.13.1",
"tinyexec": "^0.3.0",
"vitest": "^1.4.0"
},
"scripts": {
+45 -6
View File
@@ -3,20 +3,37 @@ import { StartedRedisContainer } from "@testcontainers/redis";
import { Redis } from "ioredis";
import { test } from "vitest";
import { PrismaClient } from "@trigger.dev/database";
import { createPostgresContainer, createRedisContainer } from "./utils";
import { createPostgresContainer, createRedisContainer, createElectricContainer } from "./utils";
import { Network, type StartedNetwork, type StartedTestContainer } from "testcontainers";
type PostgresContext = {
type NetworkContext = { network: StartedNetwork };
type PostgresContext = NetworkContext & {
postgresContainer: StartedPostgreSqlContainer;
prisma: PrismaClient;
};
type RedisContext = { redisContainer: StartedRedisContainer; redis: Redis };
type ContainerContext = PostgresContext & RedisContext;
type ElectricContext = {
electricOrigin: string;
};
type ContainerContext = NetworkContext & PostgresContext & RedisContext;
type ContainerWithElectricContext = ContainerContext & ElectricContext;
type Use<T> = (value: T) => Promise<void>;
const postgresContainer = async ({}, use: Use<StartedPostgreSqlContainer>) => {
const { container } = await createPostgresContainer();
const network = async ({}, use: Use<StartedNetwork>) => {
const network = await new Network().start();
await use(network);
};
const postgresContainer = async (
{ network }: { network: StartedNetwork },
use: Use<StartedPostgreSqlContainer>
) => {
const { container } = await createPostgresContainer(network);
await use(container);
await container.stop();
};
@@ -36,7 +53,7 @@ const prisma = async (
await prisma.$disconnect();
};
export const postgresTest = test.extend<PostgresContext>({ postgresContainer, prisma });
export const postgresTest = test.extend<PostgresContext>({ network, postgresContainer, prisma });
const redisContainer = async ({}, use: Use<StartedRedisContainer>) => {
const { container } = await createRedisContainer();
@@ -59,9 +76,31 @@ const redis = async (
export const redisTest = test.extend<RedisContext>({ redisContainer, redis });
const electricOrigin = async (
{
postgresContainer,
network,
}: { postgresContainer: StartedPostgreSqlContainer; network: StartedNetwork },
use: Use<string>
) => {
const { origin, container } = await createElectricContainer(postgresContainer, network);
await use(origin);
await container.stop();
};
export const containerTest = test.extend<ContainerContext>({
network,
postgresContainer,
prisma,
redisContainer,
redis,
});
export const containerWithElectricTest = test.extend<ContainerWithElectricContext>({
network,
postgresContainer,
prisma,
redisContainer,
redis,
electricOrigin,
});
+57 -22
View File
@@ -1,35 +1,70 @@
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { PostgreSqlContainer, StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { RedisContainer } from "@testcontainers/redis";
import { execSync } from "child_process";
import path from "path";
import { GenericContainer, StartedNetwork } from "testcontainers";
import { x } from "tinyexec";
export async function createPostgresContainer() {
const container = await new PostgreSqlContainer().start();
export async function createPostgresContainer(network: StartedNetwork) {
const container = await new PostgreSqlContainer("docker.io/postgres:14")
.withNetwork(network)
.withNetworkAliases("database")
.withCommand(["-c", "listen_addresses=*", "-c", "wal_level=logical"])
.start();
// Run migrations
const databasePath = path.resolve(__dirname, "../../database");
execSync(`npx prisma@5.4.1 db push --schema ${databasePath}/prisma/schema.prisma`, {
env: {
...process.env,
DATABASE_URL: container.getConnectionUri(),
DIRECT_URL: container.getConnectionUri(),
},
});
await x(
`${databasePath}/node_modules/.bin/prisma`,
[
"db",
"push",
"--force-reset",
"--accept-data-loss",
"--skip-generate",
"--schema",
`${databasePath}/prisma/schema.prisma`,
],
{
nodeOptions: {
env: {
...process.env,
DATABASE_URL: container.getConnectionUri(),
DIRECT_URL: container.getConnectionUri(),
},
},
}
);
// console.log(container.getConnectionUri());
return { url: container.getConnectionUri(), container };
return { url: container.getConnectionUri(), container, network };
}
export async function createRedisContainer() {
const container = await new RedisContainer().start();
try {
return {
container,
};
} catch (e) {
console.error(e);
throw e;
}
return {
container,
};
}
export async function createElectricContainer(
postgresContainer: StartedPostgreSqlContainer,
network: StartedNetwork
) {
const databaseUrl = `postgresql://${postgresContainer.getUsername()}:${postgresContainer.getPassword()}@${postgresContainer.getIpAddress(
network.getName()
)}:5432/${postgresContainer.getDatabase()}?sslmode=disable`;
const container = await new GenericContainer("electricsql/electric:0.7.5")
.withExposedPorts(3000)
.withNetwork(network)
.withEnvironment({
DATABASE_URL: databaseUrl,
})
.start();
return {
container,
origin: `http://${container.getHost()}:${container.getMappedPort(3000)}`,
};
}
@@ -11,17 +11,19 @@ import {
TaskRunExecution,
WorkerToExecutorMessageCatalog,
TriggerConfig,
TriggerTracer,
WorkerManifest,
ExecutorToWorkerMessageCatalog,
timeout,
runMetadata,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import { ProdRuntimeManager } from "@trigger.dev/core/v3/prod";
import {
ConsoleInterceptor,
DevUsageManager,
DurableClock,
getEnvVar,
getNumberEnvVar,
logLevels,
OtelTaskLogger,
ProdUsageManager,
@@ -303,6 +305,10 @@ const zodIpc = new ZodIpcConnection({
_execution = execution;
_isRunning = true;
runMetadata.startPeriodicFlush(
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
const measurement = usage.start();
// This lives outside of the executor because this will eventually be moved to the controller level
@@ -397,7 +403,11 @@ const zodIpc = new ZodIpcConnection({
async function flushAll(timeoutInMs: number = 10_000) {
const now = performance.now();
await Promise.all([flushUsage(timeoutInMs), flushTracingSDK(timeoutInMs)]);
await Promise.all([
flushUsage(timeoutInMs),
flushTracingSDK(timeoutInMs),
flushMetadata(timeoutInMs),
]);
const duration = performance.now() - now;
@@ -424,6 +434,16 @@ async function flushTracingSDK(timeoutInMs: number = 10_000) {
console.log(`Flushed tracingSDK in ${duration}ms`);
}
async function flushMetadata(timeoutInMs: number = 10_000) {
const now = performance.now();
await Promise.race([runMetadata.flush(), setTimeout(timeoutInMs)]);
const duration = performance.now() - now;
console.log(`Flushed runMetadata in ${duration}ms`);
}
const prodRuntimeManager = new ProdRuntimeManager(zodIpc, {
waitThresholdInMs: parseInt(env.TRIGGER_RUNTIME_WAIT_THRESHOLD_IN_MS ?? "30000", 10),
});
@@ -11,11 +11,12 @@ import {
TaskRunExecution,
WorkerToExecutorMessageCatalog,
TriggerConfig,
TriggerTracer,
WorkerManifest,
ExecutorToWorkerMessageCatalog,
timeout,
runMetadata,
} from "@trigger.dev/core/v3";
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
import { DevRuntimeManager } from "@trigger.dev/core/v3/dev";
import {
ConsoleInterceptor,
@@ -30,6 +31,7 @@ import {
TracingDiagnosticLogLevel,
TracingSDK,
usage,
getNumberEnvVar,
} from "@trigger.dev/core/v3/workers";
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
import { readFile } from "node:fs/promises";
@@ -273,6 +275,9 @@ const zodIpc = new ZodIpcConnection({
_execution = execution;
_isRunning = true;
runMetadata.startPeriodicFlush(
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
const measurement = usage.start();
// This lives outside of the executor because this will eventually be moved to the controller level
@@ -345,7 +350,7 @@ const zodIpc = new ZodIpcConnection({
}
},
FLUSH: async ({ timeoutInMs }, sender) => {
await _tracingSDK?.flush();
await Promise.allSettled([_tracingSDK?.flush(), runMetadata.flush()]);
},
},
});
+34
View File
@@ -33,8 +33,10 @@
"./types": "./src/types.ts",
"./versions": "./src/versions.ts",
"./v3": "./src/v3/index.ts",
"./v3/tracer": "./src/v3/tracer.ts",
"./v3/build": "./src/v3/build/index.ts",
"./v3/apps": "./src/v3/apps/index.ts",
"./v3/jwt": "./src/v3/jwt.ts",
"./v3/errors": "./src/v3/errors.ts",
"./v3/logger-api": "./src/v3/logger-api.ts",
"./v3/otel": "./src/v3/otel/index.ts",
@@ -95,6 +97,9 @@
"v3": [
"dist/commonjs/v3/index.d.ts"
],
"v3/tracer": [
"dist/commonjs/v3/tracer.d.ts"
],
"v3/build": [
"dist/commonjs/v3/build/index.d.ts"
],
@@ -160,6 +165,9 @@
],
"v3/schemas": [
"dist/commonjs/v3/schemas/index.d.ts"
],
"v3/jwt": [
"dist/commonjs/v3/jwt.d.ts"
]
}
},
@@ -174,7 +182,9 @@
"check-exports": "attw --pack ."
},
"dependencies": {
"@electric-sql/client": "0.6.3",
"@google-cloud/precise-date": "^4.0.0",
"@jsonhero/path": "^1.0.21",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/api-logs": "0.52.1",
"@opentelemetry/exporter-logs-otlp-http": "0.52.1",
@@ -186,8 +196,10 @@
"@opentelemetry/sdk-trace-base": "1.25.1",
"@opentelemetry/sdk-trace-node": "1.25.1",
"@opentelemetry/semantic-conventions": "1.25.1",
"dequal": "^2.0.3",
"execa": "^8.0.1",
"humanize-duration": "^3.27.3",
"jose": "^5.4.0",
"nanoid": "^3.3.4",
"socket.io-client": "4.7.5",
"superjson": "^2.2.1",
@@ -347,6 +359,17 @@
"default": "./dist/commonjs/v3/index.js"
}
},
"./v3/tracer": {
"import": {
"@triggerdotdev/source": "./src/v3/tracer.ts",
"types": "./dist/esm/v3/tracer.d.ts",
"default": "./dist/esm/v3/tracer.js"
},
"require": {
"types": "./dist/commonjs/v3/tracer.d.ts",
"default": "./dist/commonjs/v3/tracer.js"
}
},
"./v3/build": {
"import": {
"@triggerdotdev/source": "./src/v3/build/index.ts",
@@ -369,6 +392,17 @@
"default": "./dist/commonjs/v3/apps/index.js"
}
},
"./v3/jwt": {
"import": {
"@triggerdotdev/source": "./src/v3/jwt.ts",
"types": "./dist/esm/v3/jwt.d.ts",
"default": "./dist/esm/v3/jwt.js"
},
"require": {
"types": "./dist/commonjs/v3/jwt.d.ts",
"default": "./dist/commonjs/v3/jwt.js"
}
},
"./v3/errors": {
"import": {
"@triggerdotdev/source": "./src/v3/errors.ts",
+3 -1
View File
@@ -6,7 +6,7 @@ import { ApiConnectionError, ApiError, ApiSchemaValidationError } from "./errors
import { Attributes, Span, context, propagation } from "@opentelemetry/api";
import { SemanticInternalAttributes } from "../semanticInternalAttributes.js";
import { TriggerTracer } from "../tracer.js";
import type { TriggerTracer } from "../tracer.js";
import { accessoryAttributes } from "../utils/styleAttributes.js";
import {
CursorPage,
@@ -16,6 +16,7 @@ import {
OffsetLimitPageParams,
OffsetLimitPageResponse,
} from "./pagination.js";
import { TriggerJwtOptions } from "../types/tasks.js";
export const defaultRetryOptions = {
maxAttempts: 3,
@@ -35,6 +36,7 @@ export type ZodFetchOptions = {
};
export type ApiRequestOptions = Pick<ZodFetchOptions, "retry">;
type KeysEnum<T> = { [P in keyof Required<T>]: true };
// This is required so that we can determine if a given object matches the ApiRequestOptions
+156 -11
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { VERSION } from "../../version.js";
import {
AddTagsRequestBody,
BatchTaskRunExecutionResult,
@@ -37,25 +38,44 @@ import {
zodfetchOffsetLimitPage,
} from "./core.js";
import { ApiError } from "./errors.js";
import {
RunShape,
AnyRunShape,
runShapeStream,
RunStreamCallback,
RunSubscription,
TaskRunShape,
} from "./runStream.js";
import {
CreateEnvironmentVariableParams,
ImportEnvironmentVariablesParams,
ListProjectRunsQueryParams,
ListRunsQueryParams,
SubscribeToRunsQueryParams,
UpdateEnvironmentVariableParams,
} from "./types.js";
import { VERSION } from "../../version.js";
import { generateJWT } from "../jwt.js";
import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js";
export type {
CreateEnvironmentVariableParams,
ImportEnvironmentVariablesParams,
UpdateEnvironmentVariableParams,
SubscribeToRunsQueryParams,
};
export type TriggerOptions = {
spanParentAsLink?: boolean;
};
export type TriggerRequestOptions = ZodFetchOptions & {
publicAccessToken?: TriggerJwtOptions;
};
export type TriggerApiRequestOptions = ApiRequestOptions & {
publicAccessToken?: TriggerJwtOptions;
};
const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
retry: {
maxAttempts: 3,
@@ -68,23 +88,40 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
export { isRequestOptions };
export type { ApiRequestOptions };
export type { RunShape, AnyRunShape, TaskRunShape, RunStreamCallback, RunSubscription };
/**
* Trigger.dev v3 API client
*/
export class ApiClient {
private readonly baseUrl: string;
public readonly baseUrl: string;
public readonly accessToken: string;
private readonly defaultRequestOptions: ZodFetchOptions;
constructor(
baseUrl: string,
private readonly accessToken: string,
requestOptions: ApiRequestOptions = {}
) {
constructor(baseUrl: string, accessToken: string, requestOptions: ApiRequestOptions = {}) {
this.accessToken = accessToken;
this.baseUrl = baseUrl.replace(/\/$/, "");
this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions);
}
get fetchClient(): typeof fetch {
const headers = this.#getHeaders(false);
const fetchClient: typeof fetch = (input, requestInit) => {
const $requestInit: RequestInit = {
...requestInit,
headers: {
...requestInit?.headers,
...headers,
},
};
return fetch(input, $requestInit);
};
return fetchClient;
}
async getRunResult(
runId: string,
requestOptions?: ZodFetchOptions
@@ -129,7 +166,7 @@ export class ApiClient {
taskId: string,
body: TriggerTaskRequestBody,
options?: TriggerOptions,
requestOptions?: ZodFetchOptions
requestOptions?: TriggerRequestOptions
) {
const encodedTaskId = encodeURIComponent(taskId);
@@ -142,14 +179,35 @@ export class ApiClient {
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
)
.withResponse()
.then(async ({ response, data }) => {
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
const jwt = await generateJWT({
secretKey: this.accessToken,
payload: {
...claims,
scopes: [`read:runs:${data.id}`].concat(
body.options?.tags ? Array.from(body.options?.tags).map((t) => `read:tags:${t}`) : []
),
},
expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h",
});
return {
...data,
publicAccessToken: jwt,
};
});
}
batchTriggerTask(
taskId: string,
body: BatchTriggerTaskRequestBody,
options?: TriggerOptions,
requestOptions?: ZodFetchOptions
requestOptions?: TriggerRequestOptions
) {
const encodedTaskId = encodeURIComponent(taskId);
@@ -162,7 +220,26 @@ export class ApiClient {
body: JSON.stringify(body),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
)
.withResponse()
.then(async ({ response, data }) => {
const claimsHeader = response.headers.get("x-trigger-jwt-claims");
const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined;
const jwt = await generateJWT({
secretKey: this.accessToken,
payload: {
...claims,
scopes: [`read:batch:${data.batchId}`],
},
expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h",
});
return {
...data,
publicAccessToken: jwt,
};
});
}
createUploadPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) {
@@ -517,6 +594,46 @@ export class ApiClient {
);
}
subscribeToRun<TRunTypes extends AnyRunTypes>(runId: string) {
return runShapeStream<TRunTypes>(`${this.baseUrl}/realtime/v1/runs/${runId}`, {
closeOnComplete: true,
headers: this.#getRealtimeHeaders(),
});
}
subscribeToRunsWithTag<TRunTypes extends AnyRunTypes>(tag: string | string[]) {
const searchParams = createSearchQueryForSubscribeToRuns({
tags: tag,
});
return runShapeStream<TRunTypes>(
`${this.baseUrl}/realtime/v1/runs${searchParams ? `?${searchParams}` : ""}`,
{
closeOnComplete: false,
headers: this.#getRealtimeHeaders(),
}
);
}
subscribeToBatch<TRunTypes extends AnyRunTypes>(batchId: string) {
return runShapeStream<TRunTypes>(`${this.baseUrl}/realtime/v1/batches/${batchId}`, {
closeOnComplete: false,
headers: this.#getRealtimeHeaders(),
});
}
async generateJWTClaims(requestOptions?: ZodFetchOptions): Promise<Record<string, any>> {
return zodfetch(
z.record(z.any()),
`${this.baseUrl}/api/v1/auth/jwt/claims`,
{
method: "POST",
headers: this.#getHeaders(false),
},
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
);
}
#getHeaders(spanParentAsLink: boolean) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
@@ -535,6 +652,34 @@ export class ApiClient {
return headers;
}
#getRealtimeHeaders() {
const headers: Record<string, string> = {
Authorization: `Bearer ${this.accessToken}`,
"trigger-version": VERSION,
};
return headers;
}
}
function createSearchQueryForSubscribeToRuns(query?: SubscribeToRunsQueryParams): URLSearchParams {
const searchParams = new URLSearchParams();
if (query) {
if (query.tasks) {
searchParams.append(
"tasks",
Array.isArray(query.tasks) ? query.tasks.join(",") : query.tasks
);
}
if (query.tags) {
searchParams.append("tags", Array.isArray(query.tags) ? query.tags.join(",") : query.tags);
}
}
return searchParams;
}
function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchParams {
+232
View File
@@ -0,0 +1,232 @@
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 {
conditionallyImportAndParsePacket,
IOPacket,
parsePacket,
} from "../utils/ioSerialization.js";
import { AsyncIterableStream, createAsyncIterableStream, zodShapeStream } from "./stream.js";
export type RunShape<TRunTypes extends AnyRunTypes> = TRunTypes extends AnyRunTypes
? {
id: string;
taskIdentifier: TRunTypes["taskIdentifier"];
payload: TRunTypes["payload"];
output?: TRunTypes["output"];
createdAt: Date;
updatedAt: Date;
number: number;
status: RunStatus;
durationMs: number;
costInCents: number;
baseCostInCents: number;
tags: string[];
idempotencyKey?: string;
expiredAt?: Date;
ttl?: string;
finishedAt?: Date;
startedAt?: Date;
delayedUntil?: Date;
queuedAt?: Date;
metadata?: Record<string, DeserializedJson>;
error?: SerializedError;
isTest: boolean;
}
: never;
export type AnyRunShape = RunShape<AnyRunTypes>;
export type TaskRunShape<TTask extends AnyTask> = RunShape<InferRunTypes<TTask>>;
export type RunStreamCallback<TRunTypes extends AnyRunTypes> = (
run: RunShape<TRunTypes>
) => void | Promise<void>;
export type RunShapeStreamOptions = {
headers?: Record<string, string>;
fetchClient?: typeof fetch;
closeOnComplete?: boolean;
};
export function runShapeStream<TRunTypes extends AnyRunTypes>(
url: string,
options?: RunShapeStreamOptions
): RunSubscription<TRunTypes> {
return new RunSubscription<TRunTypes>(url, options);
}
export class RunSubscription<TRunTypes extends AnyRunTypes> {
private abortController: AbortController;
private unsubscribeShape?: () => void;
private stream: AsyncIterableStream<RunShape<TRunTypes>>;
private packetCache = new Map<string, any>();
constructor(
private url: string,
private options?: RunShapeStreamOptions
) {
this.abortController = new AbortController();
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,
}
);
},
cancel: () => {
this.unsubscribe();
},
});
this.stream = createAsyncIterableStream(source, {
transform: async (chunk, controller) => {
const run = await this.transformRunShape(chunk);
controller.enqueue(run);
},
});
}
unsubscribe(): void {
if (!this.abortController.signal.aborted) {
this.abortController.abort();
}
this.unsubscribeShape?.();
}
[Symbol.asyncIterator](): AsyncIterator<RunShape<TRunTypes>> {
return this.stream[Symbol.asyncIterator]();
}
getReader(): ReadableStreamDefaultReader<RunShape<TRunTypes>> {
return this.stream.getReader();
}
private async transformRunShape(row: SubscribeRunRawShape): Promise<RunShape<TRunTypes>> {
const payloadPacket = row.payloadType
? ({ data: row.payload ?? undefined, dataType: row.payloadType } satisfies IOPacket)
: undefined;
const outputPacket = row.outputType
? ({ data: row.output ?? undefined, dataType: row.outputType } satisfies IOPacket)
: undefined;
const [payload, output] = await Promise.all(
[
{ packet: payloadPacket, key: "payload" },
{ packet: outputPacket, key: "output" },
].map(async ({ packet, key }) => {
if (!packet) {
return;
}
const cachedResult = this.packetCache.get(`${row.friendlyId}/${key}`);
if (typeof cachedResult !== "undefined") {
return cachedResult;
}
const result = await conditionallyImportAndParsePacket(packet);
this.packetCache.set(`${row.friendlyId}/${key}`, result);
return result;
})
);
const metadata =
row.metadata && row.metadataType
? await parsePacket({ data: row.metadata, dataType: row.metadataType })
: undefined;
return {
id: row.friendlyId,
payload,
output,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
taskIdentifier: row.taskIdentifier,
number: row.number,
status: apiStatusFromRunStatus(row.status),
durationMs: row.usageDurationMs,
costInCents: row.costInCents,
baseCostInCents: row.baseCostInCents,
tags: row.runTags ?? [],
idempotencyKey: row.idempotencyKey ?? undefined,
expiredAt: row.expiredAt ?? undefined,
finishedAt: row.completedAt ?? undefined,
startedAt: row.startedAt ?? undefined,
delayedUntil: row.delayUntil ?? undefined,
queuedAt: row.queuedAt ?? undefined,
error: row.error ?? undefined,
isTest: row.isTest,
metadata,
} as RunShape<TRunTypes>;
}
}
function apiStatusFromRunStatus(status: string): RunStatus {
switch (status) {
case "DELAYED": {
return "DELAYED";
}
case "WAITING_FOR_DEPLOY": {
return "WAITING_FOR_DEPLOY";
}
case "PENDING": {
return "QUEUED";
}
case "PAUSED":
case "WAITING_TO_RESUME": {
return "FROZEN";
}
case "RETRYING_AFTER_FAILURE": {
return "REATTEMPTING";
}
case "EXECUTING": {
return "EXECUTING";
}
case "CANCELED": {
return "CANCELED";
}
case "COMPLETED_SUCCESSFULLY": {
return "COMPLETED";
}
case "SYSTEM_FAILURE": {
return "SYSTEM_FAILURE";
}
case "INTERRUPTED": {
return "INTERRUPTED";
}
case "CRASHED": {
return "CRASHED";
}
case "COMPLETED_WITH_ERRORS": {
return "FAILED";
}
case "EXPIRED": {
return "EXPIRED";
}
default: {
throw new Error(`Unknown status: ${status}`);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import { z } from "zod";
export type ZodShapeStreamOptions = {
headers?: Record<string, string>;
fetchClient?: typeof fetch;
signal?: AbortSignal;
};
export async function zodShapeStream<TShapeSchema extends z.ZodTypeAny>(
schema: TShapeSchema,
url: string,
callback: (shape: z.output<TShapeSchema>) => void | Promise<void>,
options?: ZodShapeStreamOptions
) {
const { ShapeStream, Shape } = await import("@electric-sql/client");
const stream = new ShapeStream<z.input<TShapeSchema>>({
url,
headers: options?.headers,
fetchClient: options?.fetchClient,
signal: options?.signal,
});
const shape = new Shape(stream);
const initialValue = await shape.value;
for (const shapeRow of initialValue.values()) {
await callback(schema.parse(shapeRow));
}
return shape.subscribe(async (newShape) => {
for (const shapeRow of newShape.values()) {
await callback(schema.parse(shapeRow));
}
});
}
export type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
export function createAsyncIterableStream<S, T>(
source: ReadableStream<S>,
transformer: Transformer<S, T>
): AsyncIterableStream<T> {
const transformedStream: any = source.pipeThrough(new TransformStream(transformer));
transformedStream[Symbol.asyncIterator] = () => {
const reader = transformedStream.getReader();
return {
async next(): Promise<IteratorResult<string>> {
const { done, value } = await reader.read();
return done ? { done: true, value: undefined } : { done: false, value };
},
};
};
return transformedStream;
}
+5
View File
@@ -36,3 +36,8 @@ export interface ListRunsQueryParams extends CursorPageParams {
export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams {
env?: Array<"dev" | "staging" | "prod"> | "dev" | "staging" | "prod";
}
export interface SubscribeToRunsQueryParams {
tasks?: Array<string> | string;
tags?: Array<string> | string;
}
+26 -8
View File
@@ -29,18 +29,19 @@ export class APIClientManagerAPI {
unregisterGlobal(API_NAME);
}
public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean {
return registerGlobal(API_NAME, config);
}
get baseURL(): string | undefined {
const store = this.#getConfig();
return store?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
const config = this.#getConfig();
return config?.baseURL ?? getEnvVar("TRIGGER_API_URL") ?? "https://api.trigger.dev";
}
get accessToken(): string | undefined {
const store = this.#getConfig();
return store?.secretKey ?? getEnvVar("TRIGGER_SECRET_KEY") ?? getEnvVar("TRIGGER_ACCESS_TOKEN");
const config = this.#getConfig();
return (
config?.secretKey ??
config?.accessToken ??
getEnvVar("TRIGGER_SECRET_KEY") ??
getEnvVar("TRIGGER_ACCESS_TOKEN")
);
}
get client(): ApiClient | undefined {
@@ -59,6 +60,23 @@ export class APIClientManagerAPI {
return new ApiClient(this.baseURL, this.accessToken);
}
runWithConfig<R extends (...args: any[]) => Promise<any>>(
config: ApiClientConfiguration,
fn: R
): Promise<ReturnType<R>> {
const originalConfig = this.#getConfig();
const $config = { ...originalConfig, ...config };
registerGlobal(API_NAME, $config, true);
return fn().finally(() => {
registerGlobal(API_NAME, originalConfig, true);
});
}
public setGlobalAPIClientConfiguration(config: ApiClientConfiguration): boolean {
return registerGlobal(API_NAME, config);
}
#getConfig(): ApiClientConfiguration | undefined {
return getGlobal(API_NAME);
}
@@ -2,6 +2,13 @@ import { type ApiRequestOptions } from "../apiClient/index.js";
export type ApiClientConfiguration = {
baseURL?: string;
/**
* @deprecated Use `accessToken` instead.
*/
secretKey?: string;
/**
* The access token to authenticate with the Trigger API.
*/
accessToken?: string;
requestOptions?: ApiRequestOptions;
};
+12
View File
@@ -18,6 +18,18 @@ export class AbortTaskRunError extends Error {
}
}
export class TaskPayloadParsedError extends Error {
public readonly cause: unknown;
constructor(cause: unknown) {
const causeMessage = cause instanceof Error ? cause.message : String(cause);
super("Parsing payload with schema failed: " + causeMessage);
this.name = "TaskPayloadParsedError";
this.cause = cause;
}
}
export function parseError(error: unknown): TaskRunError {
if (error instanceof Error) {
return {
+2 -2
View File
@@ -18,6 +18,7 @@ export { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
export * from "./task-catalog-api.js";
export * from "./types/index.js";
export { links } from "./links.js";
export * from "./jwt.js";
export {
formatDuration,
formatDurationInDays,
@@ -27,8 +28,6 @@ export {
nanosecondsToMilliseconds,
} from "./utils/durations.js";
export { TriggerTracer } from "./tracer.js";
export type { LogLevel } from "./logger/taskLogger.js";
export { eventFilterMatches } from "../eventFilterMatches.js";
@@ -60,6 +59,7 @@ export {
} from "./utils/ioSerialization.js";
export * from "./config.js";
export { getSchemaParseFn, type AnySchemaParseFn, type SchemaParseFn } from "./types/schemas.js";
import { VERSION } from "../version.js";
+40
View File
@@ -0,0 +1,40 @@
export type GenerateJWTOptions = {
secretKey: string;
payload: Record<string, any>;
expirationTime?: number | Date | string;
};
export const JWT_ALGORITHM = "HS256";
export const JWT_ISSUER = "https://id.trigger.dev";
export const JWT_AUDIENCE = "https://api.trigger.dev";
export async function generateJWT(options: GenerateJWTOptions): Promise<string> {
const { SignJWT } = await import("jose");
const secret = new TextEncoder().encode(options.secretKey);
return new SignJWT(options.payload)
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setProtectedHeader({ alg: JWT_ALGORITHM })
.setIssuedAt()
.setExpirationTime(options.expirationTime ?? "15m")
.sign(secret);
}
export async function validateJWT(token: string, apiKey: string) {
const { jwtVerify } = await import("jose");
const secret = new TextEncoder().encode(apiKey);
try {
const { payload } = await jwtVerify(token, secret, {
issuer: JWT_ISSUER,
audience: JWT_AUDIENCE,
});
return payload;
} catch (e) {
return;
}
}
+2 -23
View File
@@ -1,24 +1,3 @@
import { Span, SpanStatusCode } from "@opentelemetry/api";
export { TracingSDK, type TracingDiagnosticLogLevel, type TracingSDKConfig } from "./tracingSDK.js";
export { TracingSDK, type TracingSDKConfig, type TracingDiagnosticLogLevel } from "./tracingSDK.js";
export function recordSpanException(span: Span, error: unknown) {
if (error instanceof Error) {
span.recordException(sanitizeSpanError(error));
} else if (typeof error === "string") {
span.recordException(error.replace(/\0/g, ""));
} else {
span.recordException(JSON.stringify(error).replace(/\0/g, ""));
}
span.setStatus({ code: SpanStatusCode.ERROR });
}
function sanitizeSpanError(error: Error) {
// Create a new error object with the same name, message and stack trace
const sanitizedError = new Error(error.message.replace(/\0/g, ""));
sanitizedError.name = error.name.replace(/\0/g, "");
sanitizedError.stack = error.stack?.replace(/\0/g, "");
return sanitizedError;
}
export * from "./utils.js";
+22
View File
@@ -0,0 +1,22 @@
import { type Span, SpanStatusCode } from "@opentelemetry/api";
export function recordSpanException(span: Span, error: unknown) {
if (error instanceof Error) {
span.recordException(sanitizeSpanError(error));
} else if (typeof error === "string") {
span.recordException(error.replace(/\0/g, ""));
} else {
span.recordException(JSON.stringify(error).replace(/\0/g, ""));
}
span.setStatus({ code: SpanStatusCode.ERROR });
}
function sanitizeSpanError(error: Error) {
// Create a new error object with the same name, message and stack trace
const sanitizedError = new Error(error.message.replace(/\0/g, ""));
sanitizedError.name = error.name.replace(/\0/g, "");
sanitizedError.stack = error.stack?.replace(/\0/g, "");
return sanitizedError;
}
+85 -31
View File
@@ -1,13 +1,17 @@
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 { getGlobal, registerGlobal } from "../utils/globals.js";
import { ApiRequestOptions } from "../zodfetch.js";
import { JSONHeroPath } from "@jsonhero/path";
const API_NAME = "run-metadata";
export class RunMetadataAPI {
private static _instance?: RunMetadataAPI;
private flushTimeoutId: NodeJS.Timeout | null = null;
private hasChanges: boolean = false;
private constructor() {}
@@ -39,68 +43,118 @@ export class RunMetadataAPI {
return this.store?.[key];
}
public async setKey(
key: string,
value: DeserializedJson,
requestOptions?: ApiRequestOptions
): Promise<void> {
public setKey(key: string, value: DeserializedJson) {
const runId = taskContext.ctx?.run.id;
if (!runId) {
return;
}
const apiClient = apiClientManager.clientOrThrow();
let nextStore: Record<string, DeserializedJson> | undefined = this.store
? structuredClone(this.store)
: undefined;
const nextStore = {
...(this.store ?? {}),
[key]: value,
};
if (key.startsWith("$.")) {
const path = new JSONHeroPath(key);
path.set(nextStore, value);
} else {
nextStore = {
...(nextStore ?? {}),
[key]: value,
};
}
const response = await apiClient.updateRunMetadata(
runId,
{ metadata: nextStore },
requestOptions
);
if (!nextStore) {
return;
}
this.store = response.metadata;
if (!dequal(this.store, nextStore)) {
this.hasChanges = true;
}
this.store = nextStore;
}
public async deleteKey(key: string, requestOptions?: ApiRequestOptions): Promise<void> {
public deleteKey(key: string) {
const runId = taskContext.ctx?.run.id;
if (!runId) {
return;
}
const apiClient = apiClientManager.clientOrThrow();
const nextStore = { ...(this.store ?? {}) };
delete nextStore[key];
const response = await apiClient.updateRunMetadata(
runId,
{ metadata: nextStore },
requestOptions
);
if (!dequal(this.store, nextStore)) {
this.hasChanges = true;
}
this.store = response.metadata;
this.store = nextStore;
}
public async update(
metadata: Record<string, DeserializedJson>,
requestOptions?: ApiRequestOptions
): Promise<void> {
public update(metadata: Record<string, DeserializedJson>): void {
const runId = taskContext.ctx?.run.id;
if (!runId) {
return;
}
if (!dequal(this.store, metadata)) {
this.hasChanges = true;
}
this.store = metadata;
}
public async flush(requestOptions?: ApiRequestOptions): Promise<void> {
const runId = taskContext.ctx?.run.id;
if (!runId) {
return;
}
if (!this.store) {
return;
}
if (!this.hasChanges) {
return;
}
const apiClient = apiClientManager.clientOrThrow();
const response = await apiClient.updateRunMetadata(runId, { metadata }, requestOptions);
try {
this.hasChanges = false;
await apiClient.updateRunMetadata(runId, { metadata: this.store }, requestOptions);
} catch (error) {
this.hasChanges = true;
throw error;
}
}
this.store = response.metadata;
public startPeriodicFlush(intervalMs: number = 1000) {
const periodicFlush = async (intervalMs: number) => {
try {
await this.flush();
} catch (error) {
console.error("Failed to flush metadata", error);
throw error;
} finally {
scheduleNext();
}
};
const scheduleNext = () => {
this.flushTimeoutId = setTimeout(() => periodicFlush(intervalMs), intervalMs);
};
scheduleNext();
}
stopPeriodicFlush(): void {
if (this.flushTimeoutId) {
clearTimeout(this.flushTimeoutId);
this.flushTimeoutId = null;
}
}
}
+39 -6
View File
@@ -1,8 +1,8 @@
import { z } from "zod";
import { DeserializedJsonSchema } from "../../schemas/json.js";
import { SerializedError } from "./common.js";
import { BackgroundWorkerMetadata } from "./resources.js";
import { QueueOptions } from "./schemas.js";
import { SerializedError } from "./common.js";
import { DeserializedJsonSchema, SerializableJsonSchema } from "../../schemas/json.js";
export const WhoAmIResponseSchema = z.object({
userId: z.string(),
@@ -58,7 +58,7 @@ export const CreateBackgroundWorkerResponse = z.object({
export type CreateBackgroundWorkerResponse = z.infer<typeof CreateBackgroundWorkerResponse>;
//an array of 1, 2, or 3 strings
const RunTag = z.string().max(64, "Tags must be less than 64 characters");
const RunTag = z.string().max(128, "Tags must be less than 128 characters");
export const RunTags = z.union([RunTag, RunTag.array()]);
export type RunTags = z.infer<typeof RunTags>;
@@ -285,8 +285,8 @@ export const ScheduledTaskPayload = z.object({
type: ScheduleType,
/** When the task was scheduled to run.
* Note this will be slightly different from `new Date()` because it takes a few ms to run the task.
*
* This date is UTC. To output it as a string with a timezone you would do this:
*
* This date is UTC. To output it as a string with a timezone you would do this:
* ```ts
* const formatted = payload.timestamp.toLocaleString("en-US", {
timeZone: payload.timezone,
@@ -314,7 +314,7 @@ export const CreateScheduleOptions = z.object({
/** The id of the task you want to attach to. */
task: z.string(),
/** The schedule in CRON format.
*
*
* ```txt
* * * * * *
@@ -529,6 +529,7 @@ export const RetrieveRunResponse = z.object({
payloadPresignedUrl: z.string().optional(),
output: z.any().optional(),
outputPresignedUrl: z.string().optional(),
error: SerializedError.optional(),
schedule: RunScheduleDetails.optional(),
relatedRuns: z.object({
root: RelatedRunDetails.optional(),
@@ -548,6 +549,7 @@ export const RetrieveRunResponse = z.object({
})
.optional()
),
attemptCount: z.number().default(0),
});
export type RetrieveRunResponse = z.infer<typeof RetrieveRunResponse>;
@@ -628,3 +630,34 @@ export const UpdateMetadataResponseBody = z.object({
});
export type UpdateMetadataResponseBody = z.infer<typeof UpdateMetadataResponseBody>;
export const SubscribeRunRawShape = z.object({
id: z.string(),
idempotencyKey: z.string().nullish(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
startedAt: z.coerce.date().nullish(),
delayUntil: z.coerce.date().nullish(),
queuedAt: z.coerce.date().nullish(),
expiredAt: z.coerce.date().nullish(),
completedAt: z.coerce.date().nullish(),
taskIdentifier: z.string(),
friendlyId: z.string(),
number: z.number(),
isTest: z.boolean(),
status: z.string(),
usageDurationMs: z.number(),
costInCents: z.number(),
baseCostInCents: z.number(),
ttl: z.string().nullish(),
payload: z.string().nullish(),
payloadType: z.string().nullish(),
metadata: z.string().nullish(),
metadataType: z.string().nullish(),
output: z.string().nullish(),
outputType: z.string().nullish(),
runTags: z.array(z.string()).nullish().default([]),
error: SerializedError.nullish(),
});
export type SubscribeRunRawShape = z.infer<typeof SubscribeRunRawShape>;
+1 -1
View File
@@ -13,7 +13,7 @@ import { SemanticInternalAttributes } from "./semanticInternalAttributes.js";
import { clock } from "./clock-api.js";
import { usage } from "./usage-api.js";
import { taskContext } from "./task-context-api.js";
import { recordSpanException } from "./otel/index.js";
import { recordSpanException } from "./otel/utils.js";
export type TriggerTracerConfig =
| {
@@ -0,0 +1,5 @@
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
export type IdempotencyKey = Branded<string, "IdempotencyKey">;
+12 -89
View File
@@ -1,79 +1,10 @@
import { RetryOptions, TaskMetadata, TaskManifest, TaskRunContext } from "../schemas/index.js";
import { RetrieveRunResponse } from "../schemas/api.js";
import { AnyRunTypes, InferRunTypes } from "./tasks.js";
import { Prettify } from "./utils.js";
export * from "./utils.js";
export type InitOutput = Record<string, any> | void | undefined;
export type RunFnParams<TInitOutput extends InitOutput> = Prettify<{
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
ctx: Context;
/** If you use the `init` function, this will be whatever you returned. */
init?: TInitOutput;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type MiddlewareFnParams = Prettify<{
ctx: Context;
next: () => Promise<void>;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type InitFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type StartFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type Context = TaskRunContext;
export type SuccessFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput>;
export type FailureFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput>;
export type HandleErrorFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput> &
Prettify<{
retry?: RetryOptions;
retryAt?: Date;
retryDelayInMs?: number;
}>;
export type HandleErrorModificationOptions = {
skipRetrying?: boolean | undefined;
retryAt?: Date | undefined;
retryDelayInMs?: number | undefined;
retry?: RetryOptions | undefined;
error?: unknown;
};
export type HandleErrorResult =
| undefined
| void
| HandleErrorModificationOptions
| Promise<undefined | void | HandleErrorModificationOptions>;
export type HandleErrorArgs = {
ctx: Context;
retry?: RetryOptions;
retryAt?: Date;
retryDelayInMs?: number;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
};
export type HandleErrorFunction = (
payload: any,
error: unknown,
params: HandleErrorArgs
) => HandleErrorResult;
export * from "./tasks.js";
export * from "./idempotencyKeys.js";
type ResolveEnvironmentVariablesOptions = {
variables: Record<string, string> | Array<{ name: string; value: string }>;
@@ -96,19 +27,11 @@ export type ResolveEnvironmentVariablesFunction = (
params: ResolveEnvironmentVariablesParams
) => ResolveEnvironmentVariablesResult;
export type TaskMetadataWithFunctions = TaskMetadata & {
fns: {
run: (payload: any, params: RunFnParams<any>) => Promise<any>;
init?: (payload: any, params: InitFnParams) => Promise<InitOutput>;
cleanup?: (payload: any, params: RunFnParams<any>) => Promise<void>;
middleware?: (payload: any, params: MiddlewareFnParams) => Promise<void>;
handleError?: (
payload: any,
error: unknown,
params: HandleErrorFnParams<any>
) => HandleErrorResult;
onSuccess?: (payload: any, output: any, params: SuccessFnParams<any>) => Promise<void>;
onFailure?: (payload: any, error: unknown, params: FailureFnParams<any>) => Promise<void>;
onStart?: (payload: any, params: StartFnParams) => Promise<void>;
};
};
export type RetrieveRunResult<T> = Prettify<
Omit<RetrieveRunResponse, "output" | "payload"> & {
output?: InferRunTypes<T>["output"];
payload?: InferRunTypes<T>["payload"];
}
>;
export type AnyRetrieveRunResult = RetrieveRunResult<any>;
+124
View File
@@ -0,0 +1,124 @@
export type SchemaZodEsque<TInput, TParsedInput> = {
_input: TInput;
_output: TParsedInput;
};
export type SchemaValibotEsque<TInput, TParsedInput> = {
schema: {
_types?: {
input: TInput;
output: TParsedInput;
};
};
};
export type SchemaArkTypeEsque<TInput, TParsedInput> = {
inferIn: TInput;
infer: TParsedInput;
};
export type SchemaMyZodEsque<TInput> = {
parse: (input: any) => TInput;
};
export type SchemaSuperstructEsque<TInput> = {
create: (input: unknown) => TInput;
};
export type SchemaCustomValidatorEsque<TInput> = (input: unknown) => Promise<TInput> | TInput;
export type SchemaYupEsque<TInput> = {
validateSync: (input: unknown) => TInput;
};
export type SchemaScaleEsque<TInput> = {
assert(value: unknown): asserts value is TInput;
};
export type SchemaWithoutInput<TInput> =
| SchemaCustomValidatorEsque<TInput>
| SchemaMyZodEsque<TInput>
| SchemaScaleEsque<TInput>
| SchemaSuperstructEsque<TInput>
| SchemaYupEsque<TInput>;
export type SchemaWithInputOutput<TInput, TParsedInput> =
| SchemaZodEsque<TInput, TParsedInput>
| SchemaValibotEsque<TInput, TParsedInput>
| SchemaArkTypeEsque<TInput, TParsedInput>;
export type Schema = SchemaWithInputOutput<any, any> | SchemaWithoutInput<any>;
export type inferSchema<TSchema extends Schema> = TSchema extends SchemaWithInputOutput<
infer $TIn,
infer $TOut
>
? {
in: $TIn;
out: $TOut;
}
: TSchema extends SchemaWithoutInput<infer $InOut>
? {
in: $InOut;
out: $InOut;
}
: never;
export type inferSchemaIn<
TSchema extends Schema | undefined,
TDefault = unknown,
> = TSchema extends Schema ? inferSchema<TSchema>["in"] : TDefault;
export type inferSchemaOut<
TSchema extends Schema | undefined,
TDefault = unknown,
> = TSchema extends Schema ? inferSchema<TSchema>["out"] : TDefault;
export type SchemaParseFn<TType> = (value: unknown) => Promise<TType> | TType;
export type AnySchemaParseFn = SchemaParseFn<any>;
export function getSchemaParseFn<TType>(procedureParser: Schema): SchemaParseFn<TType> {
const parser = procedureParser as any;
if (typeof parser === "function" && typeof parser.assert === "function") {
// ParserArkTypeEsque - arktype schemas shouldn't be called as a function because they return a union type instead of throwing
return parser.assert.bind(parser);
}
if (typeof parser === "function") {
// ParserValibotEsque (>= v0.31.0)
// ParserCustomValidatorEsque
return parser;
}
if (typeof parser.parseAsync === "function") {
// ParserZodEsque
return parser.parseAsync.bind(parser);
}
if (typeof parser.parse === "function") {
// ParserZodEsque
// ParserValibotEsque (< v0.13.0)
return parser.parse.bind(parser);
}
if (typeof parser.validateSync === "function") {
// ParserYupEsque
return parser.validateSync.bind(parser);
}
if (typeof parser.create === "function") {
// ParserSuperstructEsque
return parser.create.bind(parser);
}
if (typeof parser.assert === "function") {
// ParserScaleEsque
return (value) => {
parser.assert(value);
return value as TType;
};
}
throw new Error("Could not find a validator fn");
}
+675
View File
@@ -0,0 +1,675 @@
import { SerializableJson } from "../../schemas/json.js";
import { RunTags } from "../schemas/api.js";
import { QueueOptions } from "../schemas/schemas.js";
import { IdempotencyKey } from "./idempotencyKeys.js";
import {
MachineCpu,
MachineMemory,
RetryOptions,
TaskMetadata,
TaskRunContext,
} from "../schemas/index.js";
import { Prettify } from "./utils.js";
import { AnySchemaParseFn, inferSchemaOut, Schema } from "./schemas.js";
import { TriggerApiRequestOptions } from "../apiClient/index.js";
type RequireOne<T, K extends keyof T> = {
[X in Exclude<keyof T, K>]?: T[X];
} & {
[P in K]-?: T[P];
};
export type Queue = RequireOne<QueueOptions, "name">;
export type TaskSchema = Schema;
export type { inferSchemaIn } from "./schemas.js";
type TaskRunConcurrencyOptions = Queue;
export class SubtaskUnwrapError extends Error {
public readonly taskId: string;
public readonly runId: string;
public readonly cause?: unknown;
constructor(taskId: string, runId: string, subtaskError: unknown) {
if (subtaskError instanceof Error) {
super(`Error in ${taskId}: ${subtaskError.message}`);
this.cause = subtaskError;
this.name = "SubtaskUnwrapError";
} else {
super(`Error in ${taskId}`);
this.name = "SubtaskUnwrapError";
this.cause = subtaskError;
}
this.taskId = taskId;
this.runId = runId;
}
}
export class TaskRunPromise<T> extends Promise<TaskRunResult<T>> {
constructor(
executor: (
resolve: (value: TaskRunResult<T> | PromiseLike<TaskRunResult<T>>) => void,
reject: (reason?: any) => void
) => void,
private readonly taskId: string
) {
super(executor);
}
unwrap(): Promise<T> {
return this.then((result) => {
if (result.ok) {
return result.output;
} else {
throw new SubtaskUnwrapError(this.taskId, result.id, result.error);
}
});
}
}
export type InitOutput = Record<string, any> | void | undefined;
export type RunFnParams<TInitOutput extends InitOutput> = Prettify<{
/** Metadata about the task, run, attempt, queue, environment, organization, project and batch. */
ctx: Context;
/** If you use the `init` function, this will be whatever you returned. */
init?: TInitOutput;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type MiddlewareFnParams = Prettify<{
ctx: Context;
next: () => Promise<void>;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type InitFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type StartFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
}>;
export type Context = TaskRunContext;
export type SuccessFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput>;
export type FailureFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput>;
export type HandleErrorFnParams<TInitOutput extends InitOutput> = RunFnParams<TInitOutput> &
Prettify<{
retry?: RetryOptions;
retryAt?: Date;
retryDelayInMs?: number;
}>;
export type HandleErrorModificationOptions = {
skipRetrying?: boolean | undefined;
retryAt?: Date | undefined;
retryDelayInMs?: number | undefined;
retry?: RetryOptions | undefined;
error?: unknown;
};
export type HandleErrorResult =
| undefined
| void
| HandleErrorModificationOptions
| Promise<undefined | void | HandleErrorModificationOptions>;
export type HandleErrorArgs = {
ctx: Context;
retry?: RetryOptions;
retryAt?: Date;
retryDelayInMs?: number;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
};
export type HandleErrorFunction = (
payload: any,
error: unknown,
params: HandleErrorArgs
) => HandleErrorResult;
type CommonTaskOptions<
TIdentifier extends string,
TPayload = void,
TOutput = unknown,
TInitOutput extends InitOutput = any,
> = {
/** An id for your task. This must be unique inside your project and not change between versions. */
id: TIdentifier;
/** The retry settings when an uncaught error is thrown.
*
* If omitted it will use the values in your `trigger.config.ts` file.
*
* @example
*
* ```
* export const taskWithRetries = task({
id: "task-with-retries",
retry: {
maxAttempts: 10,
factor: 1.8,
minTimeoutInMs: 500,
maxTimeoutInMs: 30_000,
randomize: false,
},
run: async ({ payload, ctx }) => {
//...
},
});
* ```
* */
retry?: RetryOptions;
/** Used to configure what should happen when more than one run is triggered at the same time.
*
* @example
* one at a time execution
*
* ```ts
* export const oneAtATime = task({
id: "one-at-a-time",
queue: {
concurrencyLimit: 1,
},
run: async ({ payload, ctx }) => {
//...
},
});
* ```
*/
queue?: QueueOptions;
/** Configure the spec of the machine you want your task to run on.
*
* @example
*
* ```ts
* export const heavyTask = task({
id: "heavy-task",
machine: {
cpu: 2,
memory: 4,
},
run: async ({ payload, ctx }) => {
//...
},
});
* ```
*/
machine?: {
/** vCPUs. The default is 0.5.
*
* Possible values:
* - 0.25
* - 0.5
* - 1
* - 2
* - 4
* @deprecated use preset instead
*/
cpu?: MachineCpu;
/** In GBs of RAM. The default is 1.
*
* Possible values:
* - 0.25
* - 0.5
* - 1
* - 2
* - 4
* - 8
* * @deprecated use preset instead
*/
memory?: MachineMemory;
/** Preset to use for the machine. Defaults to small-1x */
preset?:
| "micro"
| "small-1x"
| "small-2x"
| "medium-1x"
| "medium-2x"
| "large-1x"
| "large-2x";
};
/**
* The maximum duration in compute-time seconds that a task run is allowed to run. If the task run exceeds this duration, it will be stopped.
*
* Minimum value is 5 seconds
*/
maxDuration?: number;
/** This gets called when a task is triggered. It's where you put the code you want to execute.
*
* @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable.
* @param params - Metadata about the run.
*/
run: (payload: TPayload, params: RunFnParams<TInitOutput>) => Promise<TOutput>;
/**
* init is called before the run function is called. It's useful for setting up any global state.
*/
init?: (payload: TPayload, params: InitFnParams) => Promise<TInitOutput>;
/**
* cleanup is called after the run function has completed.
*/
cleanup?: (payload: TPayload, params: RunFnParams<TInitOutput>) => Promise<void>;
/**
* handleError is called when the run function throws an error. It can be used to modify the error or return new retry options.
*/
handleError?: (
payload: TPayload,
error: unknown,
params: HandleErrorFnParams<TInitOutput>
) => HandleErrorResult;
/**
* middleware allows you to run code "around" the run function. This can be useful for logging, metrics, or other cross-cutting concerns.
*
* When writing middleware, you should always call `next()` to continue the execution of the task:
*
* ```ts
* export const middlewareTask = task({
* id: "middleware-task",
* middleware: async (payload, { ctx, next }) => {
* console.log("Before run");
* await next();
* console.log("After run");
* },
* run: async (payload, { ctx }) => {}
* });
* ```
*/
middleware?: (payload: TPayload, params: MiddlewareFnParams) => Promise<void>;
/**
* onStart is called the first time a task is executed in a run (not before every retry)
*/
onStart?: (payload: TPayload, params: StartFnParams) => Promise<void>;
/**
* onSuccess is called after the run function has successfully completed.
*/
onSuccess?: (
payload: TPayload,
output: TOutput,
params: SuccessFnParams<TInitOutput>
) => Promise<void>;
/**
* onFailure is called after a task run has failed (meaning the run function threw an error and won't be retried anymore)
*/
onFailure?: (
payload: TPayload,
error: unknown,
params: FailureFnParams<TInitOutput>
) => Promise<void>;
};
export type TaskOptions<
TIdentifier extends string,
TPayload = void,
TOutput = unknown,
TInitOutput extends InitOutput = any,
> = CommonTaskOptions<TIdentifier, TPayload, TOutput, TInitOutput>;
export type TaskWithSchemaOptions<
TIdentifier extends string,
TSchema extends TaskSchema | undefined = undefined,
TOutput = unknown,
TInitOutput extends InitOutput = any,
> = CommonTaskOptions<TIdentifier, inferSchemaOut<TSchema>, TOutput, TInitOutput> & {
schema?: TSchema;
};
declare const __output: unique symbol;
declare const __payload: unique symbol;
type BrandRun<P, O> = { [__output]: O; [__payload]: P };
export type BrandedRun<T, P, O> = T & BrandRun<O, P>;
export type RunHandle<TTaskIdentifier extends string, TPayload, TOutput> = BrandedRun<
{
id: string;
/**
* An auto-generated JWT that can be used to access the run
*/
publicAccessToken: string;
taskIdentifier: TTaskIdentifier;
},
TPayload,
TOutput
>;
export type AnyRunHandle = RunHandle<string, any, any>;
/**
* A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner.
*/
export type BatchRunHandle<TTaskIdentifier extends string, TPayload, TOutput> = BrandedRun<
{
batchId: string;
runs: Array<RunHandle<TTaskIdentifier, TPayload, TOutput>>;
publicAccessToken: string;
taskIdentifier: TTaskIdentifier;
},
TOutput,
TPayload
>;
export type RunHandleOutput<TRunHandle> = TRunHandle extends RunHandle<string, any, infer TOutput>
? TOutput
: never;
export type RunHandlePayload<TRunHandle> = TRunHandle extends RunHandle<string, infer TPayload, any>
? TPayload
: never;
export type RunHandleTaskIdentifier<TRunHandle> = TRunHandle extends RunHandle<
infer TTaskIdentifier,
any,
any
>
? TTaskIdentifier
: never;
export type TaskRunResult<TOutput = any> =
| {
ok: true;
id: string;
output: TOutput;
}
| {
ok: false;
id: string;
error: unknown;
};
export type BatchResult<TOutput = any> = {
id: string;
runs: TaskRunResult<TOutput>[];
};
export type BatchItem<TInput> = TInput extends void
? { payload?: TInput; options?: TaskRunOptions }
: { payload: TInput; options?: TaskRunOptions };
export interface Task<TIdentifier extends string, TInput = void, TOutput = any> {
/**
* The id of the task.
*/
id: TIdentifier;
/**
* 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
* @param options
* @returns RunHandle
* - `id` - The id of the triggered task run.
*/
trigger: (
payload: TInput,
options?: TaskRunOptions,
requestOptions?: TriggerApiRequestOptions
) => Promise<RunHandle<TIdentifier, TInput, TOutput>>;
/**
* Batch trigger multiple task runs with the given payloads, and continue without waiting for the results. If you want to wait for the results, use `batchTriggerAndWait`. Returns the id of the triggered batch.
* @param items
* @returns InvokeBatchHandle
* - `batchId` - The id of the triggered batch.
* - `runs` - The ids of the triggered task runs.
*/
batchTrigger: (
items: Array<BatchItem<TInput>>,
requestOptions?: TriggerApiRequestOptions
) => Promise<BatchRunHandle<TIdentifier, TInput, TOutput>>;
/**
* Trigger a task with the given payload, and wait for the result. Returns the result of the task run
* @param payload
* @param options - Options for the task run
* @returns TaskRunResult
* @example
* ```
* const result = await task.triggerAndWait({ foo: "bar" });
*
* if (result.ok) {
* console.log(result.output);
* } else {
* console.error(result.error);
* }
* ```
*/
triggerAndWait: (payload: TInput, options?: TaskRunOptions) => TaskRunPromise<TOutput>;
/**
* Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs.
* @param items
* @returns BatchResult
* @example
* ```
* const result = await task.batchTriggerAndWait([
* { payload: { foo: "bar" } },
* { payload: { foo: "baz" } },
* ]);
*
* for (const run of result.runs) {
* if (run.ok) {
* console.log(run.output);
* } else {
* console.error(run.error);
* }
* }
* ```
*/
batchTriggerAndWait: (items: Array<BatchItem<TInput>>) => Promise<BatchResult<TOutput>>;
}
export type AnyTask = Task<string, any, any>;
export type TaskPayload<TTask extends AnyTask> = TTask extends Task<string, infer TInput, any>
? TInput
: never;
export type TaskOutput<TTask extends AnyTask> = TTask extends Task<string, any, infer TOutput>
? TOutput
: never;
export type TaskOutputHandle<TTask extends AnyTask> = TTask extends Task<
infer TIdentifier,
infer TInput,
infer TOutput
>
? RunHandle<TIdentifier, TOutput, TInput>
: never;
export type TaskBatchOutputHandle<TTask extends AnyTask> = TTask extends Task<
infer TIdentifier,
infer TInput,
infer TOutput
>
? BatchRunHandle<TIdentifier, TOutput, TInput>
: never;
export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<infer TIdentifier, any, any>
? TIdentifier
: never;
export type TriggerJwtOptions = {
/**
* The expiration time of the JWT. This can be a string like "1h" or a Date object.
*
* Defaults to 1 hour.
*/
expirationTime?: number | Date | string;
};
export type TaskRunOptions = {
/**
* A unique key that can be used to ensure that a task is only triggered once per key.
*
* You can use `idempotencyKeys.create` to create an idempotency key first, and then pass it to the task options.
*
* @example
*
* ```typescript
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
*
* export const myTask = task({
* id: "my-task",
* run: async (payload: any) => {
* // scoped to the task run by default
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
*
* // Use the idempotency key when triggering child tasks
* await childTask.triggerAndWait(payload, { idempotencyKey });
*
* // scoped globally, does not include the task run ID
* const globalIdempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });
*
* await childTask.triggerAndWait(payload, { idempotencyKey: globalIdempotencyKey });
*
* // You can also pass a string directly, which is the same as a global idempotency key
* await childTask.triggerAndWait(payload, { idempotencyKey: "my-very-unique-key" });
* }
* });
* ```
*
* When triggering a task inside another task, we automatically inject the run ID into the key material.
*
* If you are triggering a task from your backend, ensure you include some sufficiently unique key material to prevent collisions.
*
* @example
*
* ```typescript
* import { idempotencyKeys, tasks } from "@trigger.dev/sdk/v3";
*
* // Somewhere in your backend
* const idempotencyKey = await idempotenceKeys.create(["my-task-trigger", "user-123"]);
* await tasks.trigger("my-task", { foo: "bar" }, { idempotencyKey });
* ```
*
*/
idempotencyKey?: IdempotencyKey | string | string[];
maxAttempts?: number;
queue?: TaskRunConcurrencyOptions;
concurrencyKey?: string;
/**
* The delay before the task is executed. This can be a string like "1h" or a Date object.
*
* @example
* "1h" - 1 hour
* "30d" - 30 days
* "15m" - 15 minutes
* "2w" - 2 weeks
* "60s" - 60 seconds
* new Date("2025-01-01T00:00:00Z")
*/
delay?: string | Date;
/**
* Set a time-to-live for this run. If the run is not executed within this time, it will be removed from the queue and never execute.
*
* @example
*
* ```ts
* await myTask.trigger({ foo: "bar" }, { ttl: "1h" });
* await myTask.trigger({ foo: "bar" }, { ttl: 60 * 60 }); // 1 hour
* ```
*
* The minimum value is 1 second. Setting the `ttl` to `0` will disable the TTL and the run will never expire.
*
* **Note:** Runs in development have a default `ttl` of 10 minutes. You can override this by setting the `ttl` option.
*/
ttl?: string | number;
/**
* Tags to attach to the run. Tags can be used to filter runs in the dashboard and using the SDK.
*
* You can set up to 10 tags per run, they must be less than 128 characters each.
*
* We recommend prefixing tags with a namespace using an underscore or colon, like `user_1234567` or `org:9876543`.
*
* @example
*
* ```ts
* await myTask.trigger({ foo: "bar" }, { tags: ["user:1234567", "org:9876543"] });
* ```
*/
tags?: RunTags;
/**
* Metadata to attach to the run. Metadata can be used to store additional information about the run. Limited to 4KB.
*/
metadata?: Record<string, SerializableJson>;
/**
* The maximum duration in compute-time seconds that a task run is allowed to run. If the task run exceeds this duration, it will be stopped.
*
* This will override the task's maxDuration.
*
* Minimum value is 5 seconds
*/
maxDuration?: number;
};
export type TaskMetadataWithFunctions = TaskMetadata & {
fns: {
run: (payload: any, params: RunFnParams<any>) => Promise<any>;
init?: (payload: any, params: InitFnParams) => Promise<InitOutput>;
cleanup?: (payload: any, params: RunFnParams<any>) => Promise<void>;
middleware?: (payload: any, params: MiddlewareFnParams) => Promise<void>;
handleError?: (
payload: any,
error: unknown,
params: HandleErrorFnParams<any>
) => HandleErrorResult;
onSuccess?: (payload: any, output: any, params: SuccessFnParams<any>) => Promise<void>;
onFailure?: (payload: any, error: unknown, params: FailureFnParams<any>) => Promise<void>;
onStart?: (payload: any, params: StartFnParams) => Promise<void>;
parsePayload?: AnySchemaParseFn;
};
};
export type RunTypes<TTaskIdentifier extends string, TPayload, TOutput> = {
output: TOutput;
payload: TPayload;
taskIdentifier: TTaskIdentifier;
};
export type AnyRunTypes = RunTypes<string, any, any>;
export type InferRunTypes<T> = T extends RunHandle<
infer TTaskIdentifier,
infer TPayload,
infer TOutput
>
? RunTypes<TTaskIdentifier, TPayload, TOutput>
: T extends Task<infer TTaskIdentifier, infer TPayload, infer TOutput>
? RunTypes<TTaskIdentifier, TPayload, TOutput>
: AnyRunTypes;
export type RunHandleFromTypes<TRunTypes extends AnyRunTypes> = RunHandle<
TRunTypes["taskIdentifier"],
TRunTypes["payload"],
TRunTypes["output"]
>;
export type BatchRunHandleFromTypes<TRunTypes extends AnyRunTypes> = BatchRunHandle<
TRunTypes["taskIdentifier"],
TRunTypes["payload"],
TRunTypes["output"]
>;
+16
View File
@@ -6,3 +6,19 @@ export function getEnvVar(name: string): string | undefined {
return;
}
export function getNumberEnvVar(name: string, defaultValue?: number): number | undefined {
const value = getEnvVar(name);
if (value === undefined) {
return defaultValue;
}
const parsed = Number(value);
if (isNaN(parsed)) {
return defaultValue;
}
return parsed;
}
@@ -33,6 +33,12 @@ export async function parsePacket(value: IOPacket): Promise<any> {
}
}
export async function conditionallyImportAndParsePacket(value: IOPacket): Promise<any> {
const importedPacket = await conditionallyImportPacket(value);
return await parsePacket(importedPacket);
}
export async function stringifyIO(value: any): Promise<IOPacket> {
if (value === undefined) {
return { dataType: "application/json" };
@@ -7,6 +7,10 @@ export class SafeAsyncLocalStorage<T> {
this.storage = new AsyncLocalStorage<T>();
}
enterWith(context: T): void {
this.storage.enterWith(context);
}
runWith<R extends (...args: any[]) => Promise<any>>(context: T, fn: R): Promise<ReturnType<R>> {
return this.storage.run(context, fn);
}
+1 -1
View File
@@ -1,7 +1,7 @@
export { TaskExecutor, type TaskExecutorOptions } from "./taskExecutor.js";
export type { RuntimeManager } from "../runtime/manager.js";
export { PreciseWallClock as DurableClock } from "../clock/preciseWallClock.js";
export { getEnvVar } from "../utils/getEnv.js";
export { getEnvVar, getNumberEnvVar } from "../utils/getEnv.js";
export { OtelTaskLogger, logLevels } from "../logger/taskLogger.js";
export { ConsoleInterceptor } from "../consoleInterceptor.js";
export { TracingSDK, type TracingDiagnosticLogLevel, recordSpanException } from "../otel/index.js";
+19 -2
View File
@@ -2,7 +2,7 @@ import { SpanKind } from "@opentelemetry/api";
import { VERSION } from "../../version.js";
import { ApiError, RateLimitError } from "../apiClient/errors.js";
import { ConsoleInterceptor } from "../consoleInterceptor.js";
import { parseError, sanitizeError } from "../errors.js";
import { parseError, sanitizeError, TaskPayloadParsedError } from "../errors.js";
import { runMetadata, TriggerConfig } from "../index.js";
import { recordSpanException, TracingSDK } from "../otel/index.js";
import {
@@ -95,6 +95,8 @@ export class TaskExecutor {
parsedPayload = await parsePacket(payloadPacket);
parsedPayload = await this.#parsePayload(parsedPayload);
if (execution.attempt.number === 1) {
await this.#callOnStartFunctions(parsedPayload, ctx, signal);
}
@@ -394,6 +396,18 @@ export class TaskExecutor {
}
}
async #parsePayload(payload: unknown) {
if (!this.task.fns.parsePayload) {
return payload;
}
try {
return await this.task.fns.parsePayload(payload);
} catch (e) {
throw new TaskPayloadParsedError(e);
}
}
async #callOnStartFunctions(payload: unknown, ctx: TaskRunContext, signal?: AbortSignal) {
await this.#callOnStartFunction(
this._importedConfig?.onStart,
@@ -479,7 +493,10 @@ export class TaskExecutor {
return { status: "noop" };
}
if (error instanceof Error && error.name === "AbortTaskRunError") {
if (
error instanceof Error &&
(error.name === "AbortTaskRunError" || error.name === "TaskPayloadParsedError")
) {
return { status: "skipped" };
}
+1
View File
@@ -0,0 +1 @@
## trigger.dev react hooks
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@trigger.dev/react-hooks",
"version": "3.0.11",
"description": "trigger.dev react hooks",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/triggerdotdev/trigger.dev",
"directory": "packages/react-hooks"
},
"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.0.12",
"swr": "^2.2.5"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.15.4",
"@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"
},
"peerDependencies": {
"react": ">=18 || >=19.0.0-beta",
"react-dom": ">=18 || >=19.0.0-beta"
},
"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"
}
+10
View File
@@ -0,0 +1,10 @@
"use client";
import React from "react";
import { createContextAndHook } from "./utils/createContextAndHook.js";
import type { ApiClientConfiguration } from "@trigger.dev/core/v3";
const [TriggerAuthContext, useTriggerAuthContext] =
createContextAndHook<ApiClientConfiguration>("TriggerAuthContext");
export { TriggerAuthContext, useTriggerAuthContext };
@@ -0,0 +1,14 @@
"use client";
import { ApiClient } from "@trigger.dev/core/v3";
import { useTriggerAuthContext } from "../contexts.js";
export function useApiClient() {
const auth = useTriggerAuthContext();
if (!auth.baseURL || !auth.accessToken) {
throw new Error("Missing baseURL or accessToken in TriggerAuthContext");
}
return new ApiClient(auth.baseURL, auth.accessToken, auth.requestOptions);
}
@@ -0,0 +1,52 @@
"use client";
import { AnyTask, InferRunTypes, TaskRunShape } from "@trigger.dev/core/v3";
import { useEffect, useState } from "react";
import { useApiClient } from "./useApiClient.js";
export function useBatch<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)];
}
@@ -0,0 +1,46 @@
"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 };
}
@@ -0,0 +1,58 @@
"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)];
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { AnyTask, RetrieveRunResult } from "@trigger.dev/core/v3";
import { CommonTriggerHookOptions, useSWR } from "../utils/trigger-swr.js";
import { useApiClient } from "./useApiClient.js";
/**
* Custom hook to retrieve and manage the state of a run by its ID.
*
* @template TTask - The type of the task associated with the run.
* @param {string} runId - The unique identifier of the run to retrieve.
* @param {CommonTriggerHookOptions} [options] - Optional configuration for the hook's behavior.
* @returns {Object} An object containing the run data, error, loading state, validation state, and error state.
* @returns {RetrieveRunResult<TTask> | undefined} run - The retrieved run data.
* @returns {Error | undefined} error - The error object if an error occurred.
* @returns {boolean} isLoading - Indicates if the run data is currently being loaded.
* @returns {boolean} isValidating - Indicates if the run data is currently being validated.
* @returns {boolean} isError - Indicates if an error occurred during the retrieval of the run data.
*/
export function useRun<TTask extends AnyTask>(
runId: string,
options?: CommonTriggerHookOptions
): {
run: RetrieveRunResult<TTask> | undefined;
error: Error | undefined;
isLoading: boolean;
isValidating: boolean;
isError: boolean;
} {
const apiClient = useApiClient();
const {
data: run,
error,
isLoading,
isValidating,
} = useSWR<RetrieveRunResult<TTask>>(runId, () => apiClient.retrieveRun(runId), {
revalidateOnReconnect: options?.revalidateOnReconnect,
refreshInterval: (run) => {
if (!run) return options?.refreshInterval ?? 0;
if (run.isCompleted) return 0;
return options?.refreshInterval ?? 0;
},
revalidateOnFocus: options?.revalidateOnFocus,
});
return { run, error, isLoading, isValidating, isError: !!error };
}
+6
View File
@@ -0,0 +1,6 @@
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";
@@ -0,0 +1,45 @@
"use client";
import React from "react";
export function assertContextExists(
contextVal: unknown,
msgOrCtx: string | React.Context<any>
): asserts contextVal {
if (!contextVal) {
throw typeof msgOrCtx === "string"
? new Error(msgOrCtx)
: new Error(`${msgOrCtx.displayName} not found`);
}
}
type Options = { assertCtxFn?: (v: unknown, msg: string) => void };
type ContextOf<T> = React.Context<T | undefined>;
type UseCtxFn<T> = () => T;
/**
* Creates and returns a Context and two hooks that return the context value.
* The Context type is derived from the type passed in by the user.
* The first hook returned guarantees that the context exists so the returned value is always CtxValue
* The second hook makes no guarantees, so the returned value can be CtxValue | undefined
*/
export const createContextAndHook = <CtxVal>(
displayName: string,
options?: Options
): [ContextOf<CtxVal>, UseCtxFn<CtxVal>, UseCtxFn<CtxVal | Partial<CtxVal>>] => {
const { assertCtxFn = assertContextExists } = options || {};
const Ctx = React.createContext<CtxVal | undefined>(undefined);
Ctx.displayName = displayName;
const useCtx = () => {
const ctx = React.useContext(Ctx);
assertCtxFn(ctx, `${displayName} not found`);
return ctx as CtxVal;
};
const useCtxWithoutGuarantee = () => {
const ctx = React.useContext(Ctx);
return ctx ? ctx : {};
};
return [Ctx, useCtx, useCtxWithoutGuarantee];
};
@@ -0,0 +1,11 @@
"use client";
// eslint-disable-next-line import/export
export * from "swr";
// eslint-disable-next-line import/export
export { default as useSWR, SWRConfig } from "swr";
export type CommonTriggerHookOptions = {
refreshInterval?: number;
revalidateOnReconnect?: boolean;
revalidateOnFocus?: boolean;
};
+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"]
}
+11
View File
@@ -27,6 +27,9 @@
},
"sourceDialects": [
"@triggerdotdev/source"
],
"esmDialects": [
"browser"
]
},
"typesVersions": {
@@ -80,6 +83,10 @@
"exports": {
"./package.json": "./package.json",
".": {
"browser": {
"types": "./dist/browser/index.d.ts",
"default": "./dist/browser/index.js"
},
"import": {
"@triggerdotdev/source": "./src/index.ts",
"types": "./dist/esm/index.d.ts",
@@ -91,6 +98,10 @@
}
},
"./v3": {
"browser": {
"types": "./dist/browser/v3/index.d.ts",
"default": "./dist/browser/v3/index.js"
},
"import": {
"@triggerdotdev/source": "./src/v3/index.ts",
"types": "./dist/esm/v3/index.d.ts",
+160
View File
@@ -0,0 +1,160 @@
import { type ApiClientConfiguration, apiClientManager } from "@trigger.dev/core/v3";
import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3";
/**
* Register the global API client configuration. Alternatively, you can set the `TRIGGER_SECRET_KEY` and `TRIGGER_API_URL` environment variables.
* @param options The API client configuration.
* @param options.baseURL The base URL of the Trigger API. (default: `https://api.trigger.dev`)
* @param options.accessToken The accessToken to authenticate with the Trigger API. (default: `process.env.TRIGGER_SECRET_KEY`) This can be found in your Trigger.dev project "API Keys" settings.
*
* @example
*
* ```typescript
* import { configure } from "@trigger.dev/sdk/v3";
*
* configure({
* baseURL: "https://api.trigger.dev",
* accessToken: "tr_dev_1234567890"
* });
* ```
*/
export function configure(options: ApiClientConfiguration) {
apiClientManager.setGlobalAPIClientConfiguration(options);
}
export const auth = {
configure,
createPublicToken,
withAuth,
};
type PublicTokenPermissionAction = "read"; // Add more actions as needed
type PublicTokenPermissionProperties = {
/**
* Grant access to specific tasks
*/
tasks?: string | string[];
/**
* Grant access to specific run tags
*/
tags?: string | string[];
/**
* Grant access to specific runs
*/
runs?: string | string[] | true;
/**
* Grant access to specific batch runs
*/
batch?: string | string[];
};
export type PublicTokenPermissions = {
[key in PublicTokenPermissionAction]?: PublicTokenPermissionProperties;
};
export type CreatePublicTokenOptions = {
/**
* A collection of permission scopes to be granted to the token.
*
* @example
*
* ```typescript
* scopes: {
* read: {
* tags: ["file:1234"]
* }
* }
* ```
*/
scopes?: PublicTokenPermissions;
/**
* The expiration time for the token. This can be a number representing the time in milliseconds, a `Date` object, or a string.
*
* @example
*
* ```typescript
* expirationTime: "1h"
* ```
*/
expirationTime?: number | Date | string;
};
/**
* Creates a public token using the provided options.
*
* @param options - Optional parameters for creating the public token.
* @param options.scopes - An array of permission scopes to be included in the token.
* @param options.expirationTime - The expiration time for the token.
* @returns A promise that resolves to a string representing the generated public token.
*
* @example
*
* ```typescript
* import { auth } from "@trigger.dev/sdk/v3";
*
* const publicToken = await auth.createPublicToken({
* scopes: {
* read: {
* tags: ["file:1234"]
* }
* });
* ```
*/
async function createPublicToken(options?: CreatePublicTokenOptions): Promise<string> {
const apiClient = apiClientManager.clientOrThrow();
const claims = await apiClient.generateJWTClaims();
return await internal_generateJWT({
secretKey: apiClient.accessToken,
payload: {
...claims,
scopes: options?.scopes ? flattenScopes(options.scopes) : undefined,
},
expirationTime: options?.expirationTime,
});
}
/**
* Executes a provided asynchronous function with a specified API client configuration.
*
* @template R - The type of the asynchronous function to be executed.
* @param {ApiClientConfiguration} config - The configuration for the API client.
* @param {R} fn - The asynchronous function to be executed.
* @returns {Promise<ReturnType<R>>} A promise that resolves to the return type of the provided function.
*/
async function withAuth<R extends (...args: any[]) => Promise<any>>(
config: ApiClientConfiguration,
fn: R
): Promise<ReturnType<R>> {
return apiClientManager.runWithConfig(config, fn);
}
function flattenScopes(permissions: PublicTokenPermissions): string[] {
const flattenedPermissions: string[] = [];
for (const [action, properties] of Object.entries(permissions)) {
if (properties) {
if (typeof properties === "boolean" && properties) {
flattenedPermissions.push(action);
} else if (typeof properties === "object") {
for (const [property, value] of Object.entries(properties)) {
if (Array.isArray(value)) {
for (const item of value) {
flattenedPermissions.push(`${action}:${property}:${item}`);
}
} else if (typeof value === "string") {
flattenedPermissions.push(`${action}:${property}:${value}`);
}
}
}
}
}
return flattenedPermissions;
}
@@ -1,14 +1,10 @@
import { taskContext } from "@trigger.dev/core/v3";
import { type IdempotencyKey, taskContext } from "@trigger.dev/core/v3";
export const idempotencyKeys = {
create: createIdempotencyKey,
};
declare const __brand: unique symbol;
type Brand<B> = { [__brand]: B };
type Branded<T, B> = T & Brand<B>;
export type IdempotencyKey = Branded<string, "IdempotencyKey">;
export type { IdempotencyKey };
export function isIdempotencyKey(
value: string | string[] | IdempotencyKey
@@ -0,0 +1,2 @@
export { runs, type RunShape, type AnyRunShape } from "./runs.js";
export { configure, auth } from "./auth.js";
+9 -22
View File
@@ -14,7 +14,6 @@ export type { Context };
import type { Context } from "./shared.js";
import type { ApiClientConfiguration } from "@trigger.dev/core/v3";
import { apiClientManager } from "@trigger.dev/core/v3";
export type { ApiClientConfiguration };
@@ -33,28 +32,16 @@ export {
type LogLevel,
} from "@trigger.dev/core/v3";
export { runs } from "./runs.js";
export {
runs,
type RunShape,
type AnyRunShape,
type TaskRunShape,
type RetrieveRunResult,
type AnyRetrieveRunResult,
} from "./runs.js";
export * as schedules from "./schedules/index.js";
export * as envvars from "./envvars.js";
export type { ImportEnvironmentVariablesParams } from "./envvars.js";
/**
* Register the global API client configuration. Alternatively, you can set the `TRIGGER_SECRET_KEY` and `TRIGGER_API_URL` environment variables.
* @param options The API client configuration.
* @param options.baseURL The base URL of the Trigger API. (default: `https://api.trigger.dev`)
* @param options.secretKey The secret key to authenticate with the Trigger API. (default: `process.env.TRIGGER_SECRET_KEY`) This can be found in your Trigger.dev project "API Keys" settings.
*
* @example
*
* ```typescript
* import { configure } from "@trigger.dev/sdk/v3";
*
* configure({
* baseURL: "https://api.trigger.dev",
* secretKey: "tr_dev_1234567890"
* });
* ```
*/
export function configure(options: ApiClientConfiguration) {
apiClientManager.setGlobalAPIClientConfiguration(options);
}
export { configure, auth } from "./auth.js";
+30 -69
View File
@@ -1,6 +1,5 @@
import { DeserializedJson } from "@trigger.dev/core";
import {
accessoryAttributes,
ApiRequestOptions,
flattenAttributes,
mergeRequestOptions,
@@ -24,6 +23,8 @@ export const metadata = {
set: setMetadataKey,
del: deleteMetadataKey,
save: saveMetadata,
replace: replaceMetadata,
flush: flushMetadata,
};
export type RunMetadata = Record<string, DeserializedJson>;
@@ -63,74 +64,24 @@ function getMetadataKey(key: string): DeserializedJson | undefined {
*
* @param {string} key - The key to set in the metadata.
* @param {DeserializedJson} value - The value to associate with the key.
* @param {ApiRequestOptions} [requestOptions] - Optional API request options.
* @returns {Promise<void>} A promise that resolves when the metadata is updated.
*
* @example
* await metadata.set("progress", 0.5);
* metadata.set("progress", 0.5);
*/
async function setMetadataKey(
key: string,
value: DeserializedJson,
requestOptions?: ApiRequestOptions
): Promise<void> {
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "metadata.set()",
icon: "code-plus",
attributes: {
...accessoryAttributes({
items: [
{
text: key,
variant: "normal",
},
],
style: "codepath",
}),
...flattenAttributes(value, key),
},
},
requestOptions
);
await runMetadata.setKey(key, value, $requestOptions);
function setMetadataKey(key: string, value: DeserializedJson) {
runMetadata.setKey(key, value);
}
/**
* Delete a key from the metadata of the current run if inside a task run.
*
* @param {string} key - The key to delete from the metadata.
* @param {ApiRequestOptions} [requestOptions] - Optional API request options.
* @returns {Promise<void>} A promise that resolves when the key is deleted from the metadata.
*
* @example
* await metadata.del("progress");
* metadata.del("progress");
*/
async function deleteMetadataKey(key: string, requestOptions?: ApiRequestOptions): Promise<void> {
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "metadata.del()",
icon: "code-minus",
attributes: {
...accessoryAttributes({
items: [
{
text: key,
variant: "normal",
},
],
style: "codepath",
}),
key,
},
},
requestOptions
);
await runMetadata.deleteKey(key, $requestOptions);
function deleteMetadataKey(key: string) {
runMetadata.deleteKey(key);
}
/**
@@ -138,27 +89,37 @@ async function deleteMetadataKey(key: string, requestOptions?: ApiRequestOptions
* This function allows you to replace the entire metadata object with a new one.
*
* @param {RunMetadata} metadata - The new metadata object to set for the run.
* @param {ApiRequestOptions} [requestOptions] - Optional API request options.
* @returns {Promise<void>} A promise that resolves when the metadata is updated.
* @returns {void}
*
* @example
* await metadata.save({ progress: 0.6, user: { name: "Alice", id: "user_5678" } });
* metadata.replace({ progress: 0.6, user: { name: "Alice", id: "user_5678" } });
*/
async function saveMetadata(
metadata: RunMetadata,
requestOptions?: ApiRequestOptions
): Promise<void> {
function replaceMetadata(metadata: RunMetadata): void {
runMetadata.update(metadata);
}
/**
* @deprecated Use `metadata.replace()` instead.
*/
function saveMetadata(metadata: RunMetadata): void {
runMetadata.update(metadata);
}
/**
* Flushes metadata to the Trigger.dev instance
*
* @param {ApiRequestOptions} [requestOptions] - Optional request options to customize the API request.
* @returns {Promise<void>} A promise that resolves when the metadata flush operation is complete.
*/
async function flushMetadata(requestOptions?: ApiRequestOptions): Promise<void> {
const $requestOptions = mergeRequestOptions(
{
tracer,
name: "metadata.save()",
name: "metadata.flush()",
icon: "code-plus",
attributes: {
...flattenAttributes(metadata),
},
},
requestOptions
);
await runMetadata.update(metadata, $requestOptions);
await runMetadata.flush($requestOptions);
}

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