Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
T
2026-07-13 13:32:57 +08:00

212 lines
7.2 KiB
TypeScript

import { EngineServiceValidationError } from "@internal/run-engine";
import { json } from "@remix-run/server-runtime";
import {
generateJWT as internal_generateJWT,
RunEngineVersionSchema,
TriggerTaskRequestBody,
} from "@trigger.dev/core/v3";
import type { TaskRun } from "@trigger.dev/database";
import { z } from "zod";
import { prisma } from "~/db.server";
import { env } from "~/env.server";
import type { ApiAuthenticationResultSuccess } from "~/services/apiAuth.server";
import { getOneTimeUseToken } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server";
import { determineRealtimeStreamsVersion } from "~/services/realtime/v1StreamsGlobal.server";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
import {
handleRequestIdempotency,
saveRequestIdempotency,
} from "~/utils/requestIdempotency.server";
import { sanitizeTriggerSource } from "~/utils/triggerSource";
import { runStore } from "~/v3/runStore.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server";
const ParamsSchema = z.object({
taskId: z.string(),
});
export const HeadersSchema = z.object({
"idempotency-key": z
.string()
.max(2048, "idempotency-key must be 2048 characters or less")
.nullish(),
"idempotency-key-ttl": z.string().nullish(),
"trigger-version": z.string().nullish(),
"x-trigger-span-parent-as-link": z.coerce.number().nullish(),
"x-trigger-worker": z.string().nullish(),
"x-trigger-client": z.string().nullish(),
"x-trigger-engine-version": RunEngineVersionSchema.nullish(),
"x-trigger-request-idempotency-key": z.string().nullish(),
"x-trigger-realtime-streams-version": z.string().nullish(),
"x-trigger-source": z.string().nullish(),
traceparent: z.string().optional(),
tracestate: z.string().optional(),
});
const { action, loader } = createActionApiRoute(
{
headers: HeadersSchema,
params: ParamsSchema,
body: TriggerTaskRequestBody,
allowJWT: true,
maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE,
authorization: {
action: "trigger",
resource: (params) => ({ type: "tasks", id: params.taskId }),
},
corsStrategy: "all",
},
async ({ body, headers, params, authentication }) => {
const {
"idempotency-key": idempotencyKey,
"idempotency-key-ttl": idempotencyKeyTTL,
"trigger-version": triggerVersion,
"x-trigger-span-parent-as-link": spanParentAsLink,
traceparent,
tracestate,
"x-trigger-worker": isFromWorker,
"x-trigger-client": _triggerClient,
"x-trigger-engine-version": engineVersion,
"x-trigger-request-idempotency-key": requestIdempotencyKey,
"x-trigger-realtime-streams-version": realtimeStreamsVersion,
"x-trigger-source": triggerSourceHeader,
} = headers;
const cachedResponse = await handleRequestIdempotency(requestIdempotencyKey, {
requestType: "trigger",
findCachedEntity: async (cachedRequestId) => {
return await runStore.findRun(
{
id: cachedRequestId,
},
{
select: {
friendlyId: true,
},
},
prisma
);
},
buildResponse: (cachedRun) => ({
id: cachedRun.friendlyId,
isCached: false,
}),
buildResponseHeaders: async (responseBody, cachedEntity) => {
return await responseHeaders(cachedEntity, authentication);
},
});
if (cachedResponse) {
return cachedResponse;
}
const service = new TriggerTaskService();
try {
const traceContext = isFromWorker
? { traceparent, tracestate }
: { external: { traceparent, tracestate } };
const oneTimeUseToken = await getOneTimeUseToken(authentication);
const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL);
const result = await service.call(
params.taskId,
authentication.environment,
body,
{
idempotencyKey: idempotencyKey ?? undefined,
idempotencyKeyExpiresAt: idempotencyKeyExpiresAt,
triggerVersion: triggerVersion ?? undefined,
traceContext,
spanParentAsLink: spanParentAsLink === 1,
oneTimeUseToken,
realtimeStreamsVersion: determineRealtimeStreamsVersion(
realtimeStreamsVersion ?? undefined
),
triggerSource: isFromWorker
? "sdk"
: (sanitizeTriggerSource(triggerSourceHeader) ?? "api"),
triggerAction: "trigger",
},
engineVersion ?? undefined
);
if (!result) {
return json({ error: "Task not found" }, { status: 404 });
}
// Skip request-idempotency caching when the gate diverted to the
// mollifier buffer. `result.run.id` is a synthesised cuid with no
// corresponding PG row, so a lost-response SDK retry that reaches
// `handleRequestIdempotency` would lookup that id, miss in PG, and
// fall through to a fresh trigger — producing a duplicate buffer
// entry for triggers without a task-level idempotency key (the
// task-level path still dedupes via the buffer's SETNX in
// `findBufferedRunWithIdempotency`). Accepting the retry-as-fresh-
// trigger semantics here is bounded by the drainer's eventual
// materialisation: once the run lands in PG, normal request-
// idempotency from that point forward works as usual.
if (!result.isMollified) {
await saveRequestIdempotency(requestIdempotencyKey, "trigger", result.run.id);
}
const $responseHeaders = await responseHeaders(result.run, authentication);
return json(
{
id: result.run.friendlyId,
isCached: result.isCached,
},
{
headers: $responseHeaders,
status: 200,
}
);
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
} else if (error instanceof EngineServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
} else if (error instanceof OutOfEntitlementError) {
return json({ error: error.message }, { status: 422 });
}
logger.error("Trigger task failed", { error });
return json({ error: "Something went wrong" }, { status: 500 });
}
}
);
async function responseHeaders(
run: Pick<TaskRun, "friendlyId">,
authentication: ApiAuthenticationResultSuccess
): Promise<Record<string, string>> {
const { environment, realtime } = authentication;
const claims = {
sub: environment.id,
pub: true,
scopes: [`read:runs:${run.friendlyId}`, `write:inputStreams:${run.friendlyId}`],
realtime,
};
const jwt = await internal_generateJWT({
secretKey: extractJwtSigningSecretKey(environment),
payload: claims,
expirationTime: "1h",
});
return {
"x-trigger-jwt-claims": JSON.stringify(claims),
"x-trigger-jwt": jwt,
};
}
export { action, loader };