v3 dev cli command + more (#894)

This commit is contained in:
Eric Allam
2024-02-12 14:24:32 +00:00
committed by GitHub
parent 70f9bd0d70
commit baf3a84cda
154 changed files with 10722 additions and 768 deletions
+7
View File
@@ -17,6 +17,7 @@ jobs:
uses: actions/checkout@v3
with:
fetch-depth: 0
submodules: recursive
- name: ⎔ Setup pnpm
uses: pnpm/action-setup@v2.2.4
@@ -29,6 +30,9 @@ jobs:
node-version: 18
cache: "pnpm"
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
@@ -43,7 +47,10 @@ jobs:
# Build packages
pnpm run build --filter @references/nextjs-test^...
cd apps/webapp && pnpm run build:server
cd ../..
pnpm --filter @trigger.dev/database generate
pnpm --filter @trigger.dev/otlp-importer generate
# Move trigger-cli bin to correct place
pnpm install --frozen-lockfile
+3 -1
View File
@@ -8,7 +8,6 @@ on:
- "**.md"
- ".github/CODEOWNERS"
- ".github/ISSUE_TEMPLATE/**"
jobs:
release:
@@ -39,6 +38,9 @@ jobs:
node-version: 18
cache: "pnpm"
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
+3
View File
@@ -22,6 +22,9 @@ jobs:
node-version: 18
cache: "pnpm"
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: 📥 Download deps
run: pnpm install --frozen-lockfile
+3
View File
@@ -22,6 +22,9 @@ jobs:
node-version: 18
cache: "pnpm"
- name: Install Protoc
uses: arduino/setup-protoc@v3
- name: ⎔ Setup Deno
uses: denoland/setup-deno@v1
with:
+2 -1
View File
@@ -53,4 +53,5 @@ apps/**/public/build
/playwright-report/
/playwright/.cache/
.cosine
.cosine
.trigger/
+3
View File
@@ -0,0 +1,3 @@
[submodule "packages/otlp-importer/protos"]
path = packages/otlp-importer/protos
url = https://github.com/open-telemetry/opentelemetry-proto.git
+8
View File
@@ -28,6 +28,14 @@
"envFile": "${workspaceFolder}/references/job-catalog/.env",
"cwd": "${workspaceFolder}/references/job-catalog",
"sourceMaps": true
},
{
"type": "node-terminal",
"request": "launch",
"name": "Debug V3 Dev CLI",
"command": "pnpm exec trigger.dev dev",
"cwd": "${workspaceFolder}/references/v3-catalog",
"sourceMaps": true
}
]
}
+2
View File
@@ -194,3 +194,5 @@ function logError(error: unknown, request?: Request) {
}
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
export { wss } from "./v3/handleWebsockets.server";
+4 -1
View File
@@ -1,10 +1,12 @@
import { nanoid } from "nanoid";
import { nanoid, customAlphabet } from "nanoid";
import slug from "slug";
import { prisma } from "~/db.server";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
export async function createProject(
{ organizationSlug, name, userId }: { organizationSlug: string; name: string; userId: string },
attemptCount = 0
@@ -53,6 +55,7 @@ export async function createProject(
slug: organizationSlug,
},
},
externalRef: externalRefGenerator(),
},
include: {
organization: {
@@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core";
import { CreateAuthorizationCodeResponse } from "@trigger.dev/core/v3";
import { env } from "~/env.server";
import { logger } from "~/services/logger.server";
import { createAuthorizationCode } from "~/services/personalAccessToken.server";
@@ -0,0 +1,55 @@
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { CreateBackgroundWorkerService } from "~/v3/services/createBackgroundWorker.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid params" }, { status: 400 });
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
logger.info("Invalid or missing api key", { url: request.url });
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const authenticatedEnv = authenticationResult.environment;
const { projectRef } = parsedParams.data;
const rawBody = await request.json();
const body = CreateBackgroundWorkerRequestBody.safeParse(rawBody);
if (!body.success) {
return json({ error: "Invalid body", issues: body.error.issues }, { status: 400 });
}
const service = new CreateBackgroundWorkerService();
const backgroundWorker = await service.call(projectRef, authenticatedEnv, body.data);
return json(
{
id: backgroundWorker.friendlyId,
version: backgroundWorker.version,
contentHash: backgroundWorker.contentHash,
},
{ status: 200 }
);
}
@@ -0,0 +1,64 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { GetProjectDevResponse } from "@trigger.dev/core/v3";
import { z } from "zod";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
const ParamsSchema = z.object({
projectRef: z.string(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
logger.info("projects get dev env", { url: request.url });
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
}
const parsedParams = ParamsSchema.safeParse(params);
if (!parsedParams.success) {
return json({ error: "Invalid Params" }, { status: 400 });
}
const projectRef = parsedParams.data.projectRef;
const project = await prisma.project.findUnique({
where: {
externalRef: projectRef,
organization: {
members: {
some: {
userId: authenticationResult.userId,
},
},
},
},
include: {
environments: {
where: {
orgMember: {
userId: authenticationResult.userId,
},
},
},
},
});
if (!project) {
return json({ error: "Project not found" }, { status: 404 });
}
const devEnvironment = project.environments[0];
const result: GetProjectDevResponse = {
apiKey: devEnvironment.apiKey,
name: project.name,
};
return json(result);
}
@@ -0,0 +1,85 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { parseTriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
const ParamsSchema = z.object({
taskId: z.string(),
});
const HeadersSchema = z.object({
"idempotency-key": z.string().optional().nullable(),
"trigger-version": z.string().optional().nullable(),
traceparent: z.string().optional(),
tracestate: z.string().optional(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// Next authenticate the request
const authenticationResult = await authenticateApiRequest(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const rawHeaders = Object.fromEntries(request.headers);
const headers = HeadersSchema.safeParse(rawHeaders);
if (!headers.success) {
return json({ error: "Invalid headers" }, { status: 400 });
}
const {
"idempotency-key": idempotencyKey,
"trigger-version": triggerVersion,
traceparent,
tracestate,
} = headers.data;
const { taskId } = ParamsSchema.parse(params);
// Now parse the request body
const anyBody = await request.json();
const body = parseTriggerTaskRequestBody(anyBody);
if (!body.success) {
return json({ error: "Invalid request body" }, { status: 400 });
}
logger.debug("Triggering task", {
taskId,
idempotencyKey,
triggerVersion,
body: body.data,
});
const service = new TriggerTaskService();
try {
const run = await service.call(taskId, authenticationResult.environment, body.data, {
idempotencyKey: idempotencyKey ?? undefined,
triggerVersion: triggerVersion ?? undefined,
traceContext: traceparent ? { traceparent, tracestate } : undefined,
});
return json({
id: run.friendlyId,
});
} catch (error) {
if (error instanceof Error) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Something went wrong" }, { status: 500 });
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime";
import {
GetPersonalAccessTokenRequestSchema,
GetPersonalAccessTokenResponse,
} from "@trigger.dev/core";
} from "@trigger.dev/core/v3";
import { generateErrorMessage } from "zod-error";
import { logger } from "~/services/logger.server";
import { getPersonalAccessTokenFromAuthorizationCode } from "~/services/personalAccessToken.server";
+1 -1
View File
@@ -1,6 +1,6 @@
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { WhoAmIResponse } from "@trigger.dev/core";
import { WhoAmIResponse } from "@trigger.dev/core/v3";
import { prisma } from "~/db.server";
import { logger } from "~/services/logger.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
+13
View File
@@ -0,0 +1,13 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { ExportLogsServiceRequest, ExportLogsServiceResponse } from "@trigger.dev/otlp-importer";
import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
const buffer = await request.arrayBuffer();
const exportRequest = ExportLogsServiceRequest.decode(new Uint8Array(buffer));
const exportResponse = await otlpExporter.exportLogs(exportRequest);
return new Response(ExportLogsServiceResponse.encode(exportResponse).finish(), { status: 200 });
}
+13
View File
@@ -0,0 +1,13 @@
import { ActionFunctionArgs } from "@remix-run/server-runtime";
import { ExportTraceServiceRequest, ExportTraceServiceResponse } from "@trigger.dev/otlp-importer";
import { otlpExporter } from "~/v3/otlpExporter.server";
export async function action({ request }: ActionFunctionArgs) {
const buffer = await request.arrayBuffer();
const exportRequest = ExportTraceServiceRequest.decode(new Uint8Array(buffer));
const exportResponse = await otlpExporter.exportTraces(exportRequest);
return new Response(ExportTraceServiceResponse.encode(exportResponse).finish(), { status: 200 });
}
@@ -144,8 +144,4 @@ export function getSharedSqsEventConsumer() {
return consumer;
}
console.log(
"The SqsEventConsumer is disabled because AWS credentials are missing. This is OK as this is an optional feature."
);
}
+5 -1
View File
@@ -115,7 +115,11 @@ class Telemetry {
};
project = {
identify: ({ project }: { project: Project }) => {
identify: ({
project,
}: {
project: Pick<Project, "id" | "name" | "createdAt" | "updatedAt">;
}) => {
if (this.#posthogClient === undefined) return;
this.#posthogClient.groupIdentify({
groupType: "project",
@@ -0,0 +1,68 @@
export type DynamicFlushSchedulerConfig<T> = {
batchSize: number;
flushInterval: number;
callback: (batch: T[]) => Promise<void>;
};
export class DynamicFlushScheduler<T> {
private batchQueue: T[][]; // Adjust the type according to your data structure
private currentBatch: T[]; // Adjust the type according to your data structure
private readonly BATCH_SIZE: number;
private readonly FLUSH_INTERVAL: number;
private flushTimer: NodeJS.Timeout | null;
private readonly callback: (batch: T[]) => Promise<void>;
constructor(config: DynamicFlushSchedulerConfig<T>) {
this.batchQueue = [];
this.currentBatch = [];
this.BATCH_SIZE = config.batchSize;
this.FLUSH_INTERVAL = config.flushInterval;
this.callback = config.callback;
this.flushTimer = null;
this.startFlushTimer();
}
addToBatch(items: T[]): void {
this.currentBatch.push(...items);
if (this.currentBatch.length >= this.BATCH_SIZE) {
this.batchQueue.push(this.currentBatch);
this.currentBatch = [];
this.flushNextBatch();
this.resetFlushTimer();
}
}
private startFlushTimer(): void {
this.flushTimer = setInterval(() => this.checkAndFlush(), this.FLUSH_INTERVAL);
}
private resetFlushTimer(): void {
if (this.flushTimer) {
clearInterval(this.flushTimer);
}
this.startFlushTimer();
}
private checkAndFlush(): void {
if (this.currentBatch.length > 0) {
this.batchQueue.push(this.currentBatch);
this.currentBatch = [];
}
this.flushNextBatch();
}
private async flushNextBatch(): Promise<void> {
if (this.batchQueue.length === 0) return;
const batchToFlush = this.batchQueue.shift();
try {
await this.callback(batchToFlush!);
if (this.batchQueue.length > 0) {
this.flushNextBatch();
}
} catch (error) {
console.error("Error inserting batch:", error);
}
}
}
@@ -0,0 +1,265 @@
import { Prisma, TaskEventStatus, type TaskEventKind } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { RandomIdGenerator } from "@opentelemetry/sdk-trace-base";
import { Attributes, ROOT_CONTEXT, propagation, trace } from "@opentelemetry/api";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3";
import { flattenAttributes } from "@trigger.dev/core/v3";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { DynamicFlushScheduler } from "./dynamicFlushScheduler.server";
import { logger } from "~/services/logger.server";
export type CreatableEvent = Omit<
Prisma.TaskEventCreateInput,
"id" | "createdAt" | "properties" | "metadata" | "style" | "output"
> & {
properties: Attributes;
metadata: Attributes | undefined;
style: Attributes | undefined;
output: Attributes | undefined;
};
export type CreatableEventKind = TaskEventKind;
export type CreatableEventStatus = TaskEventStatus;
export type CreatableEventEnvironmentType = CreatableEvent["environmentType"];
export type TraceAttributes = Partial<
Pick<
CreatableEvent,
"attemptId" | "isError" | "runId" | "output" | "metadata" | "properties" | "style"
>
>;
export type SetAttribute<T extends TraceAttributes> = (key: keyof T, value: T[keyof T]) => void;
export type TraceEventOptions = {
kind?: CreatableEventKind;
context?: Record<string, string | undefined>;
attributes: TraceAttributes;
environment: AuthenticatedEnvironment;
taskSlug: string;
};
export type EventBuilder = {
traceId: string;
spanId: string;
setAttribute: SetAttribute<TraceAttributes>;
};
export type EventRepoConfig = {
batchSize: number;
batchInterval: number;
};
export class EventRepository {
private readonly _flushScheduler: DynamicFlushScheduler<CreatableEvent>;
private _randomIdGenerator = new RandomIdGenerator();
constructor(private db: PrismaClient = prisma, private readonly _config: EventRepoConfig) {
this._flushScheduler = new DynamicFlushScheduler({
batchSize: _config.batchSize,
flushInterval: _config.batchInterval,
callback: this.#flushBatch.bind(this),
});
}
async insert(event: CreatableEvent) {
this._flushScheduler.addToBatch([event]);
}
async insertMany(events: CreatableEvent[]) {
this._flushScheduler.addToBatch(events);
}
public async traceEvent<TResult>(
message: string,
options: TraceEventOptions,
callback: (
e: EventBuilder,
traceContext: Record<string, string | undefined>
) => Promise<TResult>
): Promise<TResult> {
const propagatedContext = extractContextFromCarrier(options.context ?? {});
const start = process.hrtime.bigint();
const startTime = new Date();
const traceId = propagatedContext?.traceparent?.traceId ?? this.generateTraceId();
const parentId = propagatedContext?.traceparent?.spanId;
const tracestate = propagatedContext?.tracestate;
const spanId = this.generateSpanId();
logger.info("traceEvent", {
traceId,
parentId,
tracestate,
spanId,
context: options.context,
propagatedContext,
});
const traceContext = {
traceparent: `00-${traceId}-${spanId}-01`,
};
const eventBuilder = {
traceId,
spanId,
setAttribute: (key: keyof TraceAttributes, value: TraceAttributes[keyof TraceAttributes]) => {
if (value) {
// We need to merge the attributes with the existing attributes
const existingValue = options.attributes[key];
if (existingValue && typeof existingValue === "object" && typeof value === "object") {
// @ts-ignore
options.attributes[key] = { ...existingValue, ...value };
} else {
// @ts-ignore
options.attributes[key] = value;
}
}
},
};
const result = await callback(eventBuilder, traceContext);
const duration = process.hrtime.bigint() - start;
const metadata = {
[SemanticInternalAttributes.ENVIRONMENT_ID]: options.environment.id,
[SemanticInternalAttributes.ENVIRONMENT_TYPE]: options.environment.type,
[SemanticInternalAttributes.ORGANIZATION_ID]: options.environment.organizationId,
[SemanticInternalAttributes.PROJECT_ID]: options.environment.projectId,
[SemanticInternalAttributes.PROJECT_REF]: options.environment.project.externalRef,
[SemanticInternalAttributes.RUN_ID]: options.attributes.runId,
[SemanticInternalAttributes.TASK_SLUG]: options.taskSlug,
[SemanticResourceAttributes.SERVICE_NAME]: "api server",
[SemanticResourceAttributes.SERVICE_NAMESPACE]: "trigger.dev",
...options.attributes.metadata,
};
const style = {
[SemanticInternalAttributes.STYLE_ICON]: "play",
};
if (!options.attributes.runId) {
throw new Error("runId is required");
}
const event: CreatableEvent = {
traceId,
spanId,
parentId,
tracestate,
duration: duration,
message: message,
serviceName: "api server",
serviceNamespace: "trigger.dev",
level: "TRACE",
kind: options.kind,
status: "OK",
startTime: startTime,
environmentId: options.environment.id,
environmentType: options.environment.type,
organizationId: options.environment.organizationId,
projectId: options.environment.projectId,
projectRef: options.environment.project.externalRef,
runId: options.attributes.runId,
taskSlug: options.taskSlug,
properties: {
...style,
...(flattenAttributes(metadata, SemanticInternalAttributes.METADATA) as Record<
string,
string
>),
},
metadata: metadata,
style: stripAttributePrefix(style, SemanticInternalAttributes.STYLE),
output: undefined,
};
this._flushScheduler.addToBatch([event]);
return result;
}
async #flushBatch(batch: CreatableEvent[]) {
const events = excludePartialEventsWithCorrespondingFullEvent(batch);
await this.db.taskEvent.createMany({
data: events as Prisma.TaskEventCreateManyInput[],
});
}
public generateTraceId() {
return this._randomIdGenerator.generateTraceId();
}
public generateSpanId() {
return this._randomIdGenerator.generateSpanId();
}
}
export const eventRepository = new EventRepository(prisma, {
batchSize: 100,
batchInterval: 5000,
});
export function stripAttributePrefix(attributes: Attributes, prefix: string) {
const result: Attributes = {};
for (const [key, value] of Object.entries(attributes)) {
if (key.startsWith(prefix)) {
result[key.slice(prefix.length + 1)] = value;
} else {
result[key] = value;
}
}
return result;
}
/**
* Filters out partial events from a batch of creatable events, excluding those that have a corresponding full event.
* @param batch - The batch of creatable events to filter.
* @returns The filtered array of creatable events, excluding partial events with corresponding full events.
*/
function excludePartialEventsWithCorrespondingFullEvent(batch: CreatableEvent[]): CreatableEvent[] {
const partialEvents = batch.filter((event) => event.isPartial);
const fullEvents = batch.filter((event) => !event.isPartial);
return fullEvents.concat(
partialEvents.filter((partialEvent) => {
return !fullEvents.some((fullEvent) => fullEvent.spanId === partialEvent.spanId);
})
);
}
function extractContextFromCarrier(carrier: Record<string, string | undefined>) {
const traceparent = carrier["traceparent"];
const tracestate = carrier["tracestate"];
return {
traceparent: parseTraceparent(traceparent),
tracestate,
};
}
function parseTraceparent(traceparent?: string): { traceId: string; spanId: string } | undefined {
if (!traceparent) {
return undefined;
}
const parts = traceparent.split("-");
if (parts.length !== 4) {
return undefined;
}
const [version, traceId, spanId, flags] = parts;
if (version !== "00") {
return undefined;
}
return { traceId, spanId };
}
@@ -0,0 +1,7 @@
import { customAlphabet } from "nanoid";
const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21);
export function generateFriendlyId(prefix: string) {
return `${prefix}_${idGenerator()}`;
}
@@ -0,0 +1,452 @@
import {
BackgroundWorkerClientMessages,
TaskRunExecutionResult,
TaskRunExecution,
ZodMessageHandler,
ZodMessageSender,
clientWebsocketMessages,
serverWebsocketMessages,
TaskRunExecutionPayload,
} from "@trigger.dev/core/v3";
import { BackgroundWorker, BackgroundWorkerTask } from "@trigger.dev/database";
import { Evt } from "evt";
import { randomUUID } from "node:crypto";
import { IncomingMessage } from "node:http";
import { WebSocketServer } from "ws";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { AuthenticatedEnvironment, authenticateApiKey } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { singleton } from "../utils/singleton";
import { generateFriendlyId } from "./friendlyIdentifiers";
export const wss = singleton("wss", initalizeWebSocketServer);
let handlers: Map<string, WebsocketHandlers>;
function initalizeWebSocketServer() {
const server = new WebSocketServer({ noServer: true });
server.on("connection", handleWebSocketConnection);
handlers = new Map();
return server;
}
async function handleWebSocketConnection(ws: WebSocket, req: IncomingMessage) {
const authHeader = req.headers.authorization;
if (!authHeader || typeof authHeader !== "string") {
ws.close(1008, "Missing Authorization header");
return;
}
const [authType, apiKey] = authHeader.split(" ");
if (authType !== "Bearer" || !apiKey) {
ws.close(1008, "Invalid Authorization header");
return;
}
const authenticationResult = await authenticateApiKey(apiKey);
if (!authenticationResult) {
ws.close(1008, "Invalid API key");
return;
}
const authenticatedEnv = authenticationResult.environment;
const handler = new WebsocketHandlers(ws, authenticatedEnv);
handlers.set(handler.id, handler);
handler.onClose.attach((closeEvent) => {
logger.debug("Websocket closed", { closeEvent });
handlers.delete(handler.id);
});
await handler.start();
}
class WebsocketHandlers {
public id: string;
public onClose: Evt<CloseEvent> = new Evt();
private backgroundWorkerHandlers: Map<string, BackgroundWorkerHandler> = new Map();
private _sender: ZodMessageSender<typeof serverWebsocketMessages>;
constructor(public ws: WebSocket, public authenticatedEnv: AuthenticatedEnvironment) {
this.id = randomUUID();
ws.addEventListener("message", this.#handleMessage.bind(this));
ws.addEventListener("close", this.#handleClose.bind(this));
ws.addEventListener("error", this.#handleError.bind(this));
this._sender = new ZodMessageSender({
schema: serverWebsocketMessages,
sender: async (message) => {
ws.send(JSON.stringify(message));
},
});
}
async start() {
this._sender.send("SERVER_READY", { id: this.id });
}
async #handleMessage(ev: MessageEvent) {
const data = JSON.parse(ev.data.toString());
logger.debug("Websocket message received", { data });
const handler = new ZodMessageHandler({
schema: clientWebsocketMessages,
messages: {
READY_FOR_TASKS: async (payload) => {
const handler = new BackgroundWorkerHandler(
payload.backgroundWorkerId,
this.authenticatedEnv,
this._sender
);
this.backgroundWorkerHandlers.set(handler.id, handler);
await handler.start();
},
WORKER_DEPRECATED: async (payload) => {
const handler = this.backgroundWorkerHandlers.get(payload.backgroundWorkerId);
if (!handler) {
logger.error("Failed to find background worker handler", {
backgroundWorkerId: payload.backgroundWorkerId,
});
return;
}
await handler.deprecate();
},
BACKGROUND_WORKER_MESSAGE: async (payload) => {
const handler = this.backgroundWorkerHandlers.get(payload.backgroundWorkerId);
if (!handler) {
logger.error("Failed to find background worker handler", {
backgroundWorkerId: payload.backgroundWorkerId,
});
return;
}
await handler.handleMessage(payload.data);
},
},
});
await handler.handleMessage(data);
}
async #handleClose(ev: CloseEvent) {
for (const handler of this.backgroundWorkerHandlers.values()) {
await handler.stop();
}
this.backgroundWorkerHandlers.clear();
this.onClose.post(ev);
}
async #handleError(ev: Event) {
logger.error("Websocket error", { ev });
}
}
class BackgroundWorkerHandler {
private _backgroundWorker: BackgroundWorker | undefined;
private _backgroundWorkerTasks: Array<BackgroundWorkerTask> | undefined;
private _abortController: AbortController = new AbortController();
private _deprecated: boolean = false;
constructor(
public id: string,
public env: AuthenticatedEnvironment,
private sender: ZodMessageSender<typeof serverWebsocketMessages>
) {}
async start() {
const backgroundWorker = await prisma.backgroundWorker.findUnique({
where: { friendlyId: this.id, runtimeEnvironmentId: this.env.id },
include: {
tasks: true,
},
});
if (!backgroundWorker) {
logger.error("Failed to find background worker", { id: this.id });
return;
}
this._backgroundWorker = backgroundWorker;
this._backgroundWorkerTasks = backgroundWorker.tasks;
logger.debug("Background worker ready", { backgroundWorker });
this.#startRunLoop().catch((err) => {
logger.error("Background worker runloop error", { err });
});
}
async handleMessage(message: BackgroundWorkerClientMessages) {
switch (message.type) {
case "TASK_RUN_COMPLETED": {
await this.#handleTaskRunCompleted(message.completion);
break;
}
}
}
async stop() {
this._abortController.abort();
}
// This will cause the background worker to stop accepting new tasks
// it will still look for tasks locked to it
async deprecate() {
this._deprecated = true;
}
async #handleTaskRunCompleted(completion: TaskRunExecutionResult) {
logger.debug("Task run completed", { taskRunCompletion: completion });
if (completion.ok) {
await prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "COMPLETED",
completedAt: new Date(),
output: completion.output,
outputType: completion.outputType,
},
});
} else {
await prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "FAILED",
completedAt: new Date(),
error: completion.error,
},
});
}
}
// Every 1 second, we'll check for new tasks to run and send them to the client
// if the abort controller is aborted, we'll stop the runloop
async #startRunLoop() {
while (!this._abortController.signal.aborted) {
const { payloads, returnReservedTasksToPending } = await this.#reserveTaskRuns();
if (payloads.length > 0) {
logger.debug("Sending task run payloads to client", { payloads });
if (this._abortController.signal.aborted) {
// Return reserverd task runs to pending
await returnReservedTasksToPending();
return;
}
this.sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId: this.id,
data: {
type: "EXECUTE_RUNS",
payloads,
},
});
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
async #reserveTaskRuns(): Promise<{
payloads: Array<TaskRunExecutionPayload>;
returnReservedTasksToPending: () => Promise<void>;
}> {
const allPayloads: Array<TaskRunExecutionPayload> = [];
const allReturnReservedTasksToPending: Array<() => Promise<void>> = [];
if (!this._backgroundWorkerTasks) {
return { payloads: allPayloads, returnReservedTasksToPending: async () => {} };
}
for (const task of this._backgroundWorkerTasks) {
const { payloads, returnReservedTasksToPending } = await this.#reserveTaskRunsForTask(task);
allPayloads.push(...payloads);
allReturnReservedTasksToPending.push(returnReservedTasksToPending);
}
const returnReservedTasksToPending = async () => {
await Promise.all(allReturnReservedTasksToPending);
};
return { payloads: allPayloads, returnReservedTasksToPending };
}
async #findTaskRunsForTask(task: BackgroundWorkerTask, tx: PrismaClientOrTransaction) {
if (this._deprecated) {
// Only find task runs that are locked to this worker
return tx.taskRun.findMany({
where: {
taskIdentifier: task.slug,
lockedAt: { equals: null },
runtimeEnvironmentId: task.runtimeEnvironmentId,
lockedToVersionId: this._backgroundWorker!.id,
},
include: {
attempts: {
take: 1,
orderBy: { number: "desc" },
},
tags: true,
},
orderBy: { createdAt: "asc" },
take: 10,
});
}
return tx.taskRun.findMany({
where: {
OR: [{ lockedToVersionId: null }, { lockedToVersionId: this._backgroundWorker!.id }],
AND: {
taskIdentifier: task.slug,
lockedAt: { equals: null },
runtimeEnvironmentId: task.runtimeEnvironmentId,
},
},
include: {
attempts: {
take: 1,
orderBy: { number: "desc" },
},
tags: true,
},
orderBy: { createdAt: "asc" },
take: 10,
});
}
async #reserveTaskRunsForTask(task: BackgroundWorkerTask): Promise<{
payloads: Array<TaskRunExecutionPayload>;
returnReservedTasksToPending: () => Promise<void>;
}> {
return await prisma.$transaction(async (tx) => {
const taskRuns = await this.#findTaskRunsForTask(task, tx);
await tx.taskRun.updateMany({
where: {
id: {
in: taskRuns.map((taskRun) => taskRun.id),
},
},
data: {
lockedAt: new Date(),
lockedById: task.id,
},
});
const attempts = taskRuns.map((taskRun) => {
const attemptFriendlyId = generateFriendlyId("attempt");
const create = {
number: taskRun.attempts[0] ? taskRun.attempts[0].number + 1 : 1,
friendlyId: attemptFriendlyId,
taskRunId: taskRun.id,
startedAt: new Date(),
backgroundWorkerId: task.workerId,
backgroundWorkerTaskId: task.id,
status: "EXECUTING" as const,
};
const execution = {
task: {
id: task.slug,
filePath: task.filePath,
exportName: task.exportName,
},
attempt: {
id: attemptFriendlyId,
number: taskRun.attempts[0] ? taskRun.attempts[0].number + 1 : 1,
startedAt: new Date(),
backgroundWorkerId: this.id,
backgroundWorkerTaskId: task.id,
status: "EXECUTING" as const,
},
run: {
id: taskRun.friendlyId,
payload: taskRun.payload,
payloadType: taskRun.payloadType,
context: taskRun.context,
createdAt: taskRun.createdAt,
tags: taskRun.tags.map((tag) => tag.name),
},
environment: {
id: this.env.id,
slug: this.env.slug,
type: this.env.type,
},
organization: {
id: this.env.organization.id,
slug: this.env.organization.slug,
name: this.env.organization.title,
},
project: {
id: this.env.project.id,
ref: this.env.project.externalRef,
slug: this.env.project.slug,
name: this.env.project.name,
},
};
return { create, execution, traceContext: taskRun.traceContext as Record<string, unknown> };
});
await tx.taskRunAttempt.createMany({
data: attempts.map(({ create }) => create),
});
const returnReservedTasksToPending = async () => {
await prisma.taskRun.updateMany({
where: {
id: {
in: attempts.map(({ create }) => create.taskRunId),
},
},
data: {
lockedAt: null,
lockedById: null,
},
});
await prisma.taskRunAttempt.updateMany({
where: {
friendlyId: {
in: attempts.map(({ create }) => create.friendlyId),
},
},
data: {
status: "FAILED",
completedAt: new Date(),
error: "Worker stopped",
},
});
};
return {
payloads: attempts.map(({ execution, traceContext }) => ({ execution, traceContext })),
returnReservedTasksToPending,
};
});
}
}
+488
View File
@@ -0,0 +1,488 @@
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { SemanticInternalAttributes } from "@trigger.dev/core/v3";
import {
AnyValue,
ExportLogsServiceRequest,
ExportLogsServiceResponse,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
KeyValue,
ResourceLogs,
ResourceSpans,
SeverityNumber,
Span,
Span_Event,
Span_Link,
Span_SpanKind,
Status_StatusCode,
} from "@trigger.dev/otlp-importer";
import {
CreatableEventKind,
CreatableEventStatus,
EventRepository,
eventRepository,
type CreatableEvent,
CreatableEventEnvironmentType,
} from "./eventRepository.server";
export type OTLPExporterConfig = {
batchSize: number;
batchInterval: number;
};
class OTLPExporter {
constructor(private readonly _eventRepository: EventRepository) {}
async exportTraces(request: ExportTraceServiceRequest): Promise<ExportTraceServiceResponse> {
const events = this.#filterResourceSpans(request.resourceSpans).flatMap((resourceSpan) => {
return convertSpansToCreateableEvents(resourceSpan);
});
this._eventRepository.insertMany(events);
return ExportTraceServiceResponse.create();
}
async exportLogs(request: ExportLogsServiceRequest): Promise<ExportLogsServiceResponse> {
const events = this.#filterResourceLogs(request.resourceLogs).flatMap((resourceLog) => {
return convertLogsToCreateableEvents(resourceLog);
});
this._eventRepository.insertMany(events);
return ExportLogsServiceResponse.create();
}
#filterResourceSpans(
resourceSpans: ExportTraceServiceRequest["resourceSpans"]
): ExportTraceServiceRequest["resourceSpans"] {
return resourceSpans.filter((resourceSpan) => {
const triggerAttribute = resourceSpan.resource?.attributes.find(
(attribute) => attribute.key === SemanticInternalAttributes.TRIGGER
);
if (!triggerAttribute) return false;
return isBoolValue(triggerAttribute.value) ? triggerAttribute.value.value.boolValue : false;
});
}
#filterResourceLogs(
resourceLogs: ExportLogsServiceRequest["resourceLogs"]
): ExportLogsServiceRequest["resourceLogs"] {
return resourceLogs.filter((resourceLog) => {
const attribute = resourceLog.resource?.attributes.find(
(attribute) => attribute.key === SemanticInternalAttributes.TRIGGER
);
if (!attribute) return false;
return isBoolValue(attribute.value) ? attribute.value.value.boolValue : false;
});
}
}
function convertLogsToCreateableEvents(resourceLog: ResourceLogs): Array<CreatableEvent> {
const resourceAttributes = resourceLog.resource?.attributes ?? [];
const resourceProperties = extractResourceProperties(resourceAttributes);
return resourceLog.scopeLogs.flatMap((scopeLog) => {
return scopeLog.logRecords.map((log) => {
return {
traceId: binaryToHex(log.traceId),
spanId: eventRepository.generateSpanId(),
parentId: binaryToHex(log.spanId),
message: isStringValue(log.body) ? log.body.value.stringValue : `${log.severityText} log`,
isPartial: false,
kind: "INTERNAL",
level: logLevelToEventLevel(log.severityNumber),
status: logLevelToEventStatus(log.severityNumber),
startTime: convertUnixNanoToDate(log.timeUnixNano),
properties: {
...convertKeyValueItemsToMap(log.attributes ?? [], [
SemanticInternalAttributes.SPAN_ID,
SemanticInternalAttributes.SPAN_PARTIAL,
]),
...convertKeyValueItemsToMap(
resourceAttributes,
[SemanticInternalAttributes.TRIGGER],
SemanticInternalAttributes.METADATA
),
},
style: convertKeyValueItemsToMap(
pickAttributes(log.attributes ?? [], SemanticInternalAttributes.STYLE),
[]
),
output: convertKeyValueItemsToMap(
pickAttributes(log.attributes ?? [], SemanticInternalAttributes.OUTPUT),
[]
),
...resourceProperties,
};
});
});
}
function convertSpansToCreateableEvents(resourceSpan: ResourceSpans): Array<CreatableEvent> {
const resourceAttributes = resourceSpan.resource?.attributes ?? [];
const resourceProperties = extractResourceProperties(resourceAttributes);
return resourceSpan.scopeSpans.flatMap((scopeSpan) => {
return scopeSpan.spans.map((span) => {
const isPartial = isPartialSpan(span);
return {
traceId: binaryToHex(span.traceId),
spanId: isPartial
? extractStringAttribute(
span?.attributes ?? [],
SemanticInternalAttributes.SPAN_ID,
binaryToHex(span.spanId)
)
: binaryToHex(span.spanId),
parentId: binaryToHex(span.parentSpanId),
message: span.name,
isPartial,
kind: spanKindToEventKind(span.kind),
level: "TRACE",
status: spanStatusToEventStatus(span.status),
startTime: convertUnixNanoToDate(span.startTimeUnixNano),
links: spanLinksToEventLinks(span.links ?? []),
events: spanEventsToEventEvents(span.events ?? []),
duration: span.endTimeUnixNano - span.startTimeUnixNano,
properties: {
...convertKeyValueItemsToMap(span.attributes ?? [], [
SemanticInternalAttributes.SPAN_ID,
SemanticInternalAttributes.SPAN_PARTIAL,
]),
...convertKeyValueItemsToMap(
resourceAttributes,
[SemanticInternalAttributes.TRIGGER],
SemanticInternalAttributes.METADATA
),
},
style: convertKeyValueItemsToMap(
pickAttributes(span.attributes ?? [], SemanticInternalAttributes.STYLE),
[]
),
output: convertKeyValueItemsToMap(
pickAttributes(span.attributes ?? [], SemanticInternalAttributes.OUTPUT),
[]
),
...resourceProperties,
};
});
});
}
function extractResourceProperties(attributes: KeyValue[]) {
return {
metadata: convertKeyValueItemsToMap(attributes, [SemanticInternalAttributes.TRIGGER]),
serviceName: extractStringAttribute(
attributes,
SemanticResourceAttributes.SERVICE_NAME,
"unknown"
),
serviceNamespace: extractStringAttribute(
attributes,
SemanticResourceAttributes.SERVICE_NAMESPACE,
"unknown"
),
environmentId: extractStringAttribute(
attributes,
SemanticInternalAttributes.ENVIRONMENT_ID,
"unknown"
),
environmentType: extractStringAttribute(
attributes,
SemanticInternalAttributes.ENVIRONMENT_TYPE,
"unknown"
) as CreatableEventEnvironmentType,
organizationId: extractStringAttribute(
attributes,
SemanticInternalAttributes.ORGANIZATION_ID,
"unknown"
),
projectId: extractStringAttribute(attributes, SemanticInternalAttributes.PROJECT_ID, "unknown"),
projectRef: extractStringAttribute(
attributes,
SemanticInternalAttributes.PROJECT_REF,
"unknown"
),
runId: extractStringAttribute(attributes, SemanticInternalAttributes.RUN_ID, "unknown"),
attemptId: extractStringAttribute(attributes, SemanticInternalAttributes.ATTEMPT_ID),
taskSlug: extractStringAttribute(attributes, SemanticInternalAttributes.TASK_SLUG, "unknown"),
taskPath: extractStringAttribute(attributes, SemanticInternalAttributes.TASK_PATH),
taskExportName: extractStringAttribute(attributes, SemanticInternalAttributes.TASK_EXPORT_NAME),
workerId: extractStringAttribute(attributes, SemanticInternalAttributes.WORKER_ID),
workerVersion: extractStringAttribute(attributes, SemanticInternalAttributes.WORKER_VERSION),
};
}
function pickAttributes(attributes: KeyValue[], prefix: string): KeyValue[] {
return attributes
.filter((attribute) => attribute.key.startsWith(prefix))
.map((attribute) => {
return {
key: attribute.key.replace(`${prefix}.`, ""),
value: attribute.value,
};
});
}
function convertKeyValueItemsToMap(
attributes: KeyValue[],
filteredKeys: string[] = [],
prefix?: string
): Record<string, string | number | boolean | undefined> {
return attributes.reduce(
(map: Record<string, string | number | boolean | undefined>, attribute) => {
if (filteredKeys.includes(attribute.key)) return map;
map[`${prefix ? `${prefix}.` : ""}${attribute.key}`] = isStringValue(attribute.value)
? attribute.value.value.stringValue
: isIntValue(attribute.value)
? Number(attribute.value.value.intValue)
: isDoubleValue(attribute.value)
? attribute.value.value.doubleValue
: isBoolValue(attribute.value)
? attribute.value.value.boolValue
: isBytesValue(attribute.value)
? binaryToHex(attribute.value.value.bytesValue)
: undefined;
return map;
},
{}
);
}
function spanLinksToEventLinks(links: Span_Link[]): CreatableEvent["links"] {
return links.map((link) => {
return {
traceId: binaryToHex(link.traceId),
spanId: binaryToHex(link.spanId),
tracestate: link.traceState,
properties: convertKeyValueItemsToMap(link.attributes ?? []),
};
});
}
function spanEventsToEventEvents(events: Span_Event[]): CreatableEvent["events"] {
return events.map((event) => {
return {
name: event.name,
time: convertUnixNanoToDate(event.timeUnixNano),
properties: convertKeyValueItemsToMap(event.attributes ?? []),
};
});
}
function spanStatusToEventStatus(status: Span["status"]): CreatableEventStatus {
if (!status) return "UNSET";
switch (status.code) {
case Status_StatusCode.OK: {
return "OK";
}
case Status_StatusCode.ERROR: {
return "ERROR";
}
case Status_StatusCode.UNSET: {
return "UNSET";
}
default: {
return "UNSET";
}
}
}
function spanKindToEventKind(kind: Span["kind"]): CreatableEventKind {
switch (kind) {
case Span_SpanKind.CLIENT: {
return "CLIENT";
}
case Span_SpanKind.SERVER: {
return "SERVER";
}
case Span_SpanKind.CONSUMER: {
return "CONSUMER";
}
case Span_SpanKind.PRODUCER: {
return "PRODUCER";
}
default: {
return "INTERNAL";
}
}
}
function logLevelToEventLevel(level: SeverityNumber): CreatableEvent["level"] {
switch (level) {
case SeverityNumber.TRACE:
case SeverityNumber.TRACE2:
case SeverityNumber.TRACE3:
case SeverityNumber.TRACE4: {
return "TRACE";
}
case SeverityNumber.DEBUG:
case SeverityNumber.DEBUG2:
case SeverityNumber.DEBUG3:
case SeverityNumber.DEBUG4: {
return "DEBUG";
}
case SeverityNumber.INFO:
case SeverityNumber.INFO2:
case SeverityNumber.INFO3:
case SeverityNumber.INFO4: {
return "INFO";
}
case SeverityNumber.WARN:
case SeverityNumber.WARN2:
case SeverityNumber.WARN3:
case SeverityNumber.WARN4: {
return "WARN";
}
case SeverityNumber.ERROR:
case SeverityNumber.ERROR2:
case SeverityNumber.ERROR3:
case SeverityNumber.ERROR4: {
return "ERROR";
}
case SeverityNumber.FATAL:
case SeverityNumber.FATAL2:
case SeverityNumber.FATAL3:
case SeverityNumber.FATAL4: {
return "ERROR";
}
default: {
return "INFO";
}
}
}
function logLevelToEventStatus(level: SeverityNumber): CreatableEventStatus {
switch (level) {
case SeverityNumber.TRACE:
case SeverityNumber.TRACE2:
case SeverityNumber.TRACE3:
case SeverityNumber.TRACE4: {
return "OK";
}
case SeverityNumber.DEBUG:
case SeverityNumber.DEBUG2:
case SeverityNumber.DEBUG3:
case SeverityNumber.DEBUG4: {
return "OK";
}
case SeverityNumber.INFO:
case SeverityNumber.INFO2:
case SeverityNumber.INFO3:
case SeverityNumber.INFO4: {
return "OK";
}
case SeverityNumber.WARN:
case SeverityNumber.WARN2:
case SeverityNumber.WARN3:
case SeverityNumber.WARN4: {
return "OK";
}
case SeverityNumber.ERROR:
case SeverityNumber.ERROR2:
case SeverityNumber.ERROR3:
case SeverityNumber.ERROR4: {
return "ERROR";
}
case SeverityNumber.FATAL:
case SeverityNumber.FATAL2:
case SeverityNumber.FATAL3:
case SeverityNumber.FATAL4: {
return "ERROR";
}
default: {
return "OK";
}
}
}
function convertUnixNanoToDate(unixNano: bigint): Date {
return new Date(Number(unixNano / BigInt(1_000_000)));
}
function extractStringAttribute(attributes: KeyValue[], name: string): string | undefined;
function extractStringAttribute(attributes: KeyValue[], name: string, fallback: string): string;
function extractStringAttribute(
attributes: KeyValue[],
name: string,
fallback?: string
): string | undefined {
const attribute = attributes.find((attribute) => attribute.key === name);
if (!attribute) return fallback;
return isStringValue(attribute?.value) ? attribute.value.value.stringValue : fallback;
}
function isPartialSpan(span: Span): boolean {
if (!span.attributes) return false;
const attribute = span.attributes.find(
(attribute) => attribute.key === SemanticInternalAttributes.SPAN_PARTIAL
);
if (!attribute) return false;
return isBoolValue(attribute.value) ? attribute.value.value.boolValue : false;
}
function isBoolValue(
value: AnyValue | undefined
): value is { value: { $case: "boolValue"; boolValue: boolean } } {
if (!value) return false;
return (value.value && value.value.$case === "boolValue")!!;
}
function isStringValue(
value: AnyValue | undefined
): value is { value: { $case: "stringValue"; stringValue: string } } {
if (!value) return false;
return (value.value && value.value.$case === "stringValue")!!;
}
function isIntValue(
value: AnyValue | undefined
): value is { value: { $case: "intValue"; intValue: bigint } } {
if (!value) return false;
return (value.value && value.value.$case === "intValue")!!;
}
function isDoubleValue(
value: AnyValue | undefined
): value is { value: { $case: "doubleValue"; doubleValue: number } } {
if (!value) return false;
return (value.value && value.value.$case === "doubleValue")!!;
}
function isBytesValue(
value: AnyValue | undefined
): value is { value: { $case: "bytesValue"; bytesValue: Buffer } } {
if (!value) return false;
return (value.value && value.value.$case === "bytesValue")!!;
}
function binaryToHex(buffer: Buffer): string;
function binaryToHex(buffer: Buffer | undefined): string | undefined;
function binaryToHex(buffer: Buffer | undefined): string | undefined {
if (!buffer) return undefined;
return Buffer.from(Array.from(buffer)).toString("hex");
}
export const otlpExporter = new OTLPExporter(eventRepository);
@@ -0,0 +1,106 @@
import { CreateBackgroundWorkerRequestBody } from "@trigger.dev/core/v3";
import type { BackgroundWorker } from "@trigger.dev/database";
import { PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
export class CreateBackgroundWorkerService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
projectRef: string,
environment: AuthenticatedEnvironment,
body: CreateBackgroundWorkerRequestBody
): Promise<BackgroundWorker> {
const project = await this.#prismaClient.project.findUniqueOrThrow({
where: {
externalRef: projectRef,
environments: {
some: {
id: environment.id,
},
},
},
include: {
backgroundWorkers: {
where: {
runtimeEnvironmentId: environment.id,
},
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
});
const latestBackgroundWorker = project.backgroundWorkers[0];
if (latestBackgroundWorker?.contentHash === body.metadata.contentHash) {
return latestBackgroundWorker;
}
const nextVersion = calculateNextBuildVersion(project.backgroundWorkers[0]?.version);
logger.debug(`Creating background worker`, {
nextVersion,
lastVersion: project.backgroundWorkers[0]?.version,
});
const backgroundWorker = await this.#prismaClient.backgroundWorker.create({
data: {
friendlyId: generateFriendlyId("worker"),
version: nextVersion,
runtimeEnvironmentId: environment.id,
projectId: project.id,
metadata: body.metadata,
contentHash: body.metadata.contentHash,
},
});
for (const task of body.metadata.tasks) {
await this.#prismaClient.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: project.id,
runtimeEnvironmentId: environment.id,
workerId: backgroundWorker.id,
slug: task.id,
filePath: task.filePath,
exportName: task.exportName,
},
});
}
return backgroundWorker;
}
}
// Calculate next build version based on the previous version
// Version formats are YYYYMMDD.1, YYYYMMDD.2, etc.
// If there is no previous version, start at Todays date and .1
function calculateNextBuildVersion(latestVersion?: string | null): string {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
const todayFormatted = `${year}${month < 10 ? "0" : ""}${month}${day < 10 ? "0" : ""}${day}`;
if (!latestVersion) {
return `${todayFormatted}.1`;
}
const [date, buildNumber] = latestVersion.split(".");
if (date === todayFormatted) {
const nextBuildNumber = parseInt(buildNumber, 10) + 1;
return `${date}.${nextBuildNumber}`;
}
return `${todayFormatted}.1`;
}
@@ -0,0 +1,94 @@
import { SemanticInternalAttributes, TriggerTaskRequestBody } from "@trigger.dev/core/v3";
import { flattenAttributes } from "@trigger.dev/core/v3";
import { nanoid } from "nanoid";
import { PrismaClient, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { eventRepository } from "../eventRepository.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
export type TriggerTaskServiceOptions = {
idempotencyKey?: string;
triggerVersion?: string;
traceContext?: Record<string, string | undefined>;
};
export class TriggerTaskService {
#prismaClient: PrismaClient;
constructor(prismaClient: PrismaClient = prisma) {
this.#prismaClient = prismaClient;
}
public async call(
taskId: string,
environment: AuthenticatedEnvironment,
body: TriggerTaskRequestBody,
options: TriggerTaskServiceOptions = {}
) {
const idempotencyKey = options.idempotencyKey ?? nanoid();
const existingRun = await this.#prismaClient.taskRun.findUnique({
where: {
runtimeEnvironmentId_idempotencyKey: {
runtimeEnvironmentId: environment.id,
idempotencyKey,
},
},
});
if (existingRun) {
return existingRun;
}
return await eventRepository.traceEvent(
`Triggering task ${taskId}`,
{
context: options.traceContext,
kind: "SERVER",
environment,
taskSlug: taskId,
attributes: {
metadata: {
...flattenAttributes(body.payload, SemanticInternalAttributes.PAYLOAD),
},
style: {
icon: "play",
},
},
},
async (event, traceContext) => {
const parentAttempt = body.options?.parentAttempt
? await this.#prismaClient.taskRunAttempt.findUnique({
where: {
friendlyId: body.options.parentAttempt,
},
})
: undefined;
const taskRun = await this.#prismaClient.taskRun.create({
data: {
friendlyId: generateFriendlyId("run"),
runtimeEnvironmentId: environment.id,
projectId: environment.projectId,
idempotencyKey,
taskIdentifier: taskId,
payload: JSON.stringify(body.payload),
payloadType: "application/json",
context: body.context,
traceContext: traceContext,
traceId: event.traceId,
spanId: event.spanId,
parentAttemptId: parentAttempt?.id,
lockedToVersionId: body.options?.lockToCurrentVersion
? parentAttempt?.backgroundWorkerId
: undefined,
},
});
event.setAttribute("runId", taskRun.friendlyId);
return taskRun;
}
);
}
}
+42
View File
@@ -0,0 +1,42 @@
import api, { DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { ConsoleSpanExporter } from "@opentelemetry/sdk-trace-base";
import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import {
LoggerProvider,
SimpleLogRecordProcessor,
ConsoleLogRecordExporter,
} from "@opentelemetry/sdk-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
api.diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ALL);
const provider = new NodeTracerProvider({
forceFlushTimeoutMillis: 500,
resource: new Resource({
[SemanticResourceAttributes.SERVICE_NAME]: "trigger.dev",
}),
});
const exporter = new OTLPTraceExporter({
url: "http://0.0.0.0:4318/v1/traces",
timeoutMillis: 1000,
});
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register();
const logExporter = new OTLPLogExporter({
url: "http://0.0.0.0:4318/v1/logs",
});
// To start a logger, you first need to initialize the Logger provider.
const loggerProvider = new LoggerProvider();
// Add a processor to export log record
loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
// To create a log record, you first need to get a Logger instance
export const logger = loggerProvider.getLogger("default");
export const tracer = provider.getTracer("trigger.dev", "3.0.0.dp.1");
+15 -2
View File
@@ -8,8 +8,7 @@
"build:db:seed": "esbuild --platform=node --bundle --minify --format=cjs ./prisma/seed.ts --outdir=prisma",
"build:remix": "remix build",
"build:server": "esbuild --platform=node --format=cjs ./server.ts --outdir=build",
"dev": "cross-env PORT=3030 remix dev",
"dev:manual": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"",
"dev": "cross-env PORT=3030 remix dev -c \"node ./build/server.js\"",
"format": "prettier --write .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "cross-env NODE_ENV=production node --max-old-space-size=8192 ./build/server.js",
@@ -48,6 +47,15 @@
"@highlight-run/react": "^3.2.0",
"@internationalized/date": "^3.5.1",
"@lezer/highlight": "^1.1.6",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.21.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.48.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-logs": "^0.48.0",
"@opentelemetry/sdk-trace-base": "^1.21.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-label": "^2.0.1",
@@ -75,6 +83,7 @@
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-backend": "workspace:*",
"@trigger.dev/database": "workspace:*",
"@trigger.dev/otlp-importer": "workspace:*",
"@trigger.dev/sdk": "workspace:*",
"@trigger.dev/yalt": "workspace:*",
"@types/pg": "8.6.6",
@@ -87,6 +96,7 @@
"cross-env": "^7.0.3",
"cuid": "^2.1.8",
"emails": "workspace:*",
"evt": "^2.4.13",
"express": "^4.18.1",
"framer-motion": "^10.12.11",
"graphile-worker": "^0.13.0",
@@ -131,6 +141,8 @@
"tailwindcss-animate": "^1.0.5",
"tiny-invariant": "^1.2.0",
"ulid": "^2.3.0",
"ulidx": "^2.2.1",
"ws": "^8.11.0",
"zod": "3.22.3",
"zod-error": "1.5.0",
"zod-validation-error": "^1.5.0"
@@ -175,6 +187,7 @@
"@types/simple-oauth2": "^5.0.4",
"@types/slug": "^5.0.3",
"@types/tar": "^6.1.4",
"@types/ws": "^8.5.3",
"@typescript-eslint/eslint-plugin": "^5.59.6",
"@typescript-eslint/parser": "^5.59.6",
"autoprefixer": "^10.4.13",
+1
View File
@@ -40,6 +40,7 @@ export async function seedCloud(prisma: PrismaClient) {
create: {
name: "My Project",
slug: "my-project-123",
externalRef: "my-project-123",
},
},
},
+43 -27
View File
@@ -3,6 +3,8 @@ 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";
const app = express();
@@ -38,28 +40,29 @@ app.use(morgan("tiny"));
const MODE = process.env.NODE_ENV;
const BUILD_DIR = path.join(process.cwd(), "build");
const build = require(BUILD_DIR);
app.all(
"*",
MODE === "production"
? createRequestHandler({ build: require(BUILD_DIR) })
: (...args) => {
purgeRequireCache();
const requestHandler = createRequestHandler({
build: require(BUILD_DIR),
mode: MODE,
});
return requestHandler(...args);
}
createRequestHandler({
build,
mode: MODE,
})
);
const port = process.env.REMIX_APP_PORT || process.env.PORT || 3000;
if (process.env.HTTP_SERVER_DISABLED !== "true") {
const wss: WebSocketServer | undefined = build.entry.module.wss;
const server = app.listen(port, () => {
// require the built app so we're ready when the first request comes in
require(BUILD_DIR);
console.log(`✅ app ready: http://localhost:${port}`);
console.log(`✅ app ready: http://localhost:${port} [NODE_ENV: ${MODE}]`);
if (MODE === "development") {
broadcastDevReady(build)
.then(() => logDevReady(build))
.catch(console.error);
}
});
server.keepAliveTimeout = 65 * 1000;
@@ -73,21 +76,34 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
}
});
});
server.on("upgrade", async (req, socket, head) => {
console.log(
`Attemping to upgrade connection at url ${req.url} with headers: ${JSON.stringify(
req.headers
)}`
);
const url = new URL(req.url ?? "", "http://localhost");
// Only upgrade the connecting if the path is `/ws`
if (url.pathname !== "/ws") {
socket.destroy(
new Error(
"Cannot connect because of invalid path: Please include `/ws` in the path of your upgrade request."
)
);
return;
}
console.log(`Client connected, upgrading their connection...`);
// Handle the WebSocket connection
wss?.handleUpgrade(req, socket, head, (ws) => {
wss?.emit("connection", ws, req);
});
});
} else {
require(BUILD_DIR);
console.log(`✅ app ready (skipping http server)`);
}
function purgeRequireCache() {
// purge require cache on requests for "server side HMR" this won't let
// you have in-memory objects between requests in development,
// alternatively you can set up nodemon/pm2-dev to restart the server on
// file changes, we prefer the DX of this though, so we've included it
// for you by default
for (const key in require.cache) {
if (key.startsWith(BUILD_DIR)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete require.cache[key];
}
}
}
+2
View File
@@ -29,6 +29,8 @@
"@trigger.dev/database/*": ["../../packages/database/src/*"],
"@trigger.dev/yalt": ["../../packages/yalt/src/index"],
"@trigger.dev/yalt/*": ["../../packages/yalt/src/*"],
"@trigger.dev/otlp-importer": ["../../packages/otlp-importer/src/index"],
"@trigger.dev/otlp-importer/*": ["../../packages/otlp-importer/src/*"],
"emails": ["../../packages/emails/src/index"],
"emails/*": ["../../packages/emails/src/*"]
},
+1 -1
View File
@@ -6,7 +6,7 @@
"main": "./src/index.ts",
"types": "./src/index.ts",
"dependencies": {
"esbuild": "^0.19.2",
"esbuild": "^0.19.11",
"tsup": "^8.0.1"
},
"devDependencies": {
+9 -5
View File
@@ -54,9 +54,13 @@ services:
ports:
- 6379:6379
redisinsight:
image: redislabs/redisinsight:latest
ports:
- "8001:8001"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config", "/etc/otel-collector-config.yaml"]
volumes:
- redis-data:/redisinsight
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "55680:55680"
- "55681:55681"
- "4317:4317" # OTLP gRPC receiver
- "4318:4318" # OTLP http receiver
+32
View File
@@ -0,0 +1,32 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
exporters:
otlp:
endpoint: "api.honeycomb.io:443"
headers:
"x-honeycomb-team": "7e999faWC62210wKXMAHNM"
logging:
verbosity: normal
otlphttp:
endpoint: "http://host.docker.internal:3030/otel"
compression: none
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp, otlphttp]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp, otlphttp]
+1 -1
View File
@@ -19,7 +19,7 @@
"format": "prettier . --write --config prettier.config.js",
"generate": "turbo run generate",
"lint": "turbo run lint",
"docker": "docker-compose -p triggerdotdev-docker -f docker/docker-compose.yml up -d",
"docker": "docker-compose -p triggerdotdev-docker -f docker/docker-compose.yml up -d --build --remove-orphans",
"docker:stop": "docker-compose -p triggerdotdev-docker -f docker/docker-compose.yml stop",
"dev:docker": "docker-compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml up -d",
"dev:docker:build": "docker-compose -p triggerdotdev-dev-docker -f docker/dev-compose.yml up -d --build",
+43 -9
View File
@@ -32,35 +32,56 @@
"type": "module",
"exports": "./dist/index.js",
"bin": {
"trigger-v3-cli": "./dist/index.js"
"trigger.dev": "./dist/index.js"
},
"devDependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/tsconfig": "workspace:*",
"@types/gradient-string": "^1.1.2",
"@types/mock-fs": "^4.13.1",
"@types/node": "16",
"@types/node-fetch": "^2.6.2",
"@types/node": "18",
"@types/object-hash": "^3.0.6",
"@types/react": "^18.2.48",
"@types/ws": "^8.5.3",
"npm-run-all": "^4.1.5",
"open": "^10.0.3",
"p-retry": "^6.1.0",
"rimraf": "^3.0.2",
"tsup": "^6.5.0",
"tsup": "^8.0.1",
"type-fest": "^3.6.0",
"typescript": "^4.9.5",
"typescript": "^5.3.3",
"vitest": "^0.34.4",
"xdg-app-paths": "^8.3.0"
},
"scripts": {
"typecheck": "tsc",
"build": "tsup",
"dev": "tsup --watch",
"build": "npm run clean && run-p build:**",
"build:main": "tsup",
"build:facade": "tsup --config tsup.facade.config.ts",
"dev": "npm run clean && run-p dev:**",
"dev:main": "tsup --watch",
"dev:facade": "tsup --config tsup.facade.config.ts --watch",
"clean": "rimraf dist",
"start": "node dist/index.js",
"test": "vitest"
},
"dependencies": {
"@clack/prompts": "^0.7.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/api-logs": "^0.48.0",
"@opentelemetry/auto-instrumentations-node": "^0.40.3",
"@opentelemetry/exporter-collector": "^0.25.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.48.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
"@opentelemetry/instrumentation": "^0.48.0",
"@opentelemetry/instrumentation-fetch": "^0.48.0",
"@opentelemetry/instrumentation-http": "^0.48.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-logs": "^0.48.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/sdk-trace-base": "^1.21.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@trigger.dev/core": "workspace:*",
"@types/degit": "^2.8.3",
"chalk": "^5.2.0",
@@ -69,22 +90,35 @@
"commander": "^9.4.1",
"degit": "^2.8.4",
"dotenv": "^16.3.1",
"esbuild": "^0.19.11",
"evt": "^2.4.13",
"execa": "^7.0.0",
"find-up": "^7.0.0",
"gradient-string": "^2.0.2",
"import-meta-resolve": "^4.0.0",
"ink": "^4.4.1",
"liquidjs": "^10.9.2",
"mock-fs": "^5.2.0",
"nanoid": "^4.0.2",
"node-fetch": "^3.3.0",
"npm-check-updates": "^16.12.2",
"object-hash": "^3.0.0",
"p-throttle": "^6.1.0",
"partysocket": "^0.0.17",
"posthog-node": "^3.1.1",
"proxy-agent": "^6.3.0",
"react": "^18.2.0",
"react-error-boundary": "^4.0.12",
"simple-git": "^3.19.0",
"source-map": "^0.7.4",
"supports-color": "^9.4.0",
"terminal-link": "^3.0.0",
"update-check": "^1.5.4",
"url": "^0.11.1",
"ws": "^8.11.0",
"ws": "^8.12.0",
"zod": "3.22.3"
},
"engines": {
"node": ">=18.0.0"
}
}
}
+46 -6
View File
@@ -2,13 +2,17 @@ import { z } from "zod";
import {
CreateAuthorizationCodeResponseSchema,
GetPersonalAccessTokenResponseSchema,
GetProjectDevResponse,
CreateBackgroundWorkerRequestBody,
WhoAmIResponseSchema,
} from "@trigger.dev/core";
CreateBackgroundWorkerResponse,
} from "@trigger.dev/core/v3";
export class ApiClient {
constructor(private readonly apiURL: string) {
this.apiURL = apiURL;
}
constructor(
private readonly apiURL: string,
private readonly accessToken?: string
) {}
async createAuthorizationCode() {
return zodfetch(
@@ -29,10 +33,46 @@ export class ApiClient {
});
}
async whoAmI({ accessToken }: { accessToken: string }) {
async whoAmI() {
if (!this.accessToken) {
throw new Error("whoAmI: No access token");
}
return zodfetch(WhoAmIResponseSchema, `${this.apiURL}/api/v2/whoami`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
});
}
async createBackgroundWorker(projectRef: string, body: CreateBackgroundWorkerRequestBody) {
if (!this.accessToken) {
throw new Error("indexProject: No access token");
}
return zodfetch(
CreateBackgroundWorkerResponse,
`${this.apiURL}/api/v1/projects/${projectRef}/background-workers`,
{
method: "POST",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
}
);
}
async getProjectDevEnv({ projectRef }: { projectRef: string }) {
if (!this.accessToken) {
throw new Error("getProjectDevEnv: No access token");
}
return zodfetch(GetProjectDevResponse, `${this.apiURL}/api/v1/projects/${projectRef}/dev`, {
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
});
+7
View File
@@ -0,0 +1,7 @@
import { z } from "zod";
export const CommonCommandOptions = z.object({
logLevel: z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log"),
});
export type CommonCommandOptions = z.infer<typeof CommonCommandOptions>;
+17 -39
View File
@@ -1,12 +1,13 @@
import { Command } from "commander";
import { devCommand } from "../commands/dev";
import { updateCommand } from "../commands/update";
import { whoamiCommand } from "../commands/whoami.js";
import { COMMAND_NAME } from "../consts";
import { getVersion } from "../utilities/getVersion";
import { printInitialBanner } from "../utilities/initialBanner";
import { login, loginCommand } from "../commands/login";
import { z } from "zod";
import { configureDevCommand } from "../commands/dev.js";
import { loginCommand } from "../commands/login.js";
import { logoutCommand } from "../commands/logout.js";
import { updateCommand } from "../commands/update.js";
import { configureWhoamiCommand } from "../commands/whoami.js";
import { COMMAND_NAME } from "../consts.js";
import { getVersion } from "../utilities/getVersion.js";
import { printInitialBanner } from "../utilities/initialBanner.js";
export const program = new Command();
@@ -39,30 +40,23 @@ program
}
});
//todo update for the new version
//todo add usage instructions to the README
program
.command("dev")
.description("Run your Trigger.dev tasks locally")
.argument("[path]", "The path to the project", ".")
.option("-p, --port <port>", "Override the local port your server is on")
.option("-H, --hostname <hostname>", "Override the hostname on which the application is served")
.option("-e, --env-file <name>", "Override the name of the env file to load")
.option(
"-i, --client-id <name>",
"The ID of the client to use for this project. Will use the value from the package.json file if not provided."
)
.command("logout")
.description("Logout of Trigger.dev")
.version(getVersion(), "-v, --version", "Display the version number")
.action(async (path, options) => {
.action(async (options) => {
try {
await printInitialBanner();
await devCommand(path, options);
await printInitialBanner(false);
await logoutCommand(options);
//todo login command
} catch (e) {
//todo error reporting
throw e;
}
});
configureDevCommand(program);
program
.command("update")
.description(
@@ -75,20 +69,4 @@ program
await updateCommand(path, options);
});
program
.command("whoami")
.description("display the current logged in user and project details")
.option(
"-a, --api-url <value>",
"Override the API URL, defaults to https://cloud.trigger.dev",
"https://cloud.trigger.dev"
)
.version(getVersion(), "-v, --version", "Display the version number")
.action(async (options) => {
try {
await printInitialBanner();
await whoamiCommand(options);
} catch (e) {
throw e;
}
});
configureWhoamiCommand(program);
-39
View File
@@ -1,39 +0,0 @@
import childProcess from "child_process";
import util from "util";
import { z } from "zod";
import { telemetryClient } from "../telemetry/telemetry";
import { logger } from "../utilities/logger";
import { resolvePath } from "../utilities/parseNameAndPath";
import { RequireKeys } from "../utilities/requiredKeys";
const asyncExecFile = util.promisify(childProcess.execFile);
export const DevCommandOptionsSchema = z.object({
port: z.coerce.number().optional(),
hostname: z.string().optional(),
envFile: z.string().optional(),
clientId: z.string().optional(),
});
export type DevCommandOptions = z.infer<typeof DevCommandOptionsSchema>;
type ResolvedOptions = RequireKeys<DevCommandOptions, "envFile">;
const formattedDate = new Intl.DateTimeFormat("en", {
hour: "numeric",
minute: "numeric",
second: "numeric",
});
export async function devCommand(path: string, anyOptions: any) {
telemetryClient.dev.started(path, anyOptions);
const result = DevCommandOptionsSchema.safeParse(anyOptions);
if (!result.success) {
logger.error(result.error.message);
return;
}
const options = result.data;
const resolvedPath = resolvePath(path);
}
+692
View File
@@ -0,0 +1,692 @@
import {
CreateBackgroundWorkerRequestBody,
TaskResource,
ZodMessageHandler,
ZodMessageSender,
clientWebsocketMessages,
serverWebsocketMessages,
} from "@trigger.dev/core/v3";
import chalk from "chalk";
import { watch } from "chokidar";
import { Command } from "commander";
import { BuildContext, context } from "esbuild";
import { findUp } from "find-up";
import { resolve as importResolve } from "import-meta-resolve";
import { Box, Text, render, useApp, useInput } from "ink";
import { createHash } from "node:crypto";
import fs, { readFileSync } from "node:fs";
import { ClientRequestArgs } from "node:http";
import { basename, dirname, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import pThrottle from "p-throttle";
import { WebSocket } from "partysocket";
import React, { Suspense, useEffect } from "react";
import { ClientOptions, WebSocket as wsWebSocket } from "ws";
import { z } from "zod";
import * as packageJson from "../../package.json";
import { ApiClient } from "../apiClient.js";
import { CLOUD_API_URL } from "../consts.js";
import { BackgroundWorker, BackgroundWorkerCoordinator } from "../dev/backgroundWorker.js";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { logger } from "../utilities/logger.js";
import { RequireKeys } from "../utilities/requiredKeys.js";
import { isLoggedIn } from "../utilities/session.js";
import { CommonCommandOptions } from "../cli/common.js";
const CONFIG_FILES = ["trigger.config.js", "trigger.config.mjs"];
const ConfigSchema = z.object({
project: z.string(),
triggerDirectories: z.string().array().optional(),
triggerUrl: z.string().optional(),
projectDir: z.string().optional(),
});
type Config = z.infer<typeof ConfigSchema>;
type ResolvedConfig = RequireKeys<Config, "triggerDirectories" | "triggerUrl" | "projectDir">;
type TaskFile = {
triggerDir: string;
filePath: string;
importPath: string;
importName: string;
};
let apiClient: ApiClient | undefined;
const DevCommandOptions = CommonCommandOptions;
type DevCommandOptions = z.infer<typeof DevCommandOptions>;
export function configureDevCommand(program: Command) {
program
.command("dev")
.description("Run your Trigger.dev tasks locally")
.argument("[path]", "The path to the project", ".")
.option(
"-l, --log-level <level>",
"The log level to use (debug, info, log, warn, error, none)",
"log"
)
.action(async (path, options) => {
try {
await devCommand(path, options);
} catch (e) {
//todo error reporting
throw e;
}
});
}
export async function devCommand(dir: string, anyOptions: unknown) {
const options = DevCommandOptions.safeParse(anyOptions);
if (!options.success) {
throw new Error(`Invalid options: ${options.error}`);
}
const authorization = await isLoggedIn();
if (!authorization.ok) {
logger.error("You must login first. Use `trigger.dev login` to login.");
process.exitCode = 1;
return;
}
let watcher;
try {
const devInstance = await startDev(dir, options.data, authorization.config);
watcher = devInstance.watcher;
const { waitUntilExit } = devInstance.devReactElement;
await waitUntilExit();
} finally {
await watcher?.close();
}
}
async function startDev(
dir: string,
options: DevCommandOptions,
authorization: { apiUrl: string; accessToken: string }
) {
let watcher: ReturnType<typeof watch> | undefined;
let rerender: (node: React.ReactNode) => void | undefined;
try {
if (options.logLevel) {
logger.loggerLevel = options.logLevel;
}
await printStandloneInitialBanner(true);
const configPath = await getConfigPath(dir);
let config = await readConfig(configPath);
watcher = watch(configPath, {
persistent: true,
}).on("change", async (_event) => {
config = await readConfig(configPath);
logger.log(`${basename(configPath)} changed...`);
logger.debug("New config", { config });
rerender(await getDevReactElement(config, authorization));
});
async function getDevReactElement(
configParam: ResolvedConfig,
authorization: { apiUrl: string; accessToken: string }
) {
const accessToken = authorization.accessToken;
const apiUrl = authorization.apiUrl;
apiClient = new ApiClient(apiUrl, accessToken);
const devEnv = await apiClient.getProjectDevEnv({ projectRef: config.project });
if (!devEnv.success) {
throw new Error(devEnv.error);
}
const environmentClient = new ApiClient(apiUrl, devEnv.data.apiKey);
return (
<DevUI
config={configParam}
apiUrl={apiUrl}
apiKey={devEnv.data.apiKey}
environmentClient={environmentClient}
projectName={devEnv.data.name}
/>
);
}
const devReactElement = render(await getDevReactElement(config, authorization));
rerender = devReactElement.rerender;
return {
devReactElement,
watcher,
stop: async () => {
devReactElement.unmount();
await watcher?.close();
},
};
} catch (e) {
await watcher?.close();
throw e;
}
}
type DevProps = {
config: ResolvedConfig;
apiUrl: string;
apiKey: string;
environmentClient: ApiClient;
projectName: string;
};
function useDev({ config, apiUrl, apiKey, environmentClient, projectName }: DevProps) {
useEffect(() => {
const websocketUrl = new URL(apiUrl);
websocketUrl.protocol = websocketUrl.protocol.replace("http", "ws");
websocketUrl.pathname = `/ws`;
const websocket = new WebSocket(websocketUrl.href, [], {
WebSocket: WebsocketFactory(apiKey),
connectionTimeout: 10000,
maxRetries: 6,
});
websocket.addEventListener("open", (foo) => {});
websocket.addEventListener("close", (event) => {});
websocket.addEventListener("error", (event) => {});
const sender = new ZodMessageSender({
schema: clientWebsocketMessages,
sender: async (message) => {
websocket?.send(JSON.stringify(message));
},
});
const backgroundWorkerCoordinator = new BackgroundWorkerCoordinator(
`${apiUrl}/projects/v3/${config.project}`
);
backgroundWorkerCoordinator.onTaskCompleted.attach(
async ({ backgroundWorkerId, completion }) => {
await sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId,
data: {
type: "TASK_RUN_COMPLETED",
completion,
},
});
}
);
backgroundWorkerCoordinator.onWorkerRegistered.attach(async ({ id, worker, record }) => {
await sender.send("READY_FOR_TASKS", {
backgroundWorkerId: id,
});
});
backgroundWorkerCoordinator.onWorkerDeprecated.attach(async ({ id }) => {
await sender.send("WORKER_DEPRECATED", {
backgroundWorkerId: id,
});
});
websocket.addEventListener("message", async (event) => {
const data = JSON.parse(
typeof event.data === "string" ? event.data : new TextDecoder("utf-8").decode(event.data)
);
const messageHandler = new ZodMessageHandler({
schema: serverWebsocketMessages,
messages: {
SERVER_READY: async (payload) => {},
BACKGROUND_WORKER_MESSAGE: async (payload) => {
await backgroundWorkerCoordinator.handleMessage(
payload.backgroundWorkerId,
payload.data
);
},
},
});
await messageHandler.handleMessage(data);
});
let ctx: BuildContext | undefined;
async function runBuild() {
if (ctx) {
await ctx.cancel();
await ctx.dispose();
}
let latestWorkerContentHash: string | undefined;
const taskFiles = await gatherTaskFiles(config);
const workerFacade = readFileSync(
new URL(importResolve("./worker-facade.js", import.meta.url)).href.replace("file://", ""),
"utf-8"
);
const entryPointContents = workerFacade.replace(
"__TASKS__",
createTaskFileImports(taskFiles)
);
let firstBuild = true;
logger.log(chalk.dim("⎔ Building background worker..."));
ctx = await context({
stdin: {
contents: entryPointContents,
resolveDir: process.cwd(),
sourcefile: "__entryPoint.ts",
},
bundle: true,
metafile: true,
write: false,
minify: false,
sourcemap: true,
logLevel: "silent",
platform: "node",
format: "esm",
target: ["node18", "es2020"],
outdir: "out",
define: {
TRIGGER_API_URL: `"${config.triggerUrl}"`,
},
banner: {
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
},
plugins: [
{
name: "trigger.dev v3",
setup(build) {
build.onEnd(async (result) => {
if (result.errors.length > 0) return;
if (!result || !result.outputFiles) {
logger.error("Build failed: no result");
return;
}
if (!firstBuild) {
logger.log(chalk.dim("⎔ Rebuilding background worker..."));
}
const metaOutputKey = join("out", `stdin.js`);
const metaOutput = result.metafile!.outputs[metaOutputKey];
if (!metaOutput) {
throw new Error(`Could not find metafile`);
}
const outputFileKey = join(config.projectDir, metaOutputKey);
const outputFile = result.outputFiles.find((file) => file.path === outputFileKey);
if (!outputFile) {
throw new Error(
`Could not find output file for entry point ${metaOutput.entryPoint}`
);
}
const sourceMapFileKey = join(config.projectDir, `${metaOutputKey}.map`);
const sourceMapFile = result.outputFiles.find(
(file) => file.path === sourceMapFileKey
);
if (!sourceMapFile) {
throw new Error(
`Could not find source map file for entry point ${metaOutput.entryPoint}`
);
}
const md5Hasher = createHash("md5");
md5Hasher.update(Buffer.from(outputFile.contents.buffer));
const contentHash = md5Hasher.digest("hex");
if (latestWorkerContentHash === contentHash) {
logger.log(chalk.dim("⎔ No changes detected, skipping build..."));
logger.debug(`No changes detected, skipping build`);
return;
}
// Create a file at join(dir, ".trigger", path) with the fileContents
const fullPath = join(config.projectDir, ".trigger", `${contentHash}.mjs`);
await fs.promises.mkdir(dirname(fullPath), { recursive: true });
await fs.promises.writeFile(fullPath, outputFile.text);
const sourceMapPath = `${fullPath}.map`;
await fs.promises.writeFile(sourceMapPath, sourceMapFile.text);
const backgroundWorker = new BackgroundWorker(fullPath, {
projectDir: config.projectDir,
env: {
TRIGGER_API_URL: apiUrl,
TRIGGER_API_KEY: apiKey,
},
});
await backgroundWorker.initialize();
latestWorkerContentHash = contentHash;
let packageVersion: string | undefined;
const taskResources: Array<TaskResource> = [];
if (!backgroundWorker.tasks) {
throw new Error(`Background Worker started without tasks`);
}
for (const task of backgroundWorker.tasks) {
taskResources.push({
id: task.id,
filePath: task.filePath,
exportName: task.exportName,
});
packageVersion = task.packageVersion;
}
if (!packageVersion) {
throw new Error(`Background Worker started without package version`);
}
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
localOnly: true,
metadata: {
packageVersion,
// This is a hack to get around the funky node16 typescript module resolution issue
cliPackageVersion: packageJson.version,
tasks: taskResources,
contentHash: contentHash,
},
};
const backgroundWorkerRecord = await environmentClient.createBackgroundWorker(
config.project,
backgroundWorkerBody
);
if (!backgroundWorkerRecord.success) {
throw new Error(backgroundWorkerRecord.error);
}
backgroundWorker.metadata = backgroundWorkerRecord.data;
if (firstBuild) {
logger.log(
chalk.green(
`Background worker started (${backgroundWorkerRecord.data.version})`
)
);
} else {
logger.log(
chalk.dim(`Background worker rebuilt (${backgroundWorkerRecord.data.version})`)
);
}
firstBuild = false;
await backgroundWorkerCoordinator.registerWorker(
backgroundWorkerRecord.data,
backgroundWorker
);
});
},
},
],
});
await ctx.watch();
}
const throttle = pThrottle({
limit: 2,
interval: 1000,
});
const throttledRebuild = throttle(runBuild);
const taskFileWatcher = watch(
config.triggerDirectories.map((triggerDir) => `${triggerDir}/*.ts`),
{
ignoreInitial: true,
}
);
taskFileWatcher.on("add", async (path) => {
throttledRebuild().catch((error) => {
logger.error(error);
});
});
taskFileWatcher.on("unlink", async (path) => {
throttledRebuild().catch((error) => {
logger.error(error);
});
});
throttledRebuild().catch((error) => {
logger.error(error);
});
return () => {
logger.debug(`Shutting down dev session for ${config.project}`);
taskFileWatcher.close();
websocket?.close();
backgroundWorkerCoordinator.close();
ctx?.dispose().catch((error) => {
console.error(error);
});
};
}, [config, apiUrl, apiKey, environmentClient]);
}
function DevUI(props: DevProps) {
return (
<Suspense>
<DevUIImp {...props} />
</Suspense>
);
}
function DevUIImp(props: DevProps) {
const dev = useDev(props);
return (
<>
<HotKeys />
</>
);
}
function useHotkeys() {
const { exit } = useApp();
useInput(async (input, key) => {
if (key.return) {
console.log("");
return;
}
switch (input.toLowerCase()) {
// clear console
case "c":
console.clear();
// This console.log causes Ink to re-render the `DevSession` component.
// Couldn't find a better way to tell it to do so...
console.log();
break;
// open browser
case "b": {
break;
}
// toggle inspector
// case "d": {
// if (inspect) {
// await openInspector(inspectorPort, props.worker);
// }
// break;
// }
// shut down
case "q":
case "x":
exit();
break;
default:
// nothing?
break;
}
});
}
function HotKeys() {
useHotkeys();
return (
<Box borderStyle="round" paddingLeft={1} paddingRight={1}>
<Text bold={true}>[b]</Text>
<Text> open a browser, </Text>
<Text bold={true}>[c]</Text>
<Text> clear console, </Text>
<Text bold={true}>[x]</Text>
<Text> to exit</Text>
</Box>
);
}
function WebsocketFactory(apiKey: string) {
return class extends wsWebSocket {
constructor(address: string | URL, options?: ClientOptions | ClientRequestArgs) {
super(address, { ...(options ?? {}), headers: { Authorization: `Bearer ${apiKey}` } });
}
};
}
function createTaskFileImports(taskFiles: TaskFile[]) {
return taskFiles
.map(
(taskFile) =>
`import * as ${taskFile.importName} from "./${taskFile.importPath}"; TaskFileImports["${
taskFile.importName
}"] = ${taskFile.importName}; TaskFiles["${taskFile.importName}"] = ${JSON.stringify(
taskFile
)};`
)
.join("\n");
}
// Find all the top-level .js or .ts files in the trigger directories
async function gatherTaskFiles(config: ResolvedConfig): Promise<Array<TaskFile>> {
const taskFiles: Array<TaskFile> = [];
for (const triggerDir of config.triggerDirectories) {
const files = await fs.promises.readdir(triggerDir, { withFileTypes: true });
for (const file of files) {
if (!file.isFile()) continue;
if (!file.name.endsWith(".js") && !file.name.endsWith(".ts")) continue;
const fullPath = join(triggerDir, file.name);
const filePath = relative(config.projectDir, fullPath);
const importPath = filePath.replace(/\.(js|ts)$/, "");
const importName = importPath.replace(/\//g, "_");
taskFiles.push({ triggerDir, importPath, importName, filePath });
}
}
return taskFiles;
}
async function getConfigPath(dir: string): Promise<string> {
const path = await findUp(CONFIG_FILES, { cwd: dir });
if (!path) {
throw new Error("No config file found.");
}
return path;
}
async function readConfig(path: string): Promise<ResolvedConfig> {
try {
// import the config file
const userConfigModule = await import(`${pathToFileURL(path).href}?_ts=${Date.now()}`);
const rawConfig = await normalizeConfig(userConfigModule ? userConfigModule.default : {});
const config = ConfigSchema.parse(rawConfig);
return resolveConfig(path, config);
} catch (error) {
console.error(`Failed to load config file at ${path}`);
throw error;
}
}
async function resolveConfig(path: string, config: Config): Promise<ResolvedConfig> {
if (!config.triggerDirectories) {
config.triggerDirectories = await findTriggerDirectories(path);
}
config.triggerDirectories = resolveTriggerDirectories(config.triggerDirectories);
if (!config.triggerUrl) {
config.triggerUrl = CLOUD_API_URL;
}
if (!config.projectDir) {
config.projectDir = dirname(path);
}
return config as ResolvedConfig;
}
async function normalizeConfig(config: any): Promise<any> {
if (typeof config === "function") {
config = config();
}
return await config;
}
function resolveTriggerDirectories(dirs: string[]): string[] {
return dirs.map((dir) => resolve(dir));
}
const IGNORED_DIRS = ["node_modules", ".git", "dist", "build"];
async function findTriggerDirectories(filePath: string): Promise<string[]> {
const dirPath = dirname(filePath);
return getTriggerDirectories(dirPath);
}
async function getTriggerDirectories(dirPath: string): Promise<string[]> {
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const triggerDirectories: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory() || IGNORED_DIRS.includes(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
if (entry.name === "trigger") {
triggerDirectories.push(fullPath);
}
triggerDirectories.push(...(await getTriggerDirectories(fullPath)));
}
return triggerDirectories;
}
+8 -8
View File
@@ -1,12 +1,12 @@
import { intro, log, outro, select, spinner } from "@clack/prompts";
import open from "open";
import pRetry, { AbortError } from "p-retry";
import { ApiClient } from "../apiClient";
import { ApiUrlOptionsSchema } from "../cli";
import { chalkLink } from "../utilities/colors";
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles";
import { logger } from "../utilities/logger";
import { whoAmI } from "./whoami";
import { ApiClient } from "../apiClient.js";
import { ApiUrlOptionsSchema } from "../cli/index.js";
import { chalkLink } from "../utilities/colors.js";
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles.js";
import { logger } from "../utilities/logger.js";
import { whoAmI } from "./whoami.js";
export async function loginCommand(options: any) {
const result = ApiUrlOptionsSchema.safeParse(options);
@@ -35,7 +35,7 @@ export async function login(apiUrl: string): Promise<LoginResult> {
const existingAccessToken = readAuthConfigFile()?.accessToken;
if (existingAccessToken) {
const whoAmiI = await whoAmI(apiUrl);
const whoAmiI = await whoAmI();
const continueOption = await select({
message: "You are already logged in.",
@@ -98,7 +98,7 @@ export async function login(apiUrl: string): Promise<LoginResult> {
getPersonalAccessTokenSpinner.stop(`Logged in with token ${indexResult.obfuscatedToken}`);
writeAuthConfigFile({ accessToken: indexResult.token });
writeAuthConfigFile({ accessToken: indexResult.token, apiUrl });
outro("Logged in successfully");
+15
View File
@@ -0,0 +1,15 @@
import { readAuthConfigFile, writeAuthConfigFile } from "../utilities/configFiles.js";
import { logger } from "../utilities/logger.js";
export async function logoutCommand(options: any) {
const config = readAuthConfigFile();
if (!config?.accessToken) {
logger.info("You are already logged out");
return;
}
writeAuthConfigFile({ ...config, accessToken: undefined, apiUrl: undefined });
logger.info("Logged out");
}
+5 -6
View File
@@ -1,11 +1,10 @@
import { spinner, confirm } from "@clack/prompts";
import { confirm, spinner } from "@clack/prompts";
import { RunOptions, run } from "npm-check-updates";
import path from "path";
import { run, RunOptions } from "npm-check-updates";
import { installDependencies } from "../utilities/installDependencies";
import { readJSONFileSync, writeJSONFile } from "../utilities/fileSystem.js";
import { logger } from "../utilities/logger.js";
import { z } from "zod";
import { chalkError, chalkSuccess } from "../utilities/colors";
import { chalkError, chalkSuccess } from "../utilities/colors.js";
import { readJSONFileSync, writeJSONFile } from "../utilities/fileSystem.js";
import { installDependencies } from "../utilities/installDependencies.js";
export const UpdateCommandOptionsSchema = z.object({
to: z.string().optional(),
+43 -35
View File
@@ -1,10 +1,12 @@
import { note, spinner } from "@clack/prompts";
import { ApiUrlOptionsSchema } from "../cli";
import { logger } from "../utilities/logger";
import { resolvePath } from "../utilities/parseNameAndPath";
import { readAuthConfigFile } from "../utilities/configFiles";
import { login } from "./login";
import { ApiClient } from "../apiClient";
import { ApiClient } from "../apiClient.js";
import { chalkLink } from "../utilities/colors.js";
import { logger } from "../utilities/logger.js";
import { isLoggedIn } from "../utilities/session.js";
import { Command } from "commander";
import { printInitialBanner } from "../utilities/initialBanner.js";
import { CommonCommandOptions } from "../cli/common.js";
import { z } from "zod";
type WhoAmIResult =
| {
@@ -19,46 +21,50 @@ type WhoAmIResult =
error: string;
};
export async function whoamiCommand(options: any): Promise<WhoAmIResult> {
const result = ApiUrlOptionsSchema.safeParse(options);
if (!result.success) {
logger.error(result.error.message);
return {
success: false,
error: result.error.message,
};
}
const WhoamiCommandOptions = CommonCommandOptions;
return whoAmI(result.data.apiUrl);
type WhoamiCommandOptions = z.infer<typeof WhoamiCommandOptions>;
export function configureWhoamiCommand(program: Command) {
program
.command("whoami")
.description("display the current logged in user and project details")
.option(
"-l, --log-level <level>",
"The log level to use (debug, info, log, warn, error, none)",
"log"
)
.action(async (options) => {
try {
await printInitialBanner();
await whoAmI(WhoamiCommandOptions.parse(options));
} catch (e) {
throw e;
}
});
}
export async function whoAmI(apiUrl: string): Promise<WhoAmIResult> {
export async function whoAmI(options?: WhoamiCommandOptions): Promise<WhoAmIResult> {
if (options?.logLevel) {
logger.loggerLevel = options?.logLevel;
}
const loadingSpinner = spinner();
loadingSpinner.start("Checking your account details");
if (!readAuthConfigFile()?.accessToken) {
loadingSpinner.stop("You must login.");
const loginResult = await login(apiUrl);
if (!loginResult.success) {
logger.error(loginResult.error);
return {
success: false,
error: loginResult.error,
};
}
}
const authentication = await isLoggedIn();
if (!authentication.ok) {
loadingSpinner.stop("You must login first. Use `trigger.dev login` to login.");
const accessToken = readAuthConfigFile()?.accessToken;
if (!accessToken) {
logger.error("No access token after login… this should never happen");
return {
success: false,
error: "No access token after login… this should never happen",
error: authentication.error,
};
}
const apiClient = new ApiClient(apiUrl);
const userData = await apiClient.whoAmI({ accessToken });
const apiClient = new ApiClient(authentication.config.apiUrl, authentication.config.accessToken);
const userData = await apiClient.whoAmI();
if (!userData.success) {
loadingSpinner.stop("Error getting your account details");
@@ -73,7 +79,9 @@ export async function whoAmI(apiUrl: string): Promise<WhoAmIResult> {
note(
`User ID: ${userData.data.userId}
Email: ${userData.data.email}`,
Email: ${userData.data.email}
URL: ${chalkLink(authentication.config.apiUrl)}
`,
"Account details"
);
+448
View File
@@ -0,0 +1,448 @@
import {
BackgroundWorkerRecord,
BackgroundWorkerServerMessages,
CreateBackgroundWorkerResponse,
TaskMetadataWithFilePath,
TaskRunBuiltInError,
TaskRunError,
TaskRunExecution,
TaskRunExecutionPayload,
TaskRunExecutionResult,
ZodMessageHandler,
ZodMessageSender,
childToWorkerMessages,
workerToChildMessages,
} from "@trigger.dev/core/v3";
import chalk from "chalk";
import { Evt } from "evt";
import { fork } from "node:child_process";
import { readFileSync } from "node:fs";
import nodePath from "node:path";
import { SourceMapConsumer, type RawSourceMap } from "source-map";
import terminalLink from "terminal-link";
import { logger } from "../utilities/logger.js";
export type CurrentWorkers = BackgroundWorkerCoordinator["currentWorkers"];
export class BackgroundWorkerCoordinator {
public onTaskCompleted: Evt<{
backgroundWorkerId: string;
completion: TaskRunExecutionResult;
worker: BackgroundWorker;
execution: TaskRunExecution;
}> = new Evt();
public onWorkerRegistered: Evt<{
worker: BackgroundWorker;
id: string;
record: CreateBackgroundWorkerResponse;
}> = new Evt();
public onWorkerDeprecated: Evt<{ worker: BackgroundWorker; id: string }> = new Evt();
private _backgroundWorkers: Map<string, BackgroundWorker> = new Map();
private _records: Map<string, CreateBackgroundWorkerResponse> = new Map();
constructor(private baseURL: string) {
this.onTaskCompleted.attach(async ({ completion, execution }) => {
await this.#notifyWorkersOfTaskCompletion(completion, execution);
});
}
async #notifyWorkersOfTaskCompletion(
completion: TaskRunExecutionResult,
execution: TaskRunExecution
) {
for (const worker of this._backgroundWorkers.values()) {
await worker.handleTaskRunCompletion(completion, execution);
}
}
get currentWorkers() {
return Array.from(this._backgroundWorkers.entries()).map(([id, worker]) => ({
id,
worker,
record: this._records.get(id)!,
}));
}
async registerWorker(record: CreateBackgroundWorkerResponse, worker: BackgroundWorker) {
for (const [workerId, existingWorker] of this._backgroundWorkers.entries()) {
if (workerId === record.id) {
continue;
}
this.onWorkerDeprecated.post({ worker: existingWorker, id: workerId });
}
this._backgroundWorkers.set(record.id, worker);
this._records.set(record.id, record);
this.onWorkerRegistered.post({ worker, id: record.id, record });
}
close() {
for (const worker of this._backgroundWorkers.values()) {
worker.close();
}
this._backgroundWorkers.clear();
this._records.clear();
}
async handleMessage(id: string, message: BackgroundWorkerServerMessages) {
switch (message.type) {
case "EXECUTE_RUNS": {
await Promise.all(message.payloads.map((payload) => this.#executeTaskRun(id, payload)));
}
}
}
async #executeTaskRun(id: string, payload: TaskRunExecutionPayload) {
const worker = this._backgroundWorkers.get(id);
if (!worker) {
logger.error(`Could not find worker ${id}`);
return;
}
const record = this._records.get(id);
if (!record) {
logger.error(`Could not find worker record ${id}`);
return;
}
const { execution } = payload;
const link = chalk.bgBlueBright(
terminalLink("view logs", `${this.baseURL}/runs/${execution.run.id}`)
);
const workerPrefix = chalk.green(`[worker:${record.version}]`);
const taskPrefix = chalk.yellow(`[task:${execution.task.id}]`);
const runId = chalk.blue(execution.run.id);
const attempt = chalk.blue(`.${execution.attempt.number}`);
logger.log(`${workerPrefix}${taskPrefix} ${runId}${attempt} ${link}`);
const now = performance.now();
const completion = await worker.executeTaskRun(payload);
const elapsed = performance.now() - now;
const resultText = !completion.ok ? chalk.red("error") : chalk.green("success");
const errorText = !completion.ok
? `\n\n\t${chalk.bgRed("Error")} ${this.#formatErrorLog(completion.error)}`
: "";
const elapsedText = chalk.dim(`(${elapsed.toFixed(2)}ms)`);
logger.log(
`${workerPrefix}${taskPrefix} ${runId}${attempt} ${resultText} ${elapsedText} ${link}${errorText}`
);
this.onTaskCompleted.post({ completion, execution, worker, backgroundWorkerId: id });
}
#formatErrorLog(error: TaskRunError) {
switch (error.type) {
case "INTERNAL_ERROR": {
return `Internal error: ${error.code}`;
}
case "STRING_ERROR": {
return error.raw;
}
case "CUSTOM_ERROR": {
return error.raw;
}
case "BUILT_IN_ERROR": {
return `${error.name === "Error" ? "" : `(${error.name})`} ${
error.message
}\n${error.stackTrace
.split("\n")
.map((line) => `\t ${line}`)
.join("\n")}\n`;
}
}
}
}
export type BackgroundWorkerParams = {
env: Record<string, string>;
projectDir: string;
};
export class BackgroundWorker {
private _rawSourceMap: RawSourceMap;
private _initialized: boolean = false;
private _handler = new ZodMessageHandler({
schema: childToWorkerMessages,
});
private _onTaskCompleted: Evt<{
completion: TaskRunExecutionResult;
execution: TaskRunExecution;
}> = new Evt();
private _onClose: Evt<void> = new Evt();
public tasks: Array<TaskMetadataWithFilePath> = [];
public metadata: BackgroundWorkerRecord | undefined;
_taskExecutions: Map<
string,
{ resolve: (value: TaskRunExecutionResult) => void; reject: (err?: any) => void }
> = new Map();
constructor(
public path: string,
private params: BackgroundWorkerParams
) {
this._rawSourceMap = JSON.parse(readFileSync(`${path}.map`, "utf-8"));
}
close() {
this._onClose.post();
}
async initialize() {
if (this._initialized) {
throw new Error("Worker already initialized");
}
let resolved = false;
this.tasks = await new Promise<Array<TaskMetadataWithFilePath>>((resolve, reject) => {
const child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
env: {
...this.params.env,
},
});
// Set a timeout to kill the child process if it doesn't respond
const timeout = setTimeout(() => {
if (resolved) {
return;
}
resolved = true;
child.kill();
reject(new Error("Worker timed out"));
}, 1000);
child.on("message", async (msg: any) => {
const message = this._handler.parseMessage(msg);
if (message.type === "TASKS_READY" && !resolved) {
clearTimeout(timeout);
resolved = true;
resolve(message.payload.tasks);
child.kill();
}
});
child.stdout?.on("data", (data) => {
logger.log(data.toString());
});
child.stderr?.on("data", (data) => {
logger.error(data.toString());
});
child.on("exit", (code) => {
if (!resolved) {
clearTimeout(timeout);
resolved = true;
reject(new Error(`Worker exited with code ${code}`));
}
});
});
this._initialized = true;
}
async handleTaskRunCompletion(completion: TaskRunExecutionResult, execution: TaskRunExecution) {
this._onTaskCompleted.post({ completion, execution });
}
// We need to fork the process before we can execute any tasks
async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> {
const metadata = this.metadata;
if (!metadata) {
throw new Error("Worker not registered");
}
const { execution, traceContext } = payload;
const child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
env: {
...this.params.env,
},
});
const sender = new ZodMessageSender({
schema: workerToChildMessages,
sender: async (message) => {
if (!child.connected) {
return;
}
child.send(message);
},
});
const ctx = Evt.newCtx();
// This will notify this task of the completion of any other tasks
this._onTaskCompleted.attach(ctx, async (taskCompletion) => {
if (execution.attempt.id === taskCompletion.execution.attempt.id) {
return;
}
await sender.send("TASK_RUN_COMPLETED", taskCompletion);
});
this._onClose.attachOnce(ctx, () => {
child.kill();
});
let resolved = false;
let resolver: (value: TaskRunExecutionResult) => void;
let rejecter: (err?: any) => void;
const promise = new Promise<TaskRunExecutionResult>((resolve, reject) => {
resolver = resolve;
rejecter = reject;
});
child.on("message", async (msg: any) => {
const message = this._handler.parseMessage(msg);
if (message.type === "TASK_RUN_COMPLETED") {
resolved = true;
resolver(message.payload.result);
this._onTaskCompleted.detach(ctx);
this._onClose.detach(ctx);
await sender.send("CLEANUP", { flush: true });
} else if (message.type === "READY_TO_DISPOSE") {
if (!child.killed) {
child.kill();
}
}
});
child.on("exit", (code) => {
if (!resolved) {
resolved = true;
this._onTaskCompleted.detach(ctx);
this._onClose.detach(ctx);
rejecter(new Error(`Worker exited with code ${code}`));
}
});
child.stdout?.on("data", (data) => {
logger.log(
`[${metadata.version}][${execution.run.id}.${execution.attempt.number}] ${data.toString()}`
);
});
await sender.send("EXECUTE_TASK_RUN", { execution, traceContext, metadata });
const result = await promise;
if (result.ok) {
return result;
}
const error = result.error;
if (error.type === "BUILT_IN_ERROR") {
const mappedError = await this.#correctError(error, execution);
return {
...result,
error: mappedError,
};
}
return result;
}
async #correctError(
error: TaskRunBuiltInError,
execution: TaskRunExecution
): Promise<TaskRunBuiltInError> {
return {
...error,
stackTrace: await this.#correctErrorStackTrace(error.stackTrace, execution),
};
}
async #correctErrorStackTrace(stackTrace: string, execution: TaskRunExecution): Promise<string> {
// Split the stack trace into lines
const lines = stackTrace.split("\n");
// Remove the first line
lines.shift();
// Use SourceMapConsumer.with to handle the source map for the entire stack trace
return SourceMapConsumer.with(this._rawSourceMap, null, (consumer) =>
lines
.map((line) => this.#correctStackTraceLine(line, consumer, execution))
.filter(Boolean)
.join("\n")
);
}
#correctStackTraceLine(
line: string,
consumer: SourceMapConsumer,
execution: TaskRunExecution
): string | undefined {
// Split the line into parts
const regex = /at (.*?) \(file:\/\/(\/.*?\.mjs):(\d+):(\d+)\)/;
const match = regex.exec(line);
if (!match) {
return line;
}
const [_, identifier, path, lineNum, colNum] = match;
const originalPosition = consumer.originalPositionFor({
line: Number(lineNum),
column: Number(colNum),
});
if (!originalPosition.source) {
return line;
}
const { source, line: originalLine, column: originalColumn } = originalPosition;
if (this.#shouldFilterLine({ identifier, path: source })) {
return;
}
const sourcePath = path
? nodePath.relative(this.params.projectDir, nodePath.resolve(nodePath.dirname(path), source))
: source;
return `at ${
identifier === "Object.run" ? `${execution.task.exportName}.run` : identifier
} (${sourcePath}:${originalLine}:${originalColumn})`;
}
#shouldFilterLine(line: { identifier?: string; path?: string }): boolean {
const filename = nodePath.basename(line.path ?? "");
if (filename === "__entryPoint.ts") {
return true;
}
if (line.identifier === "async ZodMessageHandler.handleMessage") {
return true;
}
return false;
}
}
+375
View File
@@ -0,0 +1,375 @@
// via https://github.com/maticzav/ink-table
// inlined here because of https://github.com/maticzav/ink-table/issues/258
import React from "react";
import { Box, Text } from "ink";
import { sha1 } from "object-hash";
/* Table */
type Scalar = string | number | boolean | null | undefined;
type ScalarDict = {
[key: string]: Scalar;
};
export type CellProps = React.PropsWithChildren<{ column: number }>;
export type TableProps<T extends ScalarDict> = {
/**
* List of values (rows).
*/
data: T[];
/**
* Columns that we should display in the table.
*/
columns: (keyof T)[];
/**
* Cell padding.
*/
padding: number;
/**
* Header component.
*/
header: (props: React.PropsWithChildren<unknown>) => React.JSX.Element;
/**
* Component used to render a cell in the table.
*/
cell: (props: CellProps) => React.JSX.Element;
/**
* Component used to render the skeleton of the table.
*/
skeleton: (props: React.PropsWithChildren<unknown>) => React.JSX.Element;
};
/* Table */
export default class Table<T extends ScalarDict> extends React.Component<
Pick<TableProps<T>, "data"> & Partial<TableProps<T>>
> {
/* Config */
/**
* Merges provided configuration with defaults.
*/
getConfig(): TableProps<T> {
return {
data: this.props.data,
columns: this.props.columns || this.getDataKeys(),
padding: this.props.padding || 1,
header: this.props.header || Header,
cell: this.props.cell || Cell,
skeleton: this.props.skeleton || Skeleton,
};
}
/**
* Gets all keyes used in data by traversing through the data.
*/
getDataKeys(): (keyof T)[] {
const keys = new Set<keyof T>();
// Collect all the keys.
for (const data of this.props.data) {
for (const key in data) {
keys.add(key);
}
}
return Array.from(keys);
}
/**
* Calculates the width of each column by finding
* the longest value in a cell of a particular column.
*
* Returns a list of column names and their widths.
*/
getColumns(): Column<T>[] {
const { columns, padding } = this.getConfig();
const widths: Column<T>[] = columns.map((key) => {
const header = String(key).length;
/* Get the width of each cell in the column */
const data = this.props.data.map((data) => {
const value = data[key];
if (value == undefined || value == null) return 0;
return String(value).length;
});
const width = Math.max(...data, header) + padding * 2;
/* Construct a cell */
return {
column: key,
width: width,
key: String(key),
};
});
return widths;
}
/**
* Returns a (data) row representing the headings.
*/
getHeadings(): Partial<T> {
const { columns } = this.getConfig();
const headings: Partial<T> = columns.reduce(
(acc, column) => ({ ...acc, [column]: column }),
{}
);
return headings;
}
/* Rendering utilities */
// The top most line in the table.
header = row<T>({
cell: this.getConfig().skeleton,
padding: this.getConfig().padding,
skeleton: {
component: this.getConfig().skeleton,
// chars
line: "─",
left: "┌",
right: "┐",
cross: "┬",
},
});
// The line with column names.
heading = row<T>({
cell: this.getConfig().header,
padding: this.getConfig().padding,
skeleton: {
component: this.getConfig().skeleton,
// chars
line: " ",
left: "│",
right: "│",
cross: "│",
},
});
// The line that separates rows.
separator = row<T>({
cell: this.getConfig().skeleton,
padding: this.getConfig().padding,
skeleton: {
component: this.getConfig().skeleton,
// chars
line: "─",
left: "├",
right: "┤",
cross: "┼",
},
});
// The row with the data.
data = row<T>({
cell: this.getConfig().cell,
padding: this.getConfig().padding,
skeleton: {
component: this.getConfig().skeleton,
// chars
line: " ",
left: "│",
right: "│",
cross: "│",
},
});
// The bottom most line of the table.
footer = row<T>({
cell: this.getConfig().skeleton,
padding: this.getConfig().padding,
skeleton: {
component: this.getConfig().skeleton,
// chars
line: "─",
left: "└",
right: "┘",
cross: "┴",
},
});
/* Render */
override render() {
/* Data */
const columns = this.getColumns();
const headings = this.getHeadings();
/**
* Render the table line by line.
*/
return (
<Box flexDirection="column">
{/* Header */}
{this.header({ key: "header", columns, data: {} })}
{this.heading({ key: "heading", columns, data: headings })}
{/* Data */}
{this.props.data.map((row, index) => {
// Calculate the hash of the row based on its value and position
const key = `row-${sha1(row)}-${index}`;
// Construct a row.
return (
<Box flexDirection="column" key={key}>
{this.separator({ key: `separator-${key}`, columns, data: {} })}
{this.data({ key: `data-${key}`, columns, data: row })}
</Box>
);
})}
{/* Footer */}
{this.footer({ key: "footer", columns, data: {} })}
</Box>
);
}
}
/* Helper components */
type RowConfig = {
/**
* Component used to render cells.
*/
cell: (props: CellProps) => React.JSX.Element;
/**
* Tells the padding of each cell.
*/
padding: number;
/**
* Component used to render skeleton in the row.
*/
skeleton: {
component: (props: React.PropsWithChildren<unknown>) => React.JSX.Element;
/**
* Characters used in skeleton.
* | |
* (left)-(line)-(cross)-(line)-(right)
* | |
*/
left: string;
right: string;
cross: string;
line: string;
};
};
type RowProps<T extends ScalarDict> = {
key: string;
data: Partial<T>;
columns: Column<T>[];
};
type Column<T> = {
key: string;
column: keyof T;
width: number;
};
/**
* Constructs a Row element from the configuration.
*/
function row<T extends ScalarDict>(config: RowConfig): (props: RowProps<T>) => React.JSX.Element {
/* This is a component builder. We return a function. */
const skeleton = config.skeleton;
/* Row */
return (props) => (
<Box flexDirection="row">
{/* Left */}
<skeleton.component>{skeleton.left}</skeleton.component>
{/* Data */}
{...intersperse(
(i) => {
const key = `${props.key}-hseparator-${i}`;
// The horizontal separator.
return <skeleton.component key={key}>{skeleton.cross}</skeleton.component>;
},
// Values.
props.columns.map((column, colI) => {
// content
const value = props.data[column.column];
if (value == undefined || value == null) {
const key = `${props.key}-empty-${column.key}`;
return (
<config.cell key={key} column={colI}>
{skeleton.line.repeat(column.width)}
</config.cell>
);
} else {
const key = `${props.key}-cell-${column.key}`;
// margins
const ml = config.padding;
const mr = column.width - String(value).length - config.padding;
return (
/* prettier-ignore */
<config.cell key={key} column={colI}>
{`${skeleton.line.repeat(ml)}${String(value)}${skeleton.line.repeat(mr)}`}
</config.cell>
);
}
})
)}
{/* Right */}
<skeleton.component>{skeleton.right}</skeleton.component>
</Box>
);
}
/**
* Renders the header of a table.
*/
export function Header(props: React.PropsWithChildren<unknown>) {
return (
<Text bold color="blue">
{props.children}
</Text>
);
}
/**
* Renders a cell in the table.
*/
export function Cell(props: CellProps) {
return <Text>{props.children}</Text>;
}
/**
* Redners the scaffold of the table.
*/
export function Skeleton(props: React.PropsWithChildren<unknown>) {
return <Text bold>{props.children}</Text>;
}
/* Utility functions */
/**
* Intersperses a list of elements with another element.
*/
function intersperse<T, I>(intersperser: (index: number) => I, elements: T[]): (T | I)[] {
// Intersparse by reducing from left.
const interspersed: (T | I)[] = elements.reduce(
(acc, element, index) => {
// Only add element if it's the first one.
if (acc.length === 0) return [element];
// Add the intersparser as well otherwise.
return [...acc, intersperser(index), element];
},
[] as (T | I)[]
);
return interspersed;
}
+2 -2
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env node
import { program } from "./cli/index";
import { logger } from "./utilities/logger";
import { program } from "./cli/index.js";
import { logger } from "./utilities/logger.js";
const main = async () => {
await program.parseAsync();
+1 -1
View File
@@ -1,6 +1,6 @@
import { PostHog } from "posthog-node";
import { nanoid } from "nanoid";
import { getVersion } from "../utilities/getVersion";
import { getVersion } from "../utilities/getVersion.js";
const postHogApiKey = "phc_9aSDbJCaDUMdZdHxxMPTvcj7A9fsl3mCgM1RBPmPsl7";
+5
View File
@@ -0,0 +1,5 @@
import { TaskMetadataWithFilePath } from "@trigger.dev/core/v3";
export type TaskMetadataWithRun = TaskMetadataWithFilePath & {
run: (params: any) => Promise<any>;
};
+15 -17
View File
@@ -1,26 +1,22 @@
import fs, { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import xdgAppPaths from "xdg-app-paths";
import { z } from "zod";
import { isDirectory, pathExists, readJSONFileSync } from "./fileSystem";
import { readJSONFileSync } from "./fileSystem.js";
import { logger } from "./logger.js";
function getGlobalConfigFolderPath() {
const configDir = xdgAppPaths(".trigger").config();
const legacyConfigDir = path.join(os.homedir(), ".trigger"); // Legacy config in user's home directory
// Check for the .trigger directory in root, if it is not there then use the XDG compliant path.
if (isDirectory(legacyConfigDir)) {
return legacyConfigDir;
} else {
return configDir;
}
return configDir;
}
//auth config file
export const UserAuthConfigSchema = z.object({
accessToken: z.string().optional(),
apiUrl: z.string().optional(),
});
export type UserAuthConfig = z.infer<typeof UserAuthConfigSchema>;
function getAuthConfigFilePath() {
@@ -38,12 +34,14 @@ export function writeAuthConfigFile(config: UserAuthConfig) {
}
export function readAuthConfigFile(): UserAuthConfig | undefined {
const authConfigFilePath = getAuthConfigFilePath();
if (!pathExists(authConfigFilePath)) {
return;
}
try {
const authConfigFilePath = getAuthConfigFilePath();
const json = readJSONFileSync(authConfigFilePath);
const parsed = UserAuthConfigSchema.parse(json);
return parsed;
const json = readJSONFileSync(authConfigFilePath);
const parsed = UserAuthConfigSchema.parse(json);
return parsed;
} catch (error) {
logger.debug(`Error reading auth config file: ${error}`);
return undefined;
}
}
+1 -7
View File
@@ -21,13 +21,7 @@ export function isDirectory(configPath: string) {
}
export async function pathExists(path: string): Promise<boolean> {
try {
await fsModule.access(path);
return true;
} catch (err) {
return false;
}
return fsSync.existsSync(path);
}
export async function someFileExists(directory: string, filenames: string[]): Promise<boolean> {
@@ -1,4 +1,4 @@
import { checkApiKeyIsDevServer } from "./getApiKeyType";
import { checkApiKeyIsDevServer } from "./getApiKeyType.js";
describe("Test API keys", () => {
test("dev server succeeds", async () => {
@@ -0,0 +1,69 @@
import { logger } from "./logger.js";
type VariableNames = "TRIGGER_API_URL" | "TRIGGER_API_KEY" | "TRIGGER_LOG_LEVEL";
type DeprecatedNames = "";
/**
* Create a function used to access an environment variable.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
* A warning is shown if the client is using a deprecated version - but only once.
*/
export function getEnvironmentVariableFactory({
variableName,
deprecatedName,
}: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
}): () => string | undefined;
/**
* Create a function used to access an environment variable, with a default value.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
* A warning is shown if the client is using a deprecated version - but only once.
*/
export function getEnvironmentVariableFactory({
variableName,
deprecatedName,
defaultValue,
}: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
defaultValue: () => string;
}): () => string;
/**
* Create a function used to access an environment variable.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
* A warning is shown if the client is using a deprecated version - but only once.
*/
export function getEnvironmentVariableFactory({
variableName,
deprecatedName,
defaultValue,
}: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
defaultValue?: () => string;
}): () => string | undefined {
let hasWarned = false;
return () => {
if (process.env[variableName]) {
return process.env[variableName];
} else if (deprecatedName && process.env[deprecatedName]) {
if (!hasWarned) {
// Only show the warning once.
hasWarned = true;
logger.warn(
`Using "${deprecatedName}" environment variable. This is deprecated. Please use "${variableName}", instead.`
);
}
return process.env[deprecatedName];
} else {
return defaultValue?.();
}
};
}
@@ -1,6 +1,6 @@
import { randomUUID } from "crypto";
import { pathExists } from "./fileSystem";
import { getUserPackageManager } from "./getUserPackageManager";
import { pathExists } from "./fileSystem.js";
import { getUserPackageManager } from "./getUserPackageManager.js";
import * as pathModule from "path";
import { Mock } from "vitest";
@@ -1,5 +1,5 @@
import pathModule from "path";
import { pathExists } from "./fileSystem";
import { pathExists } from "./fileSystem.js";
export type PackageManager = "npm" | "pnpm" | "yarn";
+2 -2
View File
@@ -1,7 +1,7 @@
import { type PackageJson } from "type-fest";
import path from "path";
import { PKG_ROOT } from "../consts";
import { readJSONFileSync } from "./fileSystem";
import { PKG_ROOT } from "../consts.js";
import { readJSONFileSync } from "./fileSystem.js";
export function getVersion() {
const packageJsonPath = path.join(PKG_ROOT, "package.json");
+25 -5
View File
@@ -1,11 +1,12 @@
import { spinner } from "@clack/prompts";
import chalk from "chalk";
import supportsColor from "supports-color";
import type { Result } from "update-check";
import checkForUpdate from "update-check";
import pkg from "../../package.json";
import { chalkGrey, logo } from "./colors";
import { getVersion } from "./getVersion";
import { logger } from "./logger";
import { spinner, intro } from "@clack/prompts";
import { chalkGrey, green, logo } from "./colors.js";
import { getVersion } from "./getVersion.js";
import { logger } from "./logger.js";
export async function printInitialBanner(performUpdateCheck = true) {
const packageVersion = getVersion();
@@ -21,7 +22,7 @@ export async function printInitialBanner(performUpdateCheck = true) {
// Log a slightly more noticeable message if this is a major bump
if (maybeNewVersion !== undefined) {
loadingSpinner.stop(`Update available ${chalk.green(maybeNewVersion)})`);
loadingSpinner.stop(`Update available ${chalk.green(maybeNewVersion)}`);
const currentMajor = parseInt(packageVersion.split(".")[0]!);
const newMajor = parseInt(maybeNewVersion.split(".")[0]!);
if (newMajor > currentMajor) {
@@ -37,6 +38,25 @@ After installation, run Trigger.dev with \`npx trigger.dev\`.`
}
}
export async function printStandloneInitialBanner(performUpdateCheck = true) {
const packageVersion = getVersion();
let text = `\n${logo()} ${chalkGrey(`${packageVersion}`)}`;
if (performUpdateCheck) {
const maybeNewVersion = await updateCheck();
// Log a slightly more noticeable message if this is a major bump
if (maybeNewVersion !== undefined) {
text = `${text} (update available ${chalk.green(maybeNewVersion)})`;
}
}
logger.log(
text + "\n" + (supportsColor.stdout ? chalk.hex(green)("-".repeat(54)) : "-".repeat(54))
);
}
async function doUpdateCheck(): Promise<string | undefined> {
let update: Result | null = null;
try {
@@ -1,8 +1,8 @@
import { spinner, confirm } from "@clack/prompts";
import { getUserPackageManager, type PackageManager } from "./getUserPackageManager";
import { logger } from "./logger";
import { spinner } from "@clack/prompts";
import chalk from "chalk";
import { execa } from "execa";
import { getUserPackageManager, type PackageManager } from "./getUserPackageManager.js";
import { logger } from "./logger.js";
export async function installDependencies(projectDir: string) {
logger.info("Installing dependencies...");
+113 -22
View File
@@ -1,29 +1,69 @@
// This is a copy of the logger utility from the wrangler repo: https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/logger.ts
import { format } from "node:util";
import chalk from "chalk";
import CLITable from "cli-table3";
import { formatMessagesSync } from "esbuild";
import type { Message } from "esbuild";
import { getEnvironmentVariableFactory } from "./getEnvironmentVariableFactory.js";
export const LOGGER_LEVELS = {
none: -1,
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
} as const;
export type Logger = typeof logger;
export type LoggerLevel = keyof typeof LOGGER_LEVELS;
/** A map from LOGGER_LEVEL to the error `kind` needed by `formatMessagesSync()`. */
const LOGGER_LEVEL_FORMAT_TYPE_MAP = {
error: "error",
warn: "warning",
info: undefined,
log: undefined,
debug: undefined,
} as const;
const getLogLevelFromEnv = getEnvironmentVariableFactory({
variableName: "TRIGGER_LOG_LEVEL",
});
function getLoggerLevel(): LoggerLevel {
const fromEnv = getLogLevelFromEnv()?.toLowerCase();
if (fromEnv !== undefined) {
if (fromEnv in LOGGER_LEVELS) return fromEnv as LoggerLevel;
const expected = Object.keys(LOGGER_LEVELS)
.map((level) => `"${level}"`)
.join(" | ");
console.warn(
`Unrecognised WRANGLER_LOG value ${JSON.stringify(
fromEnv
)}, expected ${expected}, defaulting to "log"...`
);
}
return "log";
}
export type TableRow<Keys extends string> = Record<Keys, string>;
export const logger = {
log(...args: unknown[]) {
console.log(...args);
},
error(...args: unknown[]) {
console.log(chalk.red(...args));
},
warn(...args: unknown[]) {
console.log(chalk.yellow(...args));
},
info(...args: unknown[]) {
console.log(chalk.cyan(...args));
},
success(...args: unknown[]) {
console.log(chalk.green(...args));
},
export class Logger {
constructor() {}
loggerLevel = getLoggerLevel();
columns = process.stdout.columns;
debug = (...args: unknown[]) => this.doLog("debug", args);
debugWithSanitization = (label: string, ...args: unknown[]) => {
this.doLog("debug", [label, ...args]);
};
info = (...args: unknown[]) => this.doLog("info", args);
log = (...args: unknown[]) => this.doLog("log", args);
warn = (...args: unknown[]) => this.doLog("warn", args);
error = (...args: unknown[]) => this.doLog("error", args);
table<Keys extends string>(data: TableRow<Keys>[]) {
if (data.length === 0) return console.log("No data");
const keys: Keys[] = data.length === 0 ? [] : (Object.keys(data[0] as {}) as Keys[]);
const keys: Keys[] = data.length === 0 ? [] : (Object.keys(data[0]!) as Keys[]);
const t = new CLITable({
head: keys,
style: {
@@ -32,6 +72,57 @@ export const logger = {
},
});
t.push(...data.map((row) => keys.map((k) => row[k])));
return this.log(t.toString());
},
};
return this.doLog("log", [t.toString()]);
}
private doLog(messageLevel: Exclude<LoggerLevel, "none">, args: unknown[]) {
const message = this.formatMessage(messageLevel, format(...args));
// only send logs to the terminal if their level is at least the configured log-level
if (LOGGER_LEVELS[this.loggerLevel] >= LOGGER_LEVELS[messageLevel]) {
console[messageLevel](message);
}
}
private formatMessage(level: Exclude<LoggerLevel, "none">, message: string): string {
const kind = LOGGER_LEVEL_FORMAT_TYPE_MAP[level];
if (kind) {
// Format the message using the esbuild formatter.
// The first line of the message is the main `text`,
// subsequent lines are put into the `notes`.
const [firstLine, ...otherLines] = message.split("\n");
const notes = otherLines.length > 0 ? otherLines.map((text) => ({ text })) : undefined;
return formatMessagesSync([{ text: firstLine, notes }], {
color: true,
kind,
terminalWidth: this.columns,
})[0]!;
} else {
return message;
}
}
}
/**
* A drop-in replacement for `console` for outputting logging messages.
*
* Errors and Warnings will get additional formatting to highlight them to the user.
* You can also set a `logger.loggerLevel` value to one of "debug", "log", "warn" or "error",
* to filter out logging messages.
*/
export const logger = new Logger();
export function logBuildWarnings(warnings: Message[]) {
const logs = formatMessagesSync(warnings, { kind: "warning", color: true });
for (const log of logs) console.warn(log);
}
/**
* Logs all errors/warnings associated with an esbuild BuildFailure in the same
* style esbuild would.
*/
export function logBuildFailure(errors: Message[], warnings: Message[]) {
const logs = formatMessagesSync(errors, { kind: "error", color: true });
for (const log of logs) console.error(log);
logBuildWarnings(warnings);
}
+28
View File
@@ -0,0 +1,28 @@
import { ApiClient } from "../apiClient.js";
import { readAuthConfigFile } from "./configFiles.js";
export async function isLoggedIn() {
const config = readAuthConfigFile();
if (!config?.accessToken || !config?.apiUrl) {
return { ok: false as const, error: "You must login first" };
}
const apiClient = new ApiClient(config.apiUrl, config.accessToken);
const userData = await apiClient.whoAmI();
if (!userData.success) {
return {
ok: false as const,
error: userData.error,
};
}
return {
ok: true as const,
config: {
apiUrl: config.apiUrl,
accessToken: config.accessToken,
},
};
}
+234
View File
@@ -0,0 +1,234 @@
import { TracingSDK, HttpInstrumentation, FetchInstrumentation } from "@trigger.dev/core/v3/otel";
// IMPORTANT: this needs to be the first import to work properly
const tracingSDK = new TracingSDK({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
resource: new Resource({
[SemanticInternalAttributes.CLI_VERSION]: packageJson.version,
}),
instrumentations: [new HttpInstrumentation(), new FetchInstrumentation()],
});
const otelTracer = tracingSDK.getTracer("trigger-dev-worker", packageJson.version);
const otelLogger = tracingSDK.getLogger("trigger-dev-worker", packageJson.version);
import { SpanKind } from "@opentelemetry/api";
import {
BackgroundWorkerRecord,
ConsoleInterceptor,
DevRuntimeManager,
OtelTaskLogger,
SemanticInternalAttributes,
TaskMetadataWithFilePath,
TaskRunContext,
TaskRunErrorCodes,
TaskRunExecution,
TriggerTracer,
ZodMessageHandler,
ZodMessageSender,
childToWorkerMessages,
logger,
parseError,
runtime,
taskContextManager,
workerToChildMessages,
} from "@trigger.dev/core/v3";
import * as packageJson from "../package.json";
import { Resource } from "@opentelemetry/resources";
import { flattenAttributes } from "@trigger.dev/core/v3";
import { TaskMetadataWithRun } from "./types.js";
const tracer = new TriggerTracer({ tracer: otelTracer, logger: otelLogger });
const consoleInterceptor = new ConsoleInterceptor(otelLogger);
const devRuntimeManager = new DevRuntimeManager({
tracer,
});
runtime.setGlobalRuntimeManager(devRuntimeManager);
const otelTaskLogger = new OtelTaskLogger({
logger: otelLogger,
level: "info",
});
logger.setGlobalTaskLogger(otelTaskLogger);
type TaskFileImport = Record<string, unknown>;
const TaskFileImports: Record<string, TaskFileImport> = {};
const TaskFiles: Record<string, string> = {};
__TASKS__;
declare const __TASKS__: Record<string, string>;
class TaskExecutor {
constructor(public task: TaskMetadataWithRun) {}
async execute(
execution: TaskRunExecution,
worker: BackgroundWorkerRecord,
traceContext: Record<string, unknown>
) {
const parsedPayload = JSON.parse(execution.run.payload);
const ctx = TaskRunContext.parse(execution);
const output = await taskContextManager.runWith(
{
ctx,
payload: parsedPayload,
worker,
},
async () => {
tracingSDK.asyncResourceDetector.resolveWithAttributes({
...taskContextManager.attributes,
[SemanticInternalAttributes.SDK_VERSION]: this.task.packageVersion,
[SemanticInternalAttributes.SDK_LANGUAGE]: "typescript",
});
return await tracer.startActiveSpan(
`Attempt #${execution.attempt.number}`,
async (span) => {
return await consoleInterceptor.intercept(console, async () => {
const output = await this.task.run({
payload: parsedPayload,
ctx: TaskRunContext.parse(execution),
});
span.setAttributes(flattenAttributes(output, SemanticInternalAttributes.OUTPUT));
return output;
});
},
{
kind: SpanKind.CONSUMER,
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "task",
},
},
tracer.extractContext(traceContext)
);
}
);
return { output: JSON.stringify(output), outputType: "application/json" };
}
}
function getTasks(): Array<TaskMetadataWithRun> {
const result: Array<TaskMetadataWithRun> = [];
for (const [importName, taskFile] of Object.entries(TaskFiles)) {
const fileImports = TaskFileImports[importName];
for (const [exportName, task] of Object.entries(fileImports ?? {})) {
if ((task as any).__trigger) {
result.push({
id: (task as any).__trigger.id,
exportName,
packageVersion: (task as any).__trigger.packageVersion,
filePath: (taskFile as any).filePath,
run: (task as any).__trigger.run,
});
}
}
}
return result;
}
function getTaskMetadata(): Array<TaskMetadataWithFilePath> {
const result = getTasks();
// Remove the run function from the metadata
return result.map((task) => {
const { run, ...metadata } = task;
return metadata;
});
}
const sender = new ZodMessageSender({
schema: childToWorkerMessages,
sender: async (message) => {
process.send?.(message);
},
});
const tasks = getTasks();
const taskExecutors: Map<string, TaskExecutor> = new Map();
for (const task of tasks) {
taskExecutors.set(task.id, new TaskExecutor(task));
}
const handler = new ZodMessageHandler({
schema: workerToChildMessages,
messages: {
EXECUTE_TASK_RUN: async ({ execution, traceContext, metadata }) => {
process.title = `trigger-dev-worker: ${execution.task.id} ${execution.attempt.id}`;
const executor = taskExecutors.get(execution.task.id);
if (!executor) {
console.error(`Could not find executor for task ${execution.task.id}`);
await sender.send("TASK_RUN_COMPLETED", {
result: {
ok: false,
id: execution.attempt.id,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.COULD_NOT_FIND_EXECUTOR,
},
},
});
return;
}
try {
const result = await executor.execute(execution, metadata, traceContext);
return sender.send("TASK_RUN_COMPLETED", {
result: {
id: execution.attempt.id,
ok: true,
...result,
},
});
} catch (e) {
return sender.send("TASK_RUN_COMPLETED", {
result: {
id: execution.attempt.id,
ok: false,
error: parseError(e),
},
});
}
},
TASK_RUN_COMPLETED: async ({ completion, execution }) => {
devRuntimeManager.resumeTask(completion, execution);
},
CLEANUP: async ({ flush }) => {
if (flush) {
await tracingSDK.flushOtel();
}
// Now we need to exit the process
await sender.send("READY_TO_DISPOSE", undefined);
},
},
});
process.on("message", async (msg: any) => {
await handler.handleMessage(msg);
});
sender.send("TASKS_READY", { tasks: getTaskMetadata() }).catch((err) => {
console.error("Failed to send TASKS_READY message", err);
});
process.title = "trigger-dev-worker";
+23 -47
View File
@@ -1,55 +1,31 @@
// See: https://www.totaltypescript.com/tsconfig-cheat-sheet
{
"include": ["src/globals.d.ts", "./src/**/*.ts", "tsup.config.ts", "./test/**/*.ts"],
"include": ["./src/**/*.ts", "./src/**/*.tsx"],
"compilerOptions": {
/* LANGUAGE COMPILATION OPTIONS */
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"module": "ESNext",
"moduleResolution": "node",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true,
/* EMIT RULES */
"outDir": "./dist",
"noEmit": true, // TSUP takes care of emitting js for us, in a MUCH faster way
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": true,
/* TYPE CHECKING RULES */
"strict": true,
// "noImplicitAny": true, // Included in "Strict"
// "noImplicitThis": true, // Included in "Strict"
// "strictBindCallApply": true, // Included in "Strict"
// "strictFunctionTypes": true, // Included in "Strict"
// "strictNullChecks": true, // Included in "Strict"
// "strictPropertyInitialization": true, // Included in "Strict"
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"useUnknownInCatchVariables": true,
// "noUncheckedIndexedAccess": true, // TLDR - Checking an indexed value (array[0]) now forces type <T | undefined> as there is no confirmation that index exists
// THE BELOW ARE EXTRA STRICT OPTIONS THAT SHOULD ONLY BY CONSIDERED IN VERY SAFE PROJECTS
// "exactOptionalPropertyTypes": true, // TLDR - Setting to undefined is not the same as a property not being defined at all
// "noPropertyAccessFromIndexSignature": true, // TLDR - Use dot notation for objects if youre sure it exists, use ['index'] notaion if unsure
/* OTHER OPTIONS */
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
// "emitDecoratorMetadata": true,
// "experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"useDefineForClassFields": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"types": ["vitest/globals"],
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"strict": true,
"noUncheckedIndexedAccess": true,
/* Building for a monorepo */
"declaration": true,
"composite": false,
"sourceMap": true,
"declarationMap": true,
/* We're building with tsup */
"moduleResolution": "Bundler",
"module": "ESNext",
"noEmit": true,
"lib": ["es2022", "DOM"],
"types": ["node", "vitest/globals"],
"jsx": "react",
"paths": {
"@trigger.dev/core/*": ["../core/src/*"],
"@trigger.dev/core": ["../core/src/index"]
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"]
}
},
"exclude": ["node_modules"]
+5 -4
View File
@@ -3,17 +3,18 @@ import { defineConfig } from "tsup";
const isDev = process.env.npm_lifecycle_event === "dev";
export default defineConfig({
clean: true,
clean: false,
tsconfig: "tsconfig.json",
dts: true,
splitting: false,
entry: ["src/index.ts"],
format: ["esm"],
minify: !isDev,
metafile: !isDev,
minify: false,
metafile: false,
sourcemap: true,
target: "esnext",
outDir: "dist",
onSuccess: isDev ? `node dist/index.js` : "",
//this is required because "xdg-app-paths" uses a dynamic import
banner: {
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
},
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
clean: false,
dts: true,
tsconfig: "tsconfig.json",
splitting: false,
entry: ["src/worker-facade.ts"],
format: ["esm"],
minify: false,
metafile: false,
sourcemap: true,
target: "esnext",
outDir: "dist",
noExternal: ["zod"],
});
+1
View File
@@ -67,6 +67,7 @@
"console-table-printer": "^2.11.2",
"degit": "^2.8.4",
"dotenv": "^16.3.1",
"esbuild": "^0.19.11",
"execa": "^7.0.0",
"gradient-string": "^2.0.2",
"inquirer": "^9.1.4",
+4
View File
@@ -0,0 +1,4 @@
export type TaskMetadata = {
id: string;
exportName: string;
};
+42 -1
View File
@@ -21,8 +21,34 @@
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./v3": {
"import": {
"types": "./dist/v3/index.d.mts",
"default": "./dist/v3/index.mjs"
},
"require": "./dist/v3/index.js",
"types": "./dist/v3/index.d.ts"
},
"./v3/otel": {
"import": {
"types": "./dist/v3/otel/index.d.mts",
"default": "./dist/v3/otel/index.mjs"
},
"require": "./dist/v3/otel/index.js",
"types": "./dist/v3/otel/index.d.ts"
},
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"v3": [
"./dist/v3/index.d.ts"
],
"v3/otel": [
"./dist/v3/otel/index.d.ts"
]
}
},
"sideEffects": false,
"scripts": {
"clean": "rimraf dist",
@@ -32,19 +58,34 @@
"test": "jest"
},
"dependencies": {
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/api-logs": "^0.48.0",
"@opentelemetry/auto-instrumentations-node": "^0.40.3",
"@opentelemetry/exporter-collector": "^0.25.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.48.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.48.0",
"@opentelemetry/instrumentation": "^0.48.0",
"@opentelemetry/instrumentation-fetch": "^0.48.0",
"@opentelemetry/instrumentation-http": "^0.48.0",
"@opentelemetry/resources": "^1.21.0",
"@opentelemetry/sdk-logs": "^0.48.0",
"@opentelemetry/sdk-node": "^0.48.0",
"@opentelemetry/sdk-trace-base": "^1.21.0",
"@opentelemetry/sdk-trace-node": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"ulidx": "^2.2.1",
"zod": "3.22.3",
"zod-error": "1.5.0"
},
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@trigger.dev/tsup": "workspace:*",
"@types/jest": "^29.5.3",
"@types/node": "16",
"jest": "^29.6.2",
"rimraf": "^3.0.2",
"ts-jest": "^29.1.1",
"tsup": "^8.0.1",
"@trigger.dev/tsup": "workspace:*",
"typescript": "^5.3.0"
},
"engines": {
-1
View File
@@ -6,7 +6,6 @@ export * from "./replacements";
export * from "./searchParams";
export * from "./eventFilterMatches";
export * from "./requestFilterMatches";
export * from "./v3";
export const API_VERSIONS = {
LAZY_LOADED_CACHED_TASKS: "2023-09-29",
+36
View File
@@ -0,0 +1,36 @@
import { context, propagation } from "@opentelemetry/api";
import { zodfetch } from "../../zodfetch";
import { TriggerTaskRequestBody, TriggerTaskResponse } from "../schemas/api";
import { taskContextManager } from "../tasks/taskContextManager";
/**
* Trigger.dev v3 API client
*/
export class ApiClient {
constructor(
private readonly baseUrl: string,
private readonly accessToken: string
) {}
triggerTask(taskId: string, options: TriggerTaskRequestBody) {
return zodfetch(TriggerTaskResponse, `${this.baseUrl}/api/v1/tasks/${taskId}/trigger`, {
method: "POST",
headers: this.#getHeaders(),
body: JSON.stringify(options),
});
}
#getHeaders() {
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${this.accessToken}`,
};
// Only inject the context if we are inside a task
if (taskContextManager.isInsideTask) {
propagation.inject(context.active(), headers);
}
return headers;
}
}
+118
View File
@@ -0,0 +1,118 @@
import type * as logsAPI from "@opentelemetry/api-logs";
import { SeverityNumber } from "@opentelemetry/api-logs";
import util from "node:util";
import { flattenAttributes } from "./utils/flattenAttributes";
export class ConsoleInterceptor {
constructor(private readonly logger: logsAPI.Logger) {}
// Intercept the console and send logs to the OpenTelemetry logger
// during the execution of the callback
async intercept<T, R extends Promise<T>>(console: Console, callback: () => R): Promise<T> {
// Save the original console methods
const originalConsole = {
log: console.log,
info: console.info,
warn: console.warn,
error: console.error,
};
// Override the console methods
console.log = this.log.bind(this);
console.info = this.info.bind(this);
console.warn = this.warn.bind(this);
console.error = this.error.bind(this);
try {
return await callback();
} finally {
// Restore the original console methods
console.log = originalConsole.log;
console.info = originalConsole.info;
console.warn = originalConsole.warn;
console.error = originalConsole.error;
}
}
log(...args: unknown[]): void {
this.#handleLog(SeverityNumber.INFO, "Log", ...args);
}
info(...args: unknown[]): void {
this.#handleLog(SeverityNumber.INFO, "Info", ...args);
}
warn(...args: unknown[]): void {
this.#handleLog(SeverityNumber.WARN, "Warn", ...args);
}
error(...args: unknown[]): void {
this.#handleLog(SeverityNumber.ERROR, "Error", ...args);
}
#handleLog(severityNumber: SeverityNumber, severityText: string, ...args: unknown[]): void {
const body = util.format(...args);
const parsed = tryParseJSON(body);
if (parsed.ok) {
this.logger.emit({
severityNumber,
severityText,
body: getLogMessage(parsed.value, severityText),
attributes: { ...this.#getAttributes(), ...flattenAttributes(parsed.value) },
});
return;
}
this.logger.emit({
severityNumber,
severityText,
body,
attributes: this.#getAttributes(),
});
}
#getAttributes(): logsAPI.LogAttributes {
return {
"log.type": "console",
};
}
}
function getLogMessage(value: Record<string, unknown>, fallback: string): string {
if (typeof value["message"] === "string") {
return value["message"];
}
if (typeof value["msg"] === "string") {
return value["msg"];
}
if (typeof value["body"] === "string") {
return value["body"];
}
if (typeof value["error"] === "string") {
return value["error"];
}
return fallback;
}
function tryParseJSON(
value: string
): { ok: true; value: Record<string, unknown> } | { ok: false; value: string } {
try {
const parsed = JSON.parse(value);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return { ok: true, value: parsed };
}
return { ok: false, value };
} catch (e) {
return { ok: false, value };
}
}
+53
View File
@@ -0,0 +1,53 @@
import { TaskRunError } from "./schemas/common";
export function parseError(error: unknown): TaskRunError {
if (error instanceof Error) {
return {
type: "BUILT_IN_ERROR",
name: error.name,
message: error.message,
stackTrace: error.stack ?? "",
};
}
if (typeof error === "string") {
return {
type: "STRING_ERROR",
raw: error,
};
}
try {
return {
type: "CUSTOM_ERROR",
raw: JSON.stringify(error),
};
} catch (e) {
return {
type: "CUSTOM_ERROR",
raw: String(error),
};
}
}
export function createErrorTaskError(error: TaskRunError): any {
switch (error.type) {
case "BUILT_IN_ERROR": {
const e = new Error(error.message);
e.name = error.name;
e.stack = error.stackTrace;
return e;
}
case "STRING_ERROR": {
return error.raw;
}
case "CUSTOM_ERROR": {
return JSON.parse(error.raw);
}
case "INTERNAL_ERROR": {
return new Error(`trigger.dev internal error (${error.code})`);
}
}
}
+22
View File
@@ -1 +1,23 @@
import { TriggerTaskRequestBody } from "./schemas";
export * from "./schemas";
export * from "./apiClient";
export * from "./zodMessageHandler";
export * from "./errors";
export * from "./runtime-api";
export * from "./logger-api";
export { SemanticInternalAttributes } from "./semanticInternalAttributes";
export function parseTriggerTaskRequestBody(body: unknown) {
return TriggerTaskRequestBody.safeParse(body);
}
export { taskContextManager } from "./tasks/taskContextManager";
export type { RuntimeManager } from "./runtime/manager";
export { DevRuntimeManager } from "./runtime/devRuntimeManager";
export { TriggerTracer } from "./tracer";
export type { TaskLogger } from "./logger/taskLogger";
export { OtelTaskLogger } from "./logger/taskLogger";
export { ConsoleInterceptor } from "./consoleInterceptor";
export { flattenAttributes } from "./utils/flattenAttributes";
+5
View File
@@ -0,0 +1,5 @@
// Split module-level variable definition into separate files to allow
// tree-shaking on each api instance.
import { LoggerAPI } from "./logger";
/** Entrypoint for logger API */
export const logger = LoggerAPI.getInstance();
+52
View File
@@ -0,0 +1,52 @@
import { NoopTaskLogger, TaskLogger } from "./taskLogger";
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals";
const API_NAME = "logger";
const NOOP_TASK_LOGGER = new NoopTaskLogger();
export class LoggerAPI implements TaskLogger {
private static _instance?: LoggerAPI;
private constructor() {}
public static getInstance(): LoggerAPI {
if (!this._instance) {
this._instance = new LoggerAPI();
}
return this._instance;
}
public disable() {
unregisterGlobal(API_NAME);
}
public setGlobalTaskLogger(taskLogger: TaskLogger): boolean {
return registerGlobal(API_NAME, taskLogger);
}
public debug(message: string, metadata?: Record<string, unknown>) {
this.#getTaskLogger().debug(message, metadata);
}
public log(message: string, metadata?: Record<string, unknown>) {
this.#getTaskLogger().log(message, metadata);
}
public info(message: string, metadata?: Record<string, unknown>) {
this.#getTaskLogger().info(message, metadata);
}
public warn(message: string, metadata?: Record<string, unknown>) {
this.#getTaskLogger().warn(message, metadata);
}
public error(message: string, metadata?: Record<string, unknown>) {
this.#getTaskLogger().error(message, metadata);
}
#getTaskLogger(): TaskLogger {
return getGlobal(API_NAME) ?? NOOP_TASK_LOGGER;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Logger, SeverityNumber } from "@opentelemetry/api-logs";
import { flattenAttributes } from "../utils/flattenAttributes";
export type LogLevel = "log" | "error" | "warn" | "info" | "debug";
const logLevels: Array<LogLevel> = ["error", "warn", "log", "info", "debug"];
export type TaskLoggerConfig = {
logger: Logger;
level: LogLevel;
};
export interface TaskLogger {
debug(message: string, properties?: Record<string, unknown>): void;
log(message: string, properties?: Record<string, unknown>): void;
info(message: string, properties?: Record<string, unknown>): void;
warn(message: string, properties?: Record<string, unknown>): void;
error(message: string, properties?: Record<string, unknown>): void;
}
export class OtelTaskLogger implements TaskLogger {
private readonly _level: number;
constructor(private readonly _config: TaskLoggerConfig) {
this._level = logLevels.indexOf(_config.level);
}
debug(message: string, properties?: Record<string, unknown>) {
if (this._level < 4) return;
this.#emitLog(message, "debug", SeverityNumber.DEBUG, properties);
}
log(message: string, properties?: Record<string, unknown>) {
if (this._level < 2) return;
this.#emitLog(message, "log", SeverityNumber.INFO, properties);
}
info(message: string, properties?: Record<string, unknown>) {
if (this._level < 3) return;
this.#emitLog(message, "info", SeverityNumber.INFO, properties);
}
warn(message: string, properties?: Record<string, unknown>) {
if (this._level < 1) return;
this.#emitLog(message, "warn", SeverityNumber.WARN, properties);
}
error(message: string, properties?: Record<string, unknown>) {
if (this._level < 0) return;
this.#emitLog(message, "error", SeverityNumber.ERROR, properties);
}
#emitLog(
message: string,
severityText: string,
severityNumber: SeverityNumber,
properties?: Record<string, unknown>
) {
this._config.logger.emit({
severityNumber,
severityText,
body: message,
attributes: { ...flattenAttributes(properties), "log.type": "logger" },
});
}
}
export class NoopTaskLogger implements TaskLogger {
debug() {}
log() {}
info() {}
warn() {}
error() {}
}
+2
View File
@@ -0,0 +1,2 @@
export { TracingSDK, type TracingSDKConfig } from "./tracingSDK";
export { HttpInstrumentation, FetchInstrumentation } from "./instrumentations";
@@ -0,0 +1,2 @@
export { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
export { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
+112
View File
@@ -0,0 +1,112 @@
import { TracerProvider } from "@opentelemetry/api";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import {
registerInstrumentations,
type InstrumentationOption,
} from "@opentelemetry/instrumentation";
import {
IResource,
Resource,
ResourceAttributes,
detectResourcesSync,
} from "@opentelemetry/resources";
import { LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { logs } from "@opentelemetry/api-logs";
import { DetectorSync, ResourceDetectionConfig } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
import { SemanticInternalAttributes } from "../semanticInternalAttributes";
class AsyncResourceDetector implements DetectorSync {
private _promise: Promise<ResourceAttributes>;
private _resolver?: (value: ResourceAttributes) => void;
constructor() {
this._promise = new Promise((resolver) => {
this._resolver = resolver;
});
}
detect(_config?: ResourceDetectionConfig): Resource {
return new Resource({}, this._promise);
}
resolveWithAttributes(attributes: ResourceAttributes) {
if (!this._resolver) {
throw new Error("Resolver not available");
}
this._resolver(attributes);
}
}
export type TracingSDKConfig = {
url: string;
forceFlushTimeoutMillis?: number;
resource?: IResource;
instrumentations?: InstrumentationOption[];
};
export class TracingSDK {
public readonly asyncResourceDetector = new AsyncResourceDetector();
private readonly _logProvider: LoggerProvider;
private readonly _traceExporter: OTLPTraceExporter;
public readonly getLogger: LoggerProvider["getLogger"];
public readonly getTracer: TracerProvider["getTracer"];
constructor(private readonly config: TracingSDKConfig) {
const commonResources = detectResourcesSync({
detectors: [this.asyncResourceDetector],
})
.merge(
new Resource({
[SemanticResourceAttributes.CLOUD_PROVIDER]: "trigger.dev",
[SemanticInternalAttributes.TRIGGER]: true,
})
)
.merge(config.resource ?? new Resource({}));
const provider = new NodeTracerProvider({
forceFlushTimeoutMillis: config.forceFlushTimeoutMillis ?? 500,
resource: commonResources,
});
const exporter = new OTLPTraceExporter({
url: `${config.url}/v1/traces`,
timeoutMillis: config.forceFlushTimeoutMillis ?? 1000,
});
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
provider.register();
registerInstrumentations({
instrumentations: config.instrumentations ?? [],
});
const logExporter = new OTLPLogExporter({
url: `${config.url}/v1/logs`,
});
// To start a logger, you first need to initialize the Logger provider.
const loggerProvider = new LoggerProvider({
resource: commonResources,
});
loggerProvider.addLogRecordProcessor(new SimpleLogRecordProcessor(logExporter));
this._logProvider = loggerProvider;
this._traceExporter = exporter;
logs.setGlobalLoggerProvider(loggerProvider);
this.getLogger = loggerProvider.getLogger.bind(loggerProvider);
this.getTracer = provider.getTracer.bind(provider);
}
public async flushOtel() {
await this._traceExporter.forceFlush();
await this._logProvider.forceFlush();
}
}
+5
View File
@@ -0,0 +1,5 @@
// Split module-level variable definition into separate files to allow
// tree-shaking on each api instance.
import { RuntimeAPI } from "./runtime";
/** Entrypoint for runtime API */
export const runtime = RuntimeAPI.getInstance();
@@ -0,0 +1,55 @@
import { TaskRunContext, TaskRunExecution, TaskRunExecutionResult } from "../schemas";
import { TriggerTracer } from "../tracer";
import { RuntimeManager } from "./manager";
export type DevRuntimeManagerOptions = {
tracer: TriggerTracer;
};
export class DevRuntimeManager implements RuntimeManager {
_taskWaits: Map<
string,
{ resolve: (value: TaskRunExecutionResult) => void; reject: (err?: any) => void }
> = new Map();
constructor(private readonly options: DevRuntimeManagerOptions) {}
disable(): void {
// do nothing
}
async waitForDuration(ms: number): Promise<void> {
return this.options.tracer.startActiveSpan("wait for duration", async (span) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
});
}
async waitUntil(date: Date): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, date.getTime() - Date.now());
});
}
async waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
return this.options.tracer.startActiveSpan("wait for task", async (span) => {
span.setAttribute("trigger.task.run.id", params.id);
const promise = new Promise<TaskRunExecutionResult>((resolve, reject) => {
this._taskWaits.set(params.id, { resolve, reject });
});
return await promise;
});
}
resumeTask(completion: TaskRunExecutionResult, execution: TaskRunExecution): void {
const wait = this._taskWaits.get(execution.run.id);
if (wait) {
wait.resolve(completion);
this._taskWaits.delete(execution.run.id);
}
}
}
+47
View File
@@ -0,0 +1,47 @@
const API_NAME = "runtime";
import { TaskRunContext, TaskRunExecutionResult } from "../schemas";
import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals";
import { type RuntimeManager } from "./manager";
import { NoopRuntimeManager } from "./noopRuntimeManager";
const NOOP_RUNTIME_MANAGER = new NoopRuntimeManager();
export class RuntimeAPI {
private static _instance?: RuntimeAPI;
private constructor() {}
public static getInstance(): RuntimeAPI {
if (!this._instance) {
this._instance = new RuntimeAPI();
}
return this._instance;
}
public waitForDuration(ms: number): Promise<void> {
return this.#getRuntimeManager().waitForDuration(ms);
}
public waitUntil(date: Date): Promise<void> {
return this.#getRuntimeManager().waitUntil(date);
}
public waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
return this.#getRuntimeManager().waitForTask(params);
}
public setGlobalRuntimeManager(runtimeManager: RuntimeManager): boolean {
return registerGlobal(API_NAME, runtimeManager);
}
public disable() {
this.#getRuntimeManager().disable();
unregisterGlobal(API_NAME);
}
#getRuntimeManager(): RuntimeManager {
return getGlobal(API_NAME) ?? NOOP_RUNTIME_MANAGER;
}
}
+8
View File
@@ -0,0 +1,8 @@
import { TaskRunContext, TaskRunExecutionResult } from "../schemas";
export interface RuntimeManager {
disable(): void;
waitUntil(date: Date): Promise<void>;
waitForDuration(ms: number): Promise<void>;
waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult>;
}
@@ -0,0 +1,20 @@
import { TaskRunContext, TaskRunExecutionResult } from "../schemas";
import { RuntimeManager } from "./manager";
export class NoopRuntimeManager implements RuntimeManager {
disable(): void {
// do nothing
}
waitForDuration(ms: number): Promise<void> {
return Promise.resolve();
}
waitUntil(date: Date): Promise<void> {
return Promise.resolve();
}
waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
throw new Error("Method not implemented.");
}
}
+53
View File
@@ -0,0 +1,53 @@
import { z } from "zod";
import { BackgroundWorkerMetadata } from "./resources";
export const WhoAmIResponseSchema = z.object({
userId: z.string(),
email: z.string().email(),
});
export type WhoAmIResponse = z.infer<typeof WhoAmIResponseSchema>;
export const GetProjectDevResponse = z.object({
apiKey: z.string(),
name: z.string(),
});
export type GetProjectDevResponse = z.infer<typeof GetProjectDevResponse>;
export const CreateBackgroundWorkerRequestBody = z.object({
localOnly: z.boolean(),
metadata: BackgroundWorkerMetadata,
});
export type CreateBackgroundWorkerRequestBody = z.infer<typeof CreateBackgroundWorkerRequestBody>;
export const CreateBackgroundWorkerResponse = z.object({
id: z.string(),
version: z.string(),
contentHash: z.string(),
});
export type CreateBackgroundWorkerResponse = z.infer<typeof CreateBackgroundWorkerResponse>;
export const BackgroundWorkerRecord = CreateBackgroundWorkerResponse;
export type BackgroundWorkerRecord = CreateBackgroundWorkerResponse;
export const TriggerTaskRequestBody = z.object({
payload: z.any(),
context: z.any(),
options: z
.object({
parentAttempt: z.string().optional(),
lockToCurrentVersion: z.boolean().optional(),
})
.optional(),
});
export type TriggerTaskRequestBody = z.infer<typeof TriggerTaskRequestBody>;
export const TriggerTaskResponse = z.object({
id: z.string(),
});
export type TriggerTaskResponse = z.infer<typeof TriggerTaskResponse>;
+145
View File
@@ -0,0 +1,145 @@
import { z } from "zod";
export const TaskRunBuiltInError = z.object({
type: z.literal("BUILT_IN_ERROR"),
name: z.string(),
message: z.string(),
stackTrace: z.string(),
});
export type TaskRunBuiltInError = z.infer<typeof TaskRunBuiltInError>;
export const TaskRunCustomErrorObject = z.object({
type: z.literal("CUSTOM_ERROR"),
raw: z.string(),
});
export type TaskRunCustomErrorObject = z.infer<typeof TaskRunCustomErrorObject>;
export const TaskRunStringError = z.object({
type: z.literal("STRING_ERROR"),
raw: z.string(),
});
export type TaskRunStringError = z.infer<typeof TaskRunStringError>;
export const TaskRunErrorCodes = {
COULD_NOT_FIND_EXECUTOR: "COULD_NOT_FIND_EXECUTOR",
} as const;
export const TaskRunInternalError = z.object({
type: z.literal("INTERNAL_ERROR"),
code: z.enum(["COULD_NOT_FIND_EXECUTOR"]),
});
export type TaskRunInternalError = z.infer<typeof TaskRunInternalError>;
export const TaskRunError = z.discriminatedUnion("type", [
TaskRunBuiltInError,
TaskRunCustomErrorObject,
TaskRunStringError,
TaskRunInternalError,
]);
export type TaskRunError = z.infer<typeof TaskRunError>;
export const TaskRun = z.object({
id: z.string(),
payload: z.string(),
payloadType: z.string(),
context: z.any(),
tags: z.array(z.string()),
createdAt: z.coerce.date(),
});
export type TaskRun = z.infer<typeof TaskRun>;
export const TaskRunExecutionTask = z.object({
id: z.string(),
filePath: z.string(),
exportName: z.string(),
});
export type TaskRunExecutionTask = z.infer<typeof TaskRunExecutionTask>;
export const TaskRunExecutionAttempt = z.object({
id: z.string(),
number: z.number(),
startedAt: z.coerce.date(),
backgroundWorkerId: z.string(),
backgroundWorkerTaskId: z.string(),
status: z.string(),
});
export type TaskRunExecutionAttempt = z.infer<typeof TaskRunExecutionAttempt>;
export const TaskRunExecutionEnvironment = z.object({
id: z.string(),
slug: z.string(),
type: z.enum(["PRODUCTION", "STAGING", "DEVELOPMENT", "PREVIEW"]),
});
export type TaskRunExecutionEnvironment = z.infer<typeof TaskRunExecutionEnvironment>;
export const TaskRunExecutionOrganization = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
});
export type TaskRunExecutionOrganization = z.infer<typeof TaskRunExecutionOrganization>;
export const TaskRunExecutionProject = z.object({
id: z.string(),
ref: z.string(),
slug: z.string(),
name: z.string(),
});
export type TaskRunExecutionProject = z.infer<typeof TaskRunExecutionProject>;
export const TaskRunExecution = z.object({
task: TaskRunExecutionTask,
attempt: TaskRunExecutionAttempt,
run: TaskRun,
environment: TaskRunExecutionEnvironment,
organization: TaskRunExecutionOrganization,
project: TaskRunExecutionProject,
});
export type TaskRunExecution = z.infer<typeof TaskRunExecution>;
export const TaskRunContext = z.object({
task: TaskRunExecutionTask,
attempt: TaskRunExecutionAttempt.omit({ backgroundWorkerId: true, backgroundWorkerTaskId: true }),
run: TaskRun.omit({ payload: true, payloadType: true }),
environment: TaskRunExecutionEnvironment,
organization: TaskRunExecutionOrganization,
project: TaskRunExecutionProject,
});
export type TaskRunContext = z.infer<typeof TaskRunContext>;
export const TaskRunFailedExecutionResult = z.object({
ok: z.literal(false),
id: z.string(),
error: TaskRunError,
});
export type TaskRunFailedExecutionResult = z.infer<typeof TaskRunFailedExecutionResult>;
export const TaskRunSuccessfulExecutionResult = z.object({
ok: z.literal(true),
id: z.string(),
output: z.string(),
outputType: z.string(),
});
export type TaskRunSuccessfulExecutionResult = z.infer<typeof TaskRunSuccessfulExecutionResult>;
export const TaskRunExecutionResult = z.discriminatedUnion("ok", [
TaskRunSuccessfulExecutionResult,
TaskRunFailedExecutionResult,
]);
export type TaskRunExecutionResult = z.infer<typeof TaskRunExecutionResult>;
+4 -1
View File
@@ -1,2 +1,5 @@
export * from "./tokens";
export * from "./whoami";
export * from "./api";
export * from "./resources";
export * from "./common";
export * from "./messages";
+101
View File
@@ -0,0 +1,101 @@
import { z } from "zod";
import { TaskRunExecutionResult, TaskRunExecution } from "./common";
import { BackgroundWorkerRecord } from "./api";
export const TaskRunExecutionPayload = z.object({
execution: TaskRunExecution,
traceContext: z.record(z.unknown()),
});
export type TaskRunExecutionPayload = z.infer<typeof TaskRunExecutionPayload>;
export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [
z.object({
type: z.literal("EXECUTE_RUNS"),
payloads: z.array(TaskRunExecutionPayload),
}),
]);
export type BackgroundWorkerServerMessages = z.infer<typeof BackgroundWorkerServerMessages>;
export const serverWebsocketMessages = {
SERVER_READY: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
}),
BACKGROUND_WORKER_MESSAGE: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
data: BackgroundWorkerServerMessages,
}),
};
export const BackgroundWorkerClientMessages = z.discriminatedUnion("type", [
z.object({
version: z.literal("v1").default("v1"),
type: z.literal("TASK_RUN_COMPLETED"),
completion: TaskRunExecutionResult,
}),
]);
export type BackgroundWorkerClientMessages = z.infer<typeof BackgroundWorkerClientMessages>;
export const clientWebsocketMessages = {
READY_FOR_TASKS: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
}),
WORKER_DEPRECATED: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
}),
BACKGROUND_WORKER_MESSAGE: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
data: BackgroundWorkerClientMessages,
}),
};
export const workerToChildMessages = {
EXECUTE_TASK_RUN: z.object({
version: z.literal("v1").default("v1"),
execution: TaskRunExecution,
traceContext: z.record(z.unknown()),
metadata: BackgroundWorkerRecord,
}),
TASK_RUN_COMPLETED: z.object({
version: z.literal("v1").default("v1"),
completion: TaskRunExecutionResult,
execution: TaskRunExecution,
}),
CLEANUP: z.object({
version: z.literal("v1").default("v1"),
flush: z.boolean().default(false),
}),
};
export const TaskMetadata = z.object({
id: z.string(),
exportName: z.string(),
packageVersion: z.string(),
});
export type TaskMetadata = z.infer<typeof TaskMetadata>;
export const TaskMetadataWithFilePath = TaskMetadata.extend({
filePath: z.string(),
});
export type TaskMetadataWithFilePath = z.infer<typeof TaskMetadataWithFilePath>;
export const childToWorkerMessages = {
TASK_RUN_COMPLETED: z.object({
version: z.literal("v1").default("v1"),
result: TaskRunExecutionResult,
}),
TASKS_READY: z.object({
version: z.literal("v1").default("v1"),
tasks: TaskMetadataWithFilePath.array(),
}),
READY_TO_DISPOSE: z.undefined(),
};
+18
View File
@@ -0,0 +1,18 @@
import { z } from "zod";
export const TaskResource = z.object({
id: z.string(),
filePath: z.string(),
exportName: z.string(),
});
export type TaskResource = z.infer<typeof TaskResource>;
export const BackgroundWorkerMetadata = z.object({
packageVersion: z.string(),
contentHash: z.string(),
cliPackageVersion: z.string(),
tasks: z.array(TaskResource),
});
export type BackgroundWorkerMetadata = z.infer<typeof BackgroundWorkerMetadata>;
-8
View File
@@ -1,8 +0,0 @@
import { z } from "zod";
export const WhoAmIResponseSchema = z.object({
userId: z.string(),
email: z.string().email(),
});
export type WhoAmIResponse = z.infer<typeof WhoAmIResponseSchema>;
@@ -0,0 +1,26 @@
export const SemanticInternalAttributes = {
ENVIRONMENT_ID: "ctx.environment.id",
ENVIRONMENT_TYPE: "ctx.environment.type",
ORGANIZATION_ID: "ctx.organization.id",
PROJECT_ID: "ctx.project.id",
PROJECT_REF: "ctx.project.ref",
ATTEMPT_ID: "ctx.attempt.id",
ATTEMP_NUMBER: "ctx.attempt.number",
RUN_ID: "ctx.run.id",
TASK_SLUG: "ctx.task.id",
TASK_PATH: "ctx.task.filePath",
TASK_EXPORT_NAME: "ctx.task.exportName",
SPAN_PARTIAL: "$span.partial",
SPAN_ID: "$span.span_id",
OUTPUT: "$output",
STYLE: "$style",
STYLE_ICON: "$style.icon",
METADATA: "$metadata",
TRIGGER: "$trigger",
PAYLOAD: "$payload",
WORKER_ID: "worker.id",
WORKER_VERSION: "worker.version",
CLI_VERSION: "cli.version",
SDK_VERSION: "sdk.version",
SDK_LANGUAGE: "sdk.language",
};
@@ -0,0 +1,59 @@
import { Attributes } from "@opentelemetry/api";
import { BackgroundWorkerRecord, TaskRunContext } from "../schemas";
import { SafeAsyncLocalStorage } from "../utils/safeAsyncLocalStorage";
import { flattenAttributes } from "../utils/flattenAttributes";
type TaskContext = {
ctx: TaskRunContext;
payload: any;
worker: BackgroundWorkerRecord;
};
export class TaskContextManager {
private _storage: SafeAsyncLocalStorage<TaskContext> = new SafeAsyncLocalStorage<TaskContext>();
get isInsideTask(): boolean {
return this.#getStore() !== undefined;
}
get ctx(): TaskRunContext | undefined {
const store = this.#getStore();
return store?.ctx;
}
get payload(): any | undefined {
const store = this.#getStore();
return store?.payload;
}
get worker(): BackgroundWorkerRecord | undefined {
const store = this.#getStore();
return store?.worker;
}
get attributes(): Attributes {
if (this.ctx) {
return {
...flattenAttributes(this.ctx, "ctx"),
...flattenAttributes(this.payload, "payload"),
...flattenAttributes(this.worker, "worker"),
"service.name": this.ctx.task.id,
};
}
return {};
}
runWith<R extends (...args: any[]) => Promise<any>>(
context: TaskContext,
fn: R
): Promise<ReturnType<R>> {
return this._storage.runWith(context, fn);
}
#getStore(): TaskContext | undefined {
return this._storage.getStore();
}
}
export const taskContextManager = new TaskContextManager();
+102
View File
@@ -0,0 +1,102 @@
import {
Context,
SpanOptions,
SpanStatusCode,
context,
propagation,
trace,
type Span,
type Tracer,
} from "@opentelemetry/api";
import { Logger, logs } from "@opentelemetry/api-logs";
import { SemanticInternalAttributes } from "./semanticInternalAttributes";
export type TriggerTracerConfig =
| {
name: string;
version: string;
}
| {
tracer: Tracer;
logger: Logger;
};
export class TriggerTracer {
constructor(private readonly _config: TriggerTracerConfig) {}
private _tracer: Tracer | undefined;
private get tracer(): Tracer {
if (!this._tracer) {
if ("tracer" in this._config) return this._config.tracer;
this._tracer = trace.getTracer(this._config.name, this._config.version);
}
return this._tracer;
}
private _logger: Logger | undefined;
private get logger(): Logger {
if (!this._logger) {
if ("logger" in this._config) return this._config.logger;
this._logger = logs.getLogger(this._config.name, this._config.version);
}
return this._logger;
}
extractContext(traceContext?: Record<string, unknown>) {
return propagation.extract(context.active(), traceContext ?? {});
}
startActiveSpan<T>(
name: string,
fn: (span: Span) => Promise<T>,
options?: SpanOptions,
ctx?: Context
): Promise<T> {
const parentContext = ctx ?? context.active();
const attributes = options?.attributes ?? {};
return this.tracer.startActiveSpan(
name,
{
...options,
attributes,
},
parentContext,
async (span) => {
this.tracer
.startSpan(
name,
{
...options,
attributes: {
...attributes,
[SemanticInternalAttributes.SPAN_PARTIAL]: true,
[SemanticInternalAttributes.SPAN_ID]: span.spanContext().spanId,
},
},
parentContext
)
.end();
try {
return await fn(span);
} catch (e) {
if (typeof e === "string" || e instanceof Error) {
span.recordException(e);
}
span.setStatus({ code: SpanStatusCode.ERROR });
throw e;
} finally {
span.end();
}
}
);
}
}
@@ -0,0 +1,40 @@
import { Attributes } from "@opentelemetry/api";
export function flattenAttributes(
obj: Record<string, unknown> | null | undefined,
prefix?: string
): Attributes {
const result: Attributes = {};
// Check if obj is null or undefined
if (obj == null) {
return result;
}
for (const [key, value] of Object.entries(obj)) {
const newPrefix = `${prefix ? `${prefix}.` : ""}${key}`;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (typeof value[i] === "object" && value[i] !== null) {
// update null check here as well
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.${i}`));
} else {
result[`${newPrefix}.${i}`] = value[i];
}
}
} else if (isRecord(value)) {
// update null check here
Object.assign(result, flattenAttributes(value, newPrefix));
} else {
if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {
result[newPrefix] = value;
}
}
}
return result;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
+47
View File
@@ -0,0 +1,47 @@
import type { RuntimeManager } from "../runtime/manager";
import { _globalThis } from "./platform";
const GLOBAL_TRIGGER_DOT_DEV_KEY = Symbol.for(`dev.trigger.ts.api`);
const _global = _globalThis as TriggerDotDevGlobal;
export function registerGlobal<Type extends keyof TriggerDotDevGlobalAPI>(
type: Type,
instance: TriggerDotDevGlobalAPI[Type],
allowOverride = false
): boolean {
const api = (_global[GLOBAL_TRIGGER_DOT_DEV_KEY] = _global[GLOBAL_TRIGGER_DOT_DEV_KEY] ?? {});
if (!allowOverride && api[type]) {
// already registered an API of this type
const err = new Error(`trigger.dev: Attempted duplicate registration of API: ${type}`);
return false;
}
api[type] = instance;
return true;
}
export function getGlobal<Type extends keyof TriggerDotDevGlobalAPI>(
type: Type
): TriggerDotDevGlobalAPI[Type] | undefined {
return _global[GLOBAL_TRIGGER_DOT_DEV_KEY]?.[type];
}
export function unregisterGlobal(type: keyof TriggerDotDevGlobalAPI) {
const api = _global[GLOBAL_TRIGGER_DOT_DEV_KEY];
if (api) {
delete api[type];
}
}
type TriggerDotDevGlobal = {
[GLOBAL_TRIGGER_DOT_DEV_KEY]?: TriggerDotDevGlobalAPI;
};
type TriggerDotDevGlobalAPI = {
runtime?: RuntimeManager;
logger?: any;
};
+1
View File
@@ -0,0 +1 @@
export const _globalThis = typeof globalThis === "object" ? globalThis : global;
@@ -0,0 +1,17 @@
import { AsyncLocalStorage } from "node:async_hooks";
export class SafeAsyncLocalStorage<T> {
private storage: AsyncLocalStorage<T>;
constructor() {
this.storage = new AsyncLocalStorage<T>();
}
runWith<R extends (...args: any[]) => Promise<any>>(context: T, fn: R): Promise<ReturnType<R>> {
return this.storage.run(context, fn);
}
getStore(): T | undefined {
return this.storage.getStore();
}
}
+123
View File
@@ -0,0 +1,123 @@
import { z } from "zod";
export interface ZodMessageCatalogSchema {
[key: string]: z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
}
export type ZodMessageHandlers<TCatalogSchema extends ZodMessageCatalogSchema> = Partial<{
[K in keyof TCatalogSchema]: (payload: z.infer<TCatalogSchema[K]>) => Promise<void>;
}>;
export type ZodMessageHandlerOptions<TMessageCatalog extends ZodMessageCatalogSchema> = {
schema: TMessageCatalog;
messages?: ZodMessageHandlers<TMessageCatalog>;
};
type MessageFromSchema<
K extends keyof TMessageCatalog,
TMessageCatalog extends ZodMessageCatalogSchema,
> = {
type: K;
payload: z.input<TMessageCatalog[K]>;
};
type MessageFromCatalog<TMessageCatalog extends ZodMessageCatalogSchema> = {
[K in keyof TMessageCatalog]: MessageFromSchema<K, TMessageCatalog>;
}[keyof TMessageCatalog];
const messageSchema = z.object({
version: z.literal("v1").default("v1"),
type: z.string(),
payload: z.unknown(),
});
export class ZodMessageHandler<TMessageCatalog extends ZodMessageCatalogSchema> {
#schema: TMessageCatalog;
#handlers: ZodMessageHandlers<TMessageCatalog> | undefined;
constructor(options: ZodMessageHandlerOptions<TMessageCatalog>) {
this.#schema = options.schema;
this.#handlers = options.messages;
}
public async handleMessage(message: unknown) {
const parsedMessage = this.parseMessage(message);
if (!this.#handlers) {
throw new Error("No handlers provided");
}
const handler = this.#handlers[parsedMessage.type];
if (!handler) {
throw new Error(`Unknown message type: ${String(parsedMessage.type)}`);
}
await handler(parsedMessage.payload);
}
public parseMessage(message: unknown): MessageFromCatalog<TMessageCatalog> {
const parsedMessage = messageSchema.safeParse(message);
if (!parsedMessage.success) {
throw new Error(`Failed to parse message: ${JSON.stringify(parsedMessage.error)}`);
}
const schema = this.#schema[parsedMessage.data.type];
if (!schema) {
throw new Error(`Unknown message type: ${parsedMessage.data.type}`);
}
const parsedPayload = schema.safeParse(parsedMessage.data.payload);
if (!parsedPayload.success) {
throw new Error(`Failed to parse message payload: ${JSON.stringify(parsedPayload.error)}`);
}
return {
type: parsedMessage.data.type,
payload: parsedPayload.data,
};
}
}
type ZodMessageSenderCallback<TMessageCatalog extends ZodMessageCatalogSchema> = (message: {
type: keyof TMessageCatalog;
payload: z.infer<TMessageCatalog[keyof TMessageCatalog]>;
version: "v1";
}) => Promise<void>;
export type ZodMessageSenderOptions<TMessageCatalog extends ZodMessageCatalogSchema> = {
schema: TMessageCatalog;
sender: ZodMessageSenderCallback<TMessageCatalog>;
};
export class ZodMessageSender<TMessageCatalog extends ZodMessageCatalogSchema> {
#schema: TMessageCatalog;
#sender: ZodMessageSenderCallback<TMessageCatalog>;
constructor(options: ZodMessageSenderOptions<TMessageCatalog>) {
this.#schema = options.schema;
this.#sender = options.sender;
}
public async send<K extends keyof TMessageCatalog>(
type: K,
payload: z.input<TMessageCatalog[K]>
) {
const schema = this.#schema[type];
if (!schema) {
throw new Error(`Unknown message type: ${type as string}`);
}
const parsedPayload = schema.safeParse(payload);
if (!parsedPayload.success) {
throw new Error(`Failed to parse message payload: ${JSON.stringify(parsedPayload.error)}`);
}
await this.#sender({ type, payload, version: "v1" });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { z } from "zod";
import { context, propagation } from "@opentelemetry/api";
type ApiResult<TSuccessResult> =
| { ok: true; data: TSuccessResult }
| {
ok: false;
error: string;
};
export async function zodfetch<TResponseBody extends any>(
schema: z.Schema<TResponseBody>,
url: string,
requestInit?: RequestInit
): Promise<ApiResult<TResponseBody>> {
try {
const response = await fetch(url, requestInit);
if ((!requestInit || requestInit.method === "GET") && response.status === 404) {
return {
ok: false,
error: `404: ${response.statusText}`,
};
}
if (response.status >= 400 && response.status < 500) {
const body = await response.json();
if (!body.error) {
return { ok: false, error: "Something went wrong" };
}
return { ok: false, error: body.error };
}
if (response.status !== 200) {
return {
ok: false,
error: `Failed to fetch ${url}, got status code ${response.status}`,
};
}
const jsonBody = await response.json();
const parsedResult = schema.safeParse(jsonBody);
if (parsedResult.success) {
return { ok: true, data: parsedResult.data };
}
if ("error" in jsonBody) {
return {
ok: false,
error: typeof jsonBody.error === "string" ? jsonBody.error : JSON.stringify(jsonBody.error),
};
}
return { ok: false, error: parsedResult.error.message };
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : JSON.stringify(error),
};
}
}
+1
View File
@@ -3,4 +3,5 @@ import { packageOptions, defineConfig } from "@trigger.dev/tsup";
export default defineConfig({
...packageOptions,
config: "tsconfig.build.json",
entry: ["./src/index.ts", "./src/v3/index.ts", "./src/v3/otel/index.ts"],
});

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