v3 dev cli command + more (#894)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -53,4 +53,5 @@ apps/**/public/build
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
|
||||
.cosine
|
||||
.cosine
|
||||
.trigger/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "packages/otlp-importer/protos"]
|
||||
path = packages/otlp-importer/protos
|
||||
url = https://github.com/open-telemetry/opentelemetry-proto.git
|
||||
Vendored
+8
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -194,3 +194,5 @@ function logError(error: unknown, request?: Request) {
|
||||
}
|
||||
|
||||
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
|
||||
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -40,6 +40,7 @@ export async function seedCloud(prisma: PrismaClient) {
|
||||
create: {
|
||||
name: "My Project",
|
||||
slug: "my-project-123",
|
||||
externalRef: "my-project-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+43
-27
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/*"]
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TaskMetadataWithFilePath } from "@trigger.dev/core/v3";
|
||||
|
||||
export type TaskMetadataWithRun = TaskMetadataWithFilePath & {
|
||||
run: (params: any) => Promise<any>;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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...");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);",
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export type TaskMetadata = {
|
||||
id: string;
|
||||
exportName: string;
|
||||
};
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -1,2 +1,5 @@
|
||||
export * from "./tokens";
|
||||
export * from "./whoami";
|
||||
export * from "./api";
|
||||
export * from "./resources";
|
||||
export * from "./common";
|
||||
export * from "./messages";
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user