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:
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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/*"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
link-workspace-packages=false
|
||||
public-hoist-pattern[]=*prisma*
|
||||
public-hoist-pattern[]=*prisma*
|
||||
prefer-workspace-packages=true
|
||||
Vendored
+2
-6
@@ -1,8 +1,4 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"denoland.vscode-deno"
|
||||
],
|
||||
"unwantedRecommendations": [
|
||||
|
||||
]
|
||||
"recommendations": ["bierner.comment-tagged-templates"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
+1
-1
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
describe("Placeholder", () => {
|
||||
it("should pass", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2019",
|
||||
|
||||
@@ -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"] })],
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "runTags" TEXT[];
|
||||
+3
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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()]);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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">;
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"]
|
||||
>;
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
## trigger.dev react hooks
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../.configs/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"stripInternal": true
|
||||
},
|
||||
"include": ["./src/**/*.ts", "./src/**/*.tsx"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user