v3: Various fixes to support papermark use-case (#970)

* Adding a changeset for v3

* Add a version field to @trigger.dev/core-apps package.json

* Build trigger.dev when doing a prerelease

* bundle @trigger.dev/core-apps with trigger.dev cli

* Fix the init command config template

* Don’t use * for the @trigger.dev/core dep version specifier

* strip workspace: from the package version before installing it

* Added dependenciesToBundle config option to bundle ESM only packages

* Added logging around resolving dependency paths

* Try again

* Resolve dependencies based on the project dir first

* flip the bundled default

* Adding some logs around dev task completion notifications

* Adding some additional logs

* Add more logs

* Write out the log using process.stdout

* Store pending completion notifications and resume them when awaited (fixes race condition)

* Cleanup some of the logs

* Copy over the postinstall step from the projects package.json

* Add support for including additional files when deploying (e.g. prisma schema)

* Don’t run scripts when resolving deps

* copy all the files just in case anything is needed in postinstall

* Remove duplicate option

* Use the tag when outputting the dev command

* Remove the postinstall script

* add the trigger dir to the config if the default is not chosen

* Remove the “hud” display in the dev command

* trigger file names with dashes now work

* Better file watching in dev

* Much better duplicate ID experience now

* Much better “Project not found” error

* Export the handleError function types from sdk

* Add support for configuring instrumentation

* Upgrade and unify @opentelemetry/* packages (and remove storybook from the webapp)

* Fix typescript error in react package

* Upgrade react types in webapp

* Allow span icons to be determined based on the span name (e.g. prisma:)

* Ignore built-in env vars when checking for env vars, and allow continuing the deployment even if missing env vars were detected

* Improve the retry.fetch default behavior and option structure

* Fixed typescript errors with packages/email react types

* Update the retry.fetch docs
This commit is contained in:
Eric Allam
2024-03-25 17:02:49 +00:00
committed by GitHub
parent b35eebb666
commit 395abe1b92
59 changed files with 1879 additions and 8451 deletions
+4 -1
View File
@@ -16,7 +16,10 @@
"emails",
"proxy",
"yalt",
"@trigger.dev/database"
"@trigger.dev/database",
"coordinator",
"docker-provider",
"kubernetes-provider"
],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
+8
View File
@@ -0,0 +1,8 @@
---
"trigger.dev": major
"@trigger.dev/core": major
"@trigger.dev/otlp-importer": major
"@trigger.dev/sdk": major
---
Updates to support Trigger.dev v3
+1 -1
View File
@@ -47,7 +47,7 @@ pnpm exec changeset version --snapshot prerelease
3. Build the packages:
```sh
pnpm run build --filter "@trigger.dev/*"
pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev"
```
4. Publish the snapshot (replace "dev" with your tag)
-48
View File
@@ -1,48 +0,0 @@
import type { StorybookConfig } from "@storybook/react-webpack5";
import path from "path";
const root = path.resolve(__dirname, "../app");
const config: StorybookConfig = {
webpackFinal: async (config) => {
return {
...config,
resolve: {
...config.resolve,
alias: {
...(config.resolve?.alias ?? {}),
"~": root,
},
extensions: [
...(config.resolve?.extensions ?? []),
...[".ts", ".tsx", ".js", ".jsx", ".mdx"],
],
},
};
},
stories: ["../app/**/stories/*.mdx", "../app/**/stories/*.stories.@(js|jsx|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/addon-interactions",
"storybook-addon-variants",
"storybook-addon-designs",
{
name: "@storybook/addon-styling",
options: {
// Check out https://github.com/storybookjs/addon-styling/blob/main/docs/api.md
// For more details on this addon's options.
postCss: true,
},
},
],
framework: {
name: "@storybook/react-webpack5",
options: {},
},
docs: {
autodocs: "tag",
},
staticDirs: [path.resolve("public")],
};
export default config;
-50
View File
@@ -1,50 +0,0 @@
import type { Preview } from "@storybook/react";
import "../app/tailwind.css";
import { createRemixStub } from "@remix-run/testing";
import React from "react";
import { LocaleContextProvider } from "../app/components/primitives/LocaleProvider";
import { OperatingSystemContextProvider } from "../app/components/primitives/OperatingSystemProvider";
const preview: Preview = {
parameters: {
actions: { argTypesRegex: "^on[A-Z].*" },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
backgrounds: {
default: "App background",
values: [
{
name: "App background",
value: "#15171A",
},
],
},
layout: "fullscreen",
},
decorators: [
(Story) => {
const RemixStub = createRemixStub([
{
path: "/*",
action: () => ({ redirect: "/" }),
loader: () => ({ redirect: "/" }),
Component: Story,
},
]);
return (
<OperatingSystemContextProvider platform="mac">
<LocaleContextProvider locales={window.navigator.languages as string[]}>
<RemixStub initialEntries={["/"]} />
</LocaleContextProvider>
</OperatingSystemContextProvider>
);
},
],
};
export default preview;
+21 -1
View File
@@ -12,10 +12,30 @@ import { cn } from "~/utils/cn";
type TaskIconProps = {
name: string | undefined;
spanName: string;
className?: string;
};
export function RunIcon({ name, className }: TaskIconProps) {
type SpanNameIcons = {
matcher: RegExp;
iconName: string;
};
const spanNameIcons: SpanNameIcons[] = [{ matcher: /^prisma:/, iconName: "brand-prisma" }];
export function RunIcon({ name, className, spanName }: TaskIconProps) {
const spanNameIcon = spanNameIcons.find(({ matcher }) => matcher.test(spanName));
if (spanNameIcon) {
return (
<NamedIcon
name={spanNameIcon.iconName}
className={cn(className)}
fallback={<InformationCircleIcon className={cn(className, "text-text-dimmed")} />}
/>
);
}
if (!name) return <Squares2X2Icon className={cn(className, "text-text-dimmed")} />;
switch (name) {
-17
View File
@@ -1,4 +1,3 @@
import { H } from "@highlight-run/node";
import {
createReadableStreamFromReadable,
type DataFunctionArgs,
@@ -165,10 +164,6 @@ function handleBrowserRequest(
});
}
if (env.HIGHLIGHT_PROJECT_ID) {
H.init({ projectID: env.HIGHLIGHT_PROJECT_ID });
}
export function handleError(error: unknown, { request, params, context }: DataFunctionArgs) {
logError(error, request);
}
@@ -178,18 +173,6 @@ Worker.init().catch((error) => {
});
function logError(error: unknown, request?: Request) {
if (env.HIGHLIGHT_PROJECT_ID) {
const parsed = request ? H.parseHeaders(Object.fromEntries(request.headers)) : undefined;
if (error instanceof Error) {
H.consumeError(error, parsed?.secureSessionId, parsed?.requestId);
} else {
H.consumeError(
new Error(`Unknown error: ${JSON.stringify(error)}`),
parsed?.secureSessionId,
parsed?.requestId
);
}
}
console.error(error);
}
+1 -4
View File
@@ -1,4 +1,3 @@
import { ErrorBoundary as HighlightErrorBoundary } from "@highlight-run/react";
import type { LinksFunction, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
@@ -111,9 +110,7 @@ function App() {
<Links />
</head>
<body className="bg-darkBackground h-full overflow-hidden">
<HighlightErrorBoundary>
<Outlet />
</HighlightErrorBoundary>
<Outlet />
<Toast />
<ScrollRestoration />
<ExternalScripts />
@@ -60,7 +60,11 @@ export default function Page() {
>
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<div className="flex items-center gap-1 overflow-x-hidden">
<RunIcon name={event.style?.icon} className="h-4 min-h-4 w-4 min-w-4" />
<RunIcon
name={event.style?.icon}
spanName={event.message}
className="h-4 min-h-4 w-4 min-w-4"
/>
<Header2 className={cn("whitespace-nowrap")}>
<SpanTitle {...event} size="large" />
</Header2>
@@ -362,7 +362,11 @@ function TasksTreeView({
<div className="flex w-full items-center justify-between gap-2 pl-1">
<div className="flex items-center gap-2 overflow-x-hidden">
<RunIcon name={node.data.style?.icon} className="h-4 min-h-4 w-4 min-w-4" />
<RunIcon
name={node.data.style?.icon}
spanName={node.data.message}
className="h-4 min-h-4 w-4 min-w-4"
/>
<NodeText node={node} />
{node.data.isRoot && <Badge variant="outline-rounded">Root</Badge>}
</div>
@@ -1,6 +1,6 @@
import { CreateBackgroundWorkerRequestBody, TaskResource } from "@trigger.dev/core/v3";
import type { BackgroundWorker } from "@trigger.dev/database";
import { PrismaClientOrTransaction } from "~/db.server";
import { Prisma, PrismaClientOrTransaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
@@ -89,46 +89,83 @@ export async function createBackgroundTasks(
prisma: PrismaClientOrTransaction
) {
for (const task of tasks) {
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: worker.projectId,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
workerId: worker.id,
slug: task.id,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
},
});
const queueName = task.queue?.name ?? `task/${task.id}`;
const taskQueue = await prisma.taskQueue.upsert({
where: {
runtimeEnvironmentId_name: {
try {
await prisma.backgroundWorkerTask.create({
data: {
friendlyId: generateFriendlyId("task"),
projectId: worker.projectId,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
name: queueName,
workerId: worker.id,
slug: task.id,
filePath: task.filePath,
exportName: task.exportName,
retryConfig: task.retry,
queueConfig: task.queue,
},
},
update: {
concurrencyLimit: task.queue?.concurrencyLimit,
rateLimit: task.queue?.rateLimit,
},
create: {
friendlyId: generateFriendlyId("queue"),
name: queueName,
concurrencyLimit: task.queue?.concurrencyLimit,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
projectId: worker.projectId,
rateLimit: task.queue?.rateLimit,
type: task.queue?.name ? "NAMED" : "VIRTUAL",
},
});
});
if (taskQueue.concurrencyLimit) {
await marqs?.updateQueueConcurrency(env, taskQueue.name, taskQueue.concurrencyLimit);
const queueName = task.queue?.name ?? `task/${task.id}`;
const taskQueue = await prisma.taskQueue.upsert({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: worker.runtimeEnvironmentId,
name: queueName,
},
},
update: {
concurrencyLimit: task.queue?.concurrencyLimit,
rateLimit: task.queue?.rateLimit,
},
create: {
friendlyId: generateFriendlyId("queue"),
name: queueName,
concurrencyLimit: task.queue?.concurrencyLimit,
runtimeEnvironmentId: worker.runtimeEnvironmentId,
projectId: worker.projectId,
rateLimit: task.queue?.rateLimit,
type: task.queue?.name ? "NAMED" : "VIRTUAL",
},
});
if (taskQueue.concurrencyLimit) {
await marqs?.updateQueueConcurrency(env, taskQueue.name, taskQueue.concurrencyLimit);
}
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// The error code for unique constraint violation in Prisma is P2002
if (error.code === "P2002") {
logger.warn("Task already exists", {
task,
worker,
});
} else {
logger.error("Prisma Error creating background worker task", {
error: {
code: error.code,
message: error.message,
},
task,
worker,
});
}
} else if (error instanceof Error) {
logger.error("Error creating background worker task", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
task,
worker,
});
} else {
logger.error("Unknown error creating background worker task", {
error,
task,
worker,
});
}
}
}
}
+17 -35
View File
@@ -19,9 +19,7 @@
"generate:sourcemaps": "remix build --sourcemap",
"clean:sourcemaps": "run-s clean:sourcemaps:*",
"clean:sourcemaps:public": "rimraf ./build/**/*.map",
"clean:sourcemaps:build": "rimraf ./public/build/**/*.map",
"storybook": "storybook dev -p 6006 --no-open",
"build-storybook": "storybook build"
"clean:sourcemaps:build": "rimraf ./public/build/**/*.map"
},
"eslintIgnore": [
"/node_modules",
@@ -44,24 +42,22 @@
"@depot/sdk-node": "^0.5.0",
"@headlessui/react": "^1.7.8",
"@heroicons/react": "^2.0.12",
"@highlight-run/node": "^3.1.0",
"@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/instrumentation": "^0.48.0",
"@opentelemetry/instrumentation-express": "^0.35.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",
"@prisma/instrumentation": "^5.9.1",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/core": "^1.22.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.49.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
"@opentelemetry/instrumentation": "^0.49.1",
"@opentelemetry/instrumentation-express": "^0.36.1",
"@opentelemetry/instrumentation-http": "^0.49.1",
"@opentelemetry/resources": "^1.22.0",
"@opentelemetry/sdk-logs": "^0.49.1",
"@opentelemetry/sdk-node": "^0.49.1",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/sdk-trace-node": "^1.22.0",
"@opentelemetry/semantic-conventions": "^1.22.0",
"@prisma/instrumentation": "^5.11.0",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.3",
"@radix-ui/react-label": "^2.0.1",
@@ -165,17 +161,6 @@
"@remix-run/dev": "2.1.0",
"@remix-run/eslint-config": "2.1.0",
"@remix-run/testing": "^2.1.0",
"@storybook/addon-backgrounds": "^7.0.7",
"@storybook/addon-docs": "^7.0.12",
"@storybook/addon-essentials": "^7.0.7",
"@storybook/addon-interactions": "^7.0.7",
"@storybook/addon-links": "^7.0.7",
"@storybook/addon-styling": "^1.0.5",
"@storybook/addon-viewport": "^7.0.7",
"@storybook/blocks": "^7.0.7",
"@storybook/react": "^7.0.7",
"@storybook/react-webpack5": "^7.0.7",
"@storybook/testing-library": "^0.0.14-next.2",
"@swc/core": "^1.3.4",
"@swc/helpers": "^0.4.11",
"@tailwindcss/forms": "^0.5.3",
@@ -194,7 +179,7 @@
"@types/node-fetch": "^2.6.2",
"@types/prismjs": "^1.26.0",
"@types/qs": "^6.9.7",
"@types/react": "18.2.17",
"@types/react": "18.2.69",
"@types/react-collapse": "^5.0.4",
"@types/react-dom": "18.2.7",
"@types/semver": "^7.3.13",
@@ -220,9 +205,6 @@
"prettier-plugin-tailwindcss": "^0.3.0",
"prop-types": "^15.8.1",
"rimraf": "^3.0.2",
"storybook": "^7.0.7",
"storybook-addon-designs": "7.0.0-beta.2",
"storybook-addon-variants": "^0.2.0",
"style-loader": "^3.3.4",
"tailwind-scrollbar": "^3.0.1",
"tailwindcss": "3.4.1",
@@ -233,4 +215,4 @@
"engines": {
"node": ">=16.0.0"
}
}
}
+20 -16
View File
@@ -106,12 +106,14 @@ export const taskWithFetchRetries = task({
//if the Response is a 429 (too many requests), it will retry using the data from the response. A lot of good APIs send these headers.
const headersResponse = await retry.fetch("http://my.host/test-headers", {
retry: {
"429": {
strategy: "headers",
limitHeader: "x-ratelimit-limit",
remainingHeader: "x-ratelimit-remaining",
resetHeader: "x-ratelimit-reset",
resetFormat: "unix_timestamp_in_ms",
byStatus: {
"429": {
strategy: "headers",
limitHeader: "x-ratelimit-limit",
remainingHeader: "x-ratelimit-remaining",
resetHeader: "x-ratelimit-reset",
resetFormat: "unix_timestamp_in_ms",
},
},
},
});
@@ -121,13 +123,15 @@ export const taskWithFetchRetries = task({
//if the Response is a 500-599 (issue with the server you're calling), it will retry up to 10 times with exponential backoff
const backoffResponse = await retry.fetch("http://my.host/test-backoff", {
retry: {
"500-599": {
strategy: "backoff",
maxAttempts: 10,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 30_000,
randomize: false,
byStatus: {
"500-599": {
strategy: "backoff",
maxAttempts: 10,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 30_000,
randomize: false,
},
},
},
});
@@ -136,9 +140,9 @@ export const taskWithFetchRetries = task({
//You can additionally specify a timeout. In this case if the response takes longer than 1 second, it will retry up to 5 times with exponential backoff
const timeoutResponse = await retry.fetch("https://httpbin.org/delay/2", {
timeout: {
durationInMs: 1000,
retry: {
timeoutInMs: 1000,
retry: {
timeout: {
maxAttempts: 5,
factor: 1.8,
minTimeoutInMs: 500,
+17 -21
View File
@@ -1,6 +1,6 @@
{
"name": "trigger.dev",
"version": "1.0.7",
"version": "2.3.18",
"description": "A Command-Line Interface for Trigger.dev (v3) projects",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -35,7 +35,7 @@
"trigger.dev": "./dist/index.js"
},
"devDependencies": {
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"@trigger.dev/tsconfig": "workspace:*",
"@types/gradient-string": "^1.1.2",
"@types/jsonlines": "^0.1.5",
@@ -75,27 +75,21 @@
"test": "vitest"
},
"dependencies": {
"@baselime/node-opentelemetry": "^0.4.6",
"@clack/prompts": "^0.7.0",
"@depot/cli": "0.0.1-cli.2.55.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",
"@traceloop/instrumentation-openai": "^0.3.9",
"@trigger.dev/core": "workspace:*",
"@trigger.dev/core-apps": "workspace:*",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/api-logs": "^0.49.1",
"@opentelemetry/exporter-logs-otlp-http": "^0.49.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
"@opentelemetry/instrumentation": "^0.49.1",
"@opentelemetry/instrumentation-fetch": "^0.49.1",
"@opentelemetry/resources": "^1.22.0",
"@opentelemetry/sdk-logs": "^0.49.1",
"@opentelemetry/sdk-node": "^0.49.1",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/sdk-trace-node": "^1.22.0",
"@opentelemetry/semantic-conventions": "^1.22.0",
"@trigger.dev/core": "workspace:^2.3.18",
"@types/degit": "^2.8.3",
"chalk": "^5.2.0",
"chokidar": "^3.5.3",
@@ -107,6 +101,7 @@
"evt": "^2.4.13",
"execa": "^8.0.0",
"find-up": "^7.0.0",
"glob": "^10.3.10",
"gradient-string": "^2.0.2",
"import-meta-resolve": "^4.0.0",
"ink": "^4.4.1",
@@ -130,6 +125,7 @@
"supports-color": "^9.4.0",
"terminal-link": "^3.0.0",
"tiny-invariant": "^1.2.0",
"tsconfig-paths": "^4.2.0",
"update-check": "^1.5.4",
"url": "^0.11.1",
"ws": "^8.12.0",
+2 -5
View File
@@ -4,16 +4,13 @@ RUN apk add --no-cache dumb-init
WORKDIR /app
# Install dependencies
COPY --chown=node:node package.json package-lock.json ./
# copy all the files just in case anything is needed in postinstall
COPY --chown=node:node . .
RUN npm ci --no-fund --no-audit && npm cache clean --force
# Development or production stage builds upon the base stage
FROM base AS final
# Copy the rest of the application
COPY --chown=node:node . .
# Use ARG for build-time variables
ARG TRIGGER_PROJECT_ID
ARG TRIGGER_DEPLOYMENT_ID
+104 -18
View File
@@ -15,7 +15,7 @@ import { resolve as importResolve } from "import-meta-resolve";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join, relative } from "node:path";
import { setTimeout } from "node:timers/promises";
import terminalLink from "terminal-link";
import invariant from "tiny-invariant";
@@ -34,15 +34,23 @@ import {
import { readConfig } from "../utilities/configFiles.js";
import { createTempDir, readJSONFile, writeJSONFile } from "../utilities/fileSystem";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { detectPackageNameFromImportPath, parsePackageName } from "../utilities/installPackages";
import {
detectPackageNameFromImportPath,
parsePackageName,
stripWorkspaceFromVersion,
} from "../utilities/installPackages";
import { logger } from "../utilities/logger.js";
import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles";
import { login } from "./login";
import { SetOptional } from "type-fest";
import type { SetOptional } from "type-fest";
import { bundleDependenciesPlugin, workerSetupImportConfigPlugin } from "../utilities/build";
import { Glob } from "glob";
const DeployCommandOptions = CommonCommandOptions.extend({
skipTypecheck: z.boolean().default(false),
skipDeploy: z.boolean().default(false),
ignoreEnvVarCheck: z.boolean().default(false),
env: z.enum(["prod", "staging"]),
loadImage: z.boolean().default(false),
buildPlatform: z.enum(["linux/amd64", "linux/arm64"]).default("linux/amd64"),
@@ -68,7 +76,11 @@ export function configureDeployCommand(program: Command) {
"Deploy to a specific environment (currently only prod and staging are supported)",
"prod"
)
.option("-T, --skip-typecheck", "Whether to skip the pre-build typecheck")
.option("--skip-typecheck", "Whether to skip the pre-build typecheck")
.option(
"--ignore-env-var-check",
"Detected missing environment variables won't block deployment"
)
.option("-c, --config <config file>", "The name of the config file, found at [path]")
.option(
"-p, --project-ref <project ref>",
@@ -425,7 +437,11 @@ async function checkEnvVars(
environmentVariablesSpinner.stop(
`Found missing env vars in ${options.env}: ${arrayToSentence(
missingEnvironmentVariables
)}. Aborting deployment. ${chalk.bgBlueBright(
)}. ${
options.ignoreEnvVarCheck
? "Continuing deployment because of --ignore-env-var-check. "
: "Aborting deployment. "
}${chalk.bgBlueBright(
terminalLink(
"Manage env vars",
`${apiUrl}/projects/v3/${config.project}/environment-variables`
@@ -437,7 +453,12 @@ async function checkEnvVars(
"envVars.missing": missingEnvironmentVariables,
});
throw new SkipLoggingError("Found missing environment variables");
if (!options.ignoreEnvVarCheck) {
throw new SkipLoggingError("Found missing environment variables");
} else {
span.end();
return;
}
}
environmentVariablesSpinner.stop(`Environment variable check passed`);
@@ -859,7 +880,6 @@ async function compileProject(
write: false,
minify: false,
sourcemap: "external", // does not set the //# sourceMappingURL= comment in the file, we handle it ourselves
packages: "external", // https://esbuild.github.io/api/#packages
logLevel: "error",
platform: "node",
format: "cjs", // This is needed to support opentelemetry instrumentation that uses module patching
@@ -869,6 +889,7 @@ async function compileProject(
TRIGGER_API_URL: `"${config.triggerUrl}"`,
__PROJECT_CONFIG__: JSON.stringify(config),
},
plugins: [bundleDependenciesPlugin(config), workerSetupImportConfigPlugin(configPath)],
});
if (result.errors.length > 0) {
@@ -984,17 +1005,29 @@ async function compileProject(
// Get all the required dependencies from the metaOutputs and save them to /tmp/dir/package.json
const allImports = [...metaOutput.imports, ...entryPointMetaOutput.imports];
const dependencies = await gatherRequiredDependencies(allImports, config);
const externalPackageJson = await readJSONFile(join(config.projectDir, "package.json"));
const dependencies = await gatherRequiredDependencies(
allImports,
config,
externalPackageJson
);
const packageJsonContents = {
name: "trigger-worker",
version: "0.0.0",
description: "",
dependencies,
scripts: {
postinstall: externalPackageJson?.scripts?.postinstall,
},
};
await writeJSONFile(join(tempDir, "package.json"), packageJsonContents);
await copyAdditionalFiles(config, tempDir);
compileSpinner.stop("Project built successfully");
const resolvingDependenciesResult = await resolveDependencies(
@@ -1112,7 +1145,7 @@ async function resolveDependencies(
logger.debug(`No cached package-lock.json found for ${digest}`);
try {
await execa("npm", ["install", "--package-lock-only"], {
await execa("npm", ["install", "--package-lock-only", "--ignore-scripts", "--no-audit"], {
cwd: projectDir,
stdio: logger.loggerLevel === "debug" ? "inherit" : "pipe",
});
@@ -1207,10 +1240,9 @@ async function typecheckProject(config: ResolvedConfig, options: DeployCommandOp
// Returns the dependency names and the version to use (taken from the CLI deps package.json)
async function gatherRequiredDependencies(
imports: Metafile["outputs"][string]["imports"],
config: ResolvedConfig
config: ResolvedConfig,
projectPackageJson: any
) {
const externalPackageJson = await readJSONFile(join(config.projectDir, "package.json"));
const dependencies: Record<string, string> = {};
for (const file of imports) {
@@ -1224,10 +1256,10 @@ async function gatherRequiredDependencies(
continue;
}
const externalDependencyVersion = (externalPackageJson?.dependencies ?? {})[packageName];
const externalDependencyVersion = (projectPackageJson?.dependencies ?? {})[packageName];
if (externalDependencyVersion) {
dependencies[packageName] = externalDependencyVersion;
dependencies[packageName] = stripWorkspaceFromVersion(externalDependencyVersion);
continue;
}
@@ -1236,7 +1268,7 @@ async function gatherRequiredDependencies(
detectDependencyVersion(packageName);
if (internalDependencyVersion) {
dependencies[packageName] = internalDependencyVersion;
dependencies[packageName] = stripWorkspaceFromVersion(internalDependencyVersion);
}
}
@@ -1253,8 +1285,8 @@ async function gatherRequiredDependencies(
continue;
} else {
const externalDependencyVersion = {
...externalPackageJson?.devDependencies,
...externalPackageJson?.dependencies,
...projectPackageJson?.devDependencies,
...projectPackageJson?.dependencies,
}[packageName];
if (externalDependencyVersion) {
@@ -1273,6 +1305,56 @@ async function gatherRequiredDependencies(
return Object.fromEntries(Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b)));
}
async function copyAdditionalFiles(config: ResolvedConfig, tempDir: string) {
const additionalFiles = config.additionalFiles ?? [];
if (additionalFiles.length === 0) {
return;
}
return await tracer.startActiveSpan(
"copyAdditionalFiles",
{
attributes: {
"config.additionalFiles": additionalFiles,
},
},
async (span) => {
try {
logger.debug(`Copying files to ${tempDir}`, {
additionalFiles,
});
const glob = new Glob(additionalFiles, {
withFileTypes: true,
ignore: ["node_modules"],
cwd: config.projectDir,
nodir: true,
});
for await (const file of glob) {
const relativeDestinationPath = join(
tempDir,
relative(config.projectDir, file.fullpath())
);
logger.debug(`Copying file ${file.fullpath()} to ${relativeDestinationPath}`);
await mkdir(dirname(relativeDestinationPath), { recursive: true });
await copyFile(file.fullpath(), relativeDestinationPath);
}
span.end();
} catch (error) {
recordSpanException(span, error);
span.end();
throw error;
}
}
);
}
async function ensureLoggedIntoDockerRegistry(
registryHost: string,
auth: { username: string; password: string }
@@ -1300,6 +1382,8 @@ async function findAllEnvironmentVariableReferencesInFile(filePath: string) {
return findAllEnvironmentVariableReferences(fileContents);
}
const IGNORED_ENV_VARS = ["NODE_ENV", "SHELL", "HOME", "PWD", "LOGNAME", "USER", "PATH"];
function findAllEnvironmentVariableReferences(code: string): string[] {
const regex = /\bprocess\.env\.([a-zA-Z_][a-zA-Z0-9_]*)\b/g;
@@ -1307,8 +1391,10 @@ function findAllEnvironmentVariableReferences(code: string): string[] {
const matchesArray = Array.from(matches, (match) => match[1]).filter(Boolean) as string[];
const filteredMatches = matchesArray.filter((match) => !IGNORED_ENV_VARS.includes(match));
// Make sure and remove duplicates
return Array.from(new Set(matchesArray));
return Array.from(new Set(filteredMatches));
}
function arrayToSentence(items: string[]): string {
+58 -55
View File
@@ -13,7 +13,7 @@ import { watch } from "chokidar";
import { Command } from "commander";
import { BuildContext, Metafile, context } from "esbuild";
import { resolve as importResolve } from "import-meta-resolve";
import { Box, Text, render, useApp, useInput } from "ink";
import { render, useInput } from "ink";
import { createHash } from "node:crypto";
import fs, { readFileSync } from "node:fs";
import { ClientRequestArgs } from "node:http";
@@ -26,10 +26,16 @@ import { z } from "zod";
import * as packageJson from "../../package.json";
import { CliApiClient } from "../apiClient";
import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js";
import { bundleDependenciesPlugin, workerSetupImportConfigPlugin } from "../utilities/build";
import { chalkPurple } from "../utilities/colors";
import { readConfig } from "../utilities/configFiles";
import { readJSONFile } from "../utilities/fileSystem";
import { printStandloneInitialBanner } from "../utilities/initialBanner.js";
import { detectPackageNameFromImportPath, parsePackageName } from "../utilities/installPackages";
import {
detectPackageNameFromImportPath,
parsePackageName,
stripWorkspaceFromVersion,
} from "../utilities/installPackages";
import { logger } from "../utilities/logger.js";
import { isLoggedIn } from "../utilities/session.js";
import { createTaskFileImports, gatherTaskFiles } from "../utilities/taskFiles";
@@ -124,7 +130,17 @@ async function startDev(
});
if (!devEnv.success) {
throw new Error(devEnv.error);
if (devEnv.error === "Project not found") {
logger.error(
`Project not found: ${config.config.project}. Ensure you are using the correct project ref and CLI profile (use --profile). Currently using the "${options.profile}" profile, which points to ${authorization.apiUrl}`
);
} else {
logger.error(
`Failed to initialize dev environment: ${devEnv.error}. Using project ref ${config.config.project}`
);
}
process.exit(1);
}
const environmentClient = new CliApiClient(apiUrl, devEnv.data.apiKey);
@@ -283,7 +299,7 @@ function useDev({
async function runBuild() {
if (ctx) {
await ctx.cancel();
// This will stop the watching
await ctx.dispose();
}
@@ -336,7 +352,6 @@ function useDev({
write: false,
minify: false,
sourcemap: "external", // does not set the //# sourceMappingURL= comment in the file, we handle it ourselves
packages: "external", // https://esbuild.github.io/api/#packages
logLevel: "error",
platform: "node",
format: "cjs", // This is needed to support opentelemetry instrumentation that uses module patching
@@ -347,6 +362,8 @@ function useDev({
__PROJECT_CONFIG__: JSON.stringify(config),
},
plugins: [
bundleDependenciesPlugin(config),
workerSetupImportConfigPlugin(configPath),
{
name: "trigger.dev v3",
setup(build) {
@@ -458,6 +475,19 @@ function useDev({
throw new Error(`Background Worker started without package version`);
}
// Check for any duplicate task ids
const taskIds = taskResources.map((task) => task.id);
const duplicateTaskIds = taskIds.filter(
(id, index) => taskIds.indexOf(id) !== index
);
if (duplicateTaskIds.length > 0) {
logger.error(
createDuplicateTaskIdOutputErrorMessage(duplicateTaskIds, taskResources)
);
return;
}
const backgroundWorkerBody: CreateBackgroundWorkerRequestBody = {
localOnly: true,
metadata: {
@@ -526,8 +556,9 @@ function useDev({
}
const throttle = pThrottle({
limit: 2,
limit: 1,
interval: 1000,
strict: true,
});
const throttledRebuild = throttle(runBuild);
@@ -539,7 +570,7 @@ function useDev({
}
);
taskFileWatcher.on("add", async (path) => {
taskFileWatcher.on("change", async (path) => {
throttledRebuild().catch((error) => {
logger.error(error);
});
@@ -588,58 +619,13 @@ function DevUIImp(props: DevProps) {
}
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;
}
});
useInput(async (input, key) => {});
}
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>
);
return <></>;
}
function WebsocketFactory(apiKey: string) {
@@ -674,7 +660,7 @@ async function gatherRequiredDependencies(
detectDependencyVersion(packageName);
if (internalDependencyVersion) {
dependencies[packageName] = internalDependencyVersion;
dependencies[packageName] = stripWorkspaceFromVersion(internalDependencyVersion);
}
}
@@ -712,6 +698,23 @@ async function gatherRequiredDependencies(
return dependencies;
}
function createDuplicateTaskIdOutputErrorMessage(
duplicateTaskIds: Array<string>,
taskResources: Array<TaskResource>
) {
const duplicateTable = duplicateTaskIds
.map((id) => {
const tasks = taskResources.filter((task) => task.id === id);
return `id "${chalkPurple(id)}" was found in:\n${tasks
.map((task) => `${task.filePath} -> ${task.exportName}`)
.join("\n")}`;
})
.join("\n\n");
return `Duplicate task ids detected:\n\n${duplicateTable}\n\n`;
}
function gatherProcessEnv() {
const env = {
NODE_ENV: process.env.NODE_ENV ?? "development",
+19 -15
View File
@@ -52,10 +52,6 @@ export function configureInitCommand(program: Command) {
"-p, --project-ref <project ref>",
"The project ref to use when initializing the project"
)
.option(
"-p, --project-ref <project ref>",
"The project ref to use when initializing the project"
)
.option(
"-t, --tag <package tag>",
"The version of the @trigger.dev/sdk package to install",
@@ -145,11 +141,11 @@ async function _initCommand(dir: string, options: InitCommandOptions) {
log.info("Skipping package installation");
}
// Create the config file
await writeConfigFile(dir, selectedProject, options);
// Create the trigger dir
await createTriggerDir(dir, options);
const triggerDir = await createTriggerDir(dir, options);
// Create the config file
await writeConfigFile(dir, selectedProject, options, triggerDir);
// Add trigger.config.ts to tsconfig.json
await addConfigFileToTsConfig(dir, options);
@@ -166,7 +162,7 @@ async function _initCommand(dir: string, options: InitCommandOptions) {
log.info("Next steps:");
log.info(
` 1. To start developing, run ${chalk.green(
"npx trigger.dev@latest dev"
`npx trigger.dev@${options.tag} dev`
)} in your project directory`
);
log.info(` 2. Visit your ${projectDashboard} to view your newly created tasks.`);
@@ -189,10 +185,12 @@ async function _initCommand(dir: string, options: InitCommandOptions) {
async function createTriggerDir(dir: string, options: InitCommandOptions) {
return await tracer.startActiveSpan("createTriggerDir", async (span) => {
try {
const defaultValue = `${dir}/src/trigger`;
const location = await text({
message: "Where would you like to create the Trigger.dev directory?",
defaultValue: `${dir}/src/trigger`,
placeholder: `${dir}/src/trigger`,
defaultValue: defaultValue,
placeholder: defaultValue,
});
if (isCancel(location)) {
@@ -238,10 +236,10 @@ async function createTriggerDir(dir: string, options: InitCommandOptions) {
log.step(`Created directory at ${location}`);
span.end();
return;
return { location, isCustomValue: location !== defaultValue };
}
const exampleFile = resolveInternalFilePath(`./templates/examples/${example}.js`);
const exampleFile = resolveInternalFilePath(`./templates/examples/${example}.ts.template`);
const outputPath = join(triggerDir, "example.ts");
await createFileFromTemplate({
@@ -255,6 +253,8 @@ async function createTriggerDir(dir: string, options: InitCommandOptions) {
log.step(`Created example file at ${relativeOutputPath}`);
span.end();
return { location, isCustomValue: location !== defaultValue };
} catch (e) {
if (!(e instanceof SkipCommandError)) {
recordSpanException(span, e);
@@ -431,7 +431,8 @@ async function installPackages(dir: string, options: InitCommandOptions) {
async function writeConfigFile(
dir: string,
project: GetProjectResponseBody,
options: InitCommandOptions
options: InitCommandOptions,
triggerDir: { location: string; isCustomValue: boolean }
) {
return await tracer.startActiveSpan("writeConfigFile", async (span) => {
try {
@@ -439,7 +440,7 @@ async function writeConfigFile(
spnnr.start("Creating config file");
const projectDir = resolve(process.cwd(), dir);
const templatePath = resolveInternalFilePath("./templates/trigger.config.ts");
const templatePath = resolveInternalFilePath("./templates/trigger.config.ts.template");
const outputPath = join(projectDir, "trigger.config.ts");
span.setAttributes({
@@ -452,6 +453,9 @@ async function writeConfigFile(
templatePath,
replacements: {
projectRef: project.externalRef,
triggerDirectoriesOption: triggerDir.isCustomValue
? `\n triggerDirectories: ["${triggerDir.location}"],`
: "",
},
outputPath,
override: options.overrideConfig,
+51 -12
View File
@@ -1,20 +1,59 @@
import { BaselimeSDK } from "@baselime/node-opentelemetry";
import { trace } from "@opentelemetry/api";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { Resource, detectResourcesSync, processDetectorSync } from "@opentelemetry/resources";
import { NodeTracerProvider, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-node";
import { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
import { DiagConsoleLogger, DiagLogLevel, diag, trace } from "@opentelemetry/api";
import * as packageJson from "../../package.json";
const sdk = new BaselimeSDK({
baselimeKey: "e9f963244f8b092850d42e34a5339b2d5e68070b".split("").reverse().join(""), // this is a joke
instrumentations: [new FetchInstrumentation()],
service: "cli-v3",
serverless: true,
});
function initializeTracing(): NodeTracerProvider | undefined {
if (!process.argv.includes("--skip-telemetry")) {
return sdk.start();
if (process.argv.includes("--skip-telemetry") || process.env.TRIGGER_DEV_SKIP_TELEMETRY) {
return;
}
if (process.env.OTEL_INTERNAL_DIAG_DEBUG) {
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
}
const resource = detectResourcesSync({
detectors: [processDetectorSync],
}).merge(
new Resource({
service: "trigger.dev cli v3",
})
);
const traceProvider = new NodeTracerProvider({
forceFlushTimeoutMillis: 500,
resource,
spanLimits: {
attributeCountLimit: 1000,
attributeValueLengthLimit: 1000,
eventCountLimit: 100,
attributePerEventCountLimit: 100,
linkCountLimit: 10,
attributePerLinkCountLimit: 100,
},
});
const spanExporter = new OTLPTraceExporter({
url: "https://otel.baselime.io/v1",
timeoutMillis: 500,
headers: {
"x-api-key": "e9f963244f8b092850d42e34a5339b2d5e68070b".split("").reverse().join(""), // this is a joke
},
});
const spanProcessor = new SimpleSpanProcessor(spanExporter);
traceProvider.addSpanProcessor(spanProcessor);
traceProvider.register();
registerInstrumentations({
instrumentations: [new FetchInstrumentation()],
});
return traceProvider;
}
export const provider = initializeTracing();
@@ -1,6 +1,6 @@
import type { ProjectConfig } from "@trigger.dev/core/v3";
import type { TriggerConfig } from "@trigger.dev/sdk/v3";
export const config: ProjectConfig = {
export const config: TriggerConfig = {
project: "${projectRef}",
retries: {
enabledInDev: false,
@@ -11,5 +11,5 @@ export const config: ProjectConfig = {
factor: 2,
randomize: true,
},
},
},${triggerDirectoriesOption}
};
+198
View File
@@ -0,0 +1,198 @@
import { ResolvedConfig } from "@trigger.dev/core/v3";
import type * as esbuild from "esbuild";
import type { Plugin } from "esbuild";
import { extname, isAbsolute } from "node:path";
import tsConfigPaths from "tsconfig-paths";
import { logger } from "./logger";
import { readFileSync } from "node:fs";
export function workerSetupImportConfigPlugin(configPath?: string): Plugin {
return {
name: "trigger-worker-setup",
setup(build) {
if (!configPath) {
return;
}
build.onLoad({ filter: /worker-setup\.js$/ }, async (args) => {
let workerSetupContents = readFileSync(args.path, "utf-8");
workerSetupContents = workerSetupContents.replace(
"__SETUP_IMPORTED_PROJECT_CONFIG__",
`import * as setupImportedConfigExports from "${configPath}"; const setupImportedConfig = setupImportedConfigExports.config;`
);
logger.debug("Loading worker setup", {
args,
workerSetupContents,
configPath,
});
return {
contents: workerSetupContents,
loader: "js",
};
});
},
};
}
export function bundleDependenciesPlugin(config: ResolvedConfig): Plugin {
const matchPath = config.tsconfigPath ? createMatchPath(config.tsconfigPath) : undefined;
function resolvePath(id: string) {
if (!matchPath) {
return id;
}
return matchPath(id, undefined, undefined, [".ts", ".tsx", ".js", ".jsx"]) || id;
}
return {
name: "trigger-bundle-dependencies",
setup(build) {
build.onResolve({ filter: /.*/ }, (args) => {
const resolvedPath = resolvePath(args.path);
logger.ignore(`Checking if ${args.path} should be bundled or external`, {
...args,
resolvedPath,
});
if (!isBareModuleId(resolvedPath)) {
logger.ignore(`Bundling ${args.path} because its not a bareModuleId`, {
...args,
});
return undefined; // let esbuild bundle it
}
if (args.path.startsWith("@trigger.dev/")) {
logger.ignore(`Bundling ${args.path} because its a trigger.dev package`, {
...args,
});
return undefined; // let esbuild bundle it
}
// Skip assets that are treated as files (.css, .svg, .png, etc.).
// Otherwise, esbuild would emit code that would attempt to require()
// or import these files --- which aren't JavaScript!
let loader;
try {
loader = getLoaderForFile(args.path);
} catch (e) {
if (!(e instanceof Error && e.message.startsWith("Cannot get loader for file"))) {
throw e;
}
}
if (loader === "file") {
return undefined;
}
for (let pattern of config.dependenciesToBundle ?? []) {
if (typeof pattern === "string" ? args.path === pattern : pattern.test(args.path)) {
return undefined; // let esbuild bundle it
}
}
logger.ignore(`Externalizing ${args.path}`, {
...args,
});
// Everything else should be external
return {
path: args.path,
external: true,
};
});
},
};
}
function isBareModuleId(id: string): boolean {
return !id.startsWith("node:") && !id.startsWith(".") && !isAbsolute(id);
}
export function createMatchPath(tsconfigPath: string | undefined) {
// There is no tsconfig to match paths against.
if (!tsconfigPath) {
return undefined;
}
// When passing a absolute path, loadConfig assumes that the path contains
// a tsconfig file.
// Ref.: https://github.com/dividab/tsconfig-paths/blob/v4.0.0/src/__tests__/config-loader.test.ts#L74
let configLoaderResult = tsConfigPaths.loadConfig(tsconfigPath);
if (configLoaderResult.resultType === "failed") {
if (configLoaderResult.message === "Missing baseUrl in compilerOptions") {
throw new Error(
`🚨 Oops! No baseUrl found, please set compilerOptions.baseUrl in your tsconfig or jsconfig`
);
}
return undefined;
}
return tsConfigPaths.createMatchPath(
configLoaderResult.absoluteBaseUrl,
configLoaderResult.paths,
configLoaderResult.mainFields,
configLoaderResult.addMatchAll
);
}
const loaders: { [ext: string]: esbuild.Loader } = {
".aac": "file",
".avif": "file",
".css": "file",
".csv": "file",
".eot": "file",
".fbx": "file",
".flac": "file",
".gif": "file",
".glb": "file",
".gltf": "file",
".gql": "text",
".graphql": "text",
".hdr": "file",
".ico": "file",
".jpeg": "file",
".jpg": "file",
".js": "jsx",
".jsx": "jsx",
".json": "json",
// We preprocess md and mdx files using @mdx-js/mdx and send through
// the JSX for esbuild to handle
".md": "jsx",
".mdx": "jsx",
".mov": "file",
".mp3": "file",
".mp4": "file",
".node": "copy",
".ogg": "file",
".otf": "file",
".png": "file",
".psd": "file",
".sql": "text",
".svg": "file",
".ts": "ts",
".tsx": "tsx",
".ttf": "file",
".wasm": "file",
".wav": "file",
".webm": "file",
".webmanifest": "file",
".webp": "file",
".woff": "file",
".woff2": "file",
".zip": "file",
};
export function getLoaderForFile(file: string): esbuild.Loader {
const ext = extname(file);
const loader = loaders[ext];
if (loader) return loader;
throw new Error(`Cannot get loader for file ${file}`);
}
+6 -2
View File
@@ -131,7 +131,7 @@ export async function readConfig(
const tempDir = await createTempDir();
const builtConfigFilePath = join(tempDir, "config.mjs");
const builtConfigFilePath = join(tempDir, "config.js");
const builtConfigFileHref = pathToFileURL(builtConfigFilePath).href;
logger.debug("Building config file", {
@@ -147,7 +147,7 @@ export async function readConfig(
metafile: true,
minify: false,
write: true,
format: "esm",
format: "cjs",
platform: "node",
target: ["es2018", "node18"],
outfile: builtConfigFilePath,
@@ -183,6 +183,10 @@ export async function resolveConfig(path: string, config: Config): Promise<Resol
config.projectDir = path;
}
if (!config.tsconfigPath) {
config.tsconfigPath = await getConfigPath(path, "tsconfig.json");
}
return config as ResolvedConfig;
}
@@ -62,6 +62,18 @@ export function detectPackageNameFromImportPath(path: string): string {
}
}
/**
* Removes the workspace prefix from a version string.
* @param version - The version string to strip the workspace prefix from.
* @returns The version string without the workspace prefix.
* @example
* stripWorkspaceFromVersion("workspace:1.0.0") // "1.0.0"
* stripWorkspaceFromVersion("1.0.0") // "1.0.0"
*/
export function stripWorkspaceFromVersion(version: string) {
return version.replace(/^workspace:/, "");
}
export function parsePackageName(packageSpecifier: string): { name: string; version?: string } {
const parts = packageSpecifier.split("@");
+1
View File
@@ -55,6 +55,7 @@ export class Logger {
columns = process.stdout.columns;
debug = (...args: unknown[]) => this.doLog("debug", args);
ignore = (...args: unknown[]) => {};
debugWithSanitization = (label: string, ...args: unknown[]) => {
this.doLog("debug", [label, ...args]);
};
+1 -1
View File
@@ -30,7 +30,7 @@ export async function gatherTaskFiles(config: ResolvedConfig): Promise<Array<Tas
const filePath = relative(config.projectDir, fullPath);
const importPath = filePath.replace(/\.(js|ts)$/, "");
const importName = importPath.replace(/\//g, "_");
const importName = importPath.replace(/\//g, "_").replace(/\./g, "_").replace(/-/g, "_");
taskFiles.push({ triggerDir, importPath, importName, filePath });
}
@@ -364,6 +364,7 @@ export class BackgroundWorker {
if (!this._taskRunProcesses.has(payload.execution.run.id)) {
const taskRunProcess = new TaskRunProcess(
payload.execution.run.id,
this.path,
{
...this.params.env,
@@ -520,6 +521,7 @@ class TaskRunProcess {
public onExit: Evt<number> = new Evt();
constructor(
private runId: string,
private path: string,
private env: NodeJS.ProcessEnv,
private metadata: BackgroundWorkerProperties,
@@ -529,7 +531,7 @@ class TaskRunProcess {
schema: workerToChildMessages,
sender: async (message) => {
if (this._child?.connected && !this._isBeingKilled && !this._child.killed) {
this._child?.send?.(message);
this._child.send(message);
}
},
});
@@ -542,10 +544,9 @@ class TaskRunProcess {
}
async initialize() {
logger.debug("initializing task run process", {
logger.debug(`[${this.runId}] initializing task run process`, {
env: this.env,
path: this.path,
processEnv: process.env,
});
this._child = fork(this.path, {
@@ -575,6 +576,8 @@ class TaskRunProcess {
return;
}
logger.debug(`[${this.runId}] cleaning up task run process`, { kill });
await this._sender.send("CLEANUP", {
flush: true,
kill,
@@ -619,6 +622,13 @@ class TaskRunProcess {
return;
}
if (execution.run.id === this.runId) {
// We don't need to notify the task run process if it's the same as the one we're running
return;
}
logger.debug(`[${this.runId}] task run completed notification`, { completion, execution });
this._sender.send("TASK_RUN_COMPLETED_NOTIFICATION", {
completion,
execution,
@@ -669,6 +679,8 @@ class TaskRunProcess {
}
async #handleExit(code: number) {
logger.debug(`[${this.runId}] task run process exiting`, { code });
// Go through all the attempts currently pending and reject them
for (const [id, status] of this._attemptStatuses.entries()) {
if (status === "PENDING") {
@@ -6,7 +6,6 @@ import {
type TracingSDK,
type HandleErrorFunction,
} from "@trigger.dev/core/v3";
import "source-map-support/register.js";
__WORKER_SETUP__;
declare const __WORKER_SETUP__: unknown;
@@ -1,6 +1,7 @@
import "source-map-support/register.js";
import { Resource } from "@opentelemetry/resources";
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
import {
ProjectConfig,
SemanticInternalAttributes,
TracingDiagnosticLogLevel,
TracingSDK,
@@ -8,12 +9,16 @@ import {
childToWorkerMessages,
} from "@trigger.dev/core/v3";
__SETUP_IMPORTED_PROJECT_CONFIG__;
declare const __SETUP_IMPORTED_PROJECT_CONFIG__: unknown;
declare const setupImportedConfig: ProjectConfig | undefined;
export const tracingSDK = new TracingSDK({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
resource: new Resource({
[SemanticInternalAttributes.CLI_VERSION]: "3.0.0",
}),
instrumentations: [new OpenAIInstrumentation()],
instrumentations: setupImportedConfig?.instrumentations ?? [],
diagLogLevel: (process.env.OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none",
});
@@ -1,19 +1,21 @@
import { Resource } from "@opentelemetry/resources";
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
import {
ProjectConfig,
SemanticInternalAttributes,
TracingDiagnosticLogLevel,
TracingSDK,
ZodMessageSender,
childToWorkerMessages,
} from "@trigger.dev/core/v3";
__SETUP_IMPORTED_PROJECT_CONFIG__;
declare const __SETUP_IMPORTED_PROJECT_CONFIG__: unknown;
declare const setupImportedConfig: ProjectConfig | undefined;
export const tracingSDK = new TracingSDK({
url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
resource: new Resource({
[SemanticInternalAttributes.CLI_VERSION]: "3.0.0",
}),
instrumentations: [new OpenAIInstrumentation()],
instrumentations: setupImportedConfig?.instrumentations ?? [],
diagLogLevel: (process.env.OTEL_LOG_LEVEL as TracingDiagnosticLogLevel) ?? "none",
});
+1 -1
View File
@@ -17,6 +17,6 @@ export default defineConfig({
outDir: "dist",
onSuccess: isDev ? `${copyTemplates} && node dist/index.js` : copyTemplates,
banner: {
js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);",
js: "import { createRequire as createRequireFromMetaUrl } from 'node:module';const require = createRequireFromMetaUrl(import.meta.url);",
},
});
+1
View File
@@ -18,4 +18,5 @@ export default defineConfig({
sourcemap: false,
target: "esnext",
outDir: "dist/workers",
noExternal: ["@trigger.dev/core-apps"],
});
+1
View File
@@ -1,6 +1,7 @@
{
"name": "@trigger.dev/core-apps",
"description": "Backend core code used across apps",
"version": "0.0.0",
"private": true,
"license": "MIT",
"main": "./dist/index.js",
+1 -1
View File
@@ -46,6 +46,6 @@
"node": ">=18.0.0"
},
"dependencies": {
"@opentelemetry/api": "^1.7.0"
"@opentelemetry/api": "^1.8.0"
}
}
+10 -14
View File
@@ -59,21 +59,17 @@
},
"dependencies": {
"@google-cloud/precise-date": "^4.0.0",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/api": "^1.8.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",
"@opentelemetry/exporter-logs-otlp-http": "^0.49.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.49.1",
"@opentelemetry/instrumentation": "^0.49.1",
"@opentelemetry/resources": "^1.22.0",
"@opentelemetry/sdk-logs": "^0.49.1",
"@opentelemetry/sdk-node": "^0.49.1",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/sdk-trace-node": "^1.22.0",
"@opentelemetry/semantic-conventions": "^1.22.0",
"humanize-duration": "^3.27.3",
"socket.io": "^4.7.4",
"socket.io-client": "^4.7.4",
-1
View File
@@ -1,7 +1,6 @@
import { Span, SpanStatusCode } from "@opentelemetry/api";
export { TracingSDK, type TracingSDKConfig, type TracingDiagnosticLogLevel } from "./tracingSDK";
export { HttpInstrumentation, FetchInstrumentation } from "./instrumentations";
export function recordSpanException(span: Span, error: unknown) {
if (error instanceof Error) {
@@ -1,2 +0,0 @@
export { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
export { FetchInstrumentation } from "@opentelemetry/instrumentation-fetch";
@@ -20,6 +20,8 @@ export class DevRuntimeManager implements RuntimeManager {
_tasks: Map<string, TaskMetadataWithFilePath> = new Map();
_pendingCompletionNotifications: Map<string, TaskRunExecutionResult> = new Map();
disable(): void {
// do nothing
}
@@ -47,6 +49,14 @@ export class DevRuntimeManager implements RuntimeManager {
}
async waitForTask(params: { id: string; ctx: TaskRunContext }): Promise<TaskRunExecutionResult> {
const pendingCompletion = this._pendingCompletionNotifications.get(params.id);
if (pendingCompletion) {
this._pendingCompletionNotifications.delete(params.id);
return pendingCompletion;
}
const promise = new Promise<TaskRunExecutionResult>((resolve, reject) => {
this._taskWaits.set(params.id, { resolve, reject });
});
@@ -66,6 +76,20 @@ export class DevRuntimeManager implements RuntimeManager {
const promise = Promise.all(
params.runs.map((runId) => {
return new Promise<TaskRunExecutionResult>((resolve, reject) => {
const pendingCompletion = this._pendingCompletionNotifications.get(runId);
if (pendingCompletion) {
this._pendingCompletionNotifications.delete(runId);
if (pendingCompletion.ok) {
resolve(pendingCompletion);
} else {
reject(pendingCompletion);
}
return;
}
this._taskWaits.set(runId, { resolve, reject });
});
})
@@ -83,6 +107,9 @@ export class DevRuntimeManager implements RuntimeManager {
const wait = this._taskWaits.get(execution.run.id);
if (!wait) {
// We need to store the completion in case the task is awaited later
this._pendingCompletionNotifications.set(execution.run.id, completion);
return;
}
+16 -2
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { RetryOptions } from "./messages";
import { EventFilter } from "./eventFilter";
import { Prettify } from "../types";
export const FetchRetryHeadersStrategy = z.object({
/** The `headers` strategy retries the request using info from the response headers. */
@@ -45,14 +46,14 @@ export const FetchRetryStrategy = z.discriminatedUnion("strategy", [
export type FetchRetryStrategy = z.infer<typeof FetchRetryStrategy>;
export const FetchRetryOptions = z.record(FetchRetryStrategy);
export const FetchRetryByStatusOptions = z.record(z.string(), FetchRetryStrategy);
/** An object where the key is a status code pattern and the value is a retrying strategy. Supported patterns are:
- Specific status codes: 429
- Ranges: 500-599
- Wildcards: 2xx, 3xx, 4xx, 5xx
*/
export type FetchRetryOptions = z.infer<typeof FetchRetryOptions>;
export type FetchRetryByStatusOptions = Prettify<z.infer<typeof FetchRetryByStatusOptions>>;
export const FetchTimeoutOptions = z.object({
/** The maximum time to wait for the request to complete. */
@@ -61,3 +62,16 @@ export const FetchTimeoutOptions = z.object({
});
export type FetchTimeoutOptions = z.infer<typeof FetchTimeoutOptions>;
export const FetchRetryOptions = z.object({
/** The retrying strategy for specific status codes. */
byStatus: FetchRetryByStatusOptions.optional(),
/** The timeout options for the request. */
timeout: RetryOptions.optional(),
/**
* The retrying strategy for connection errors.
*/
connectionError: RetryOptions.optional(),
});
export type FetchRetryOptions = Prettify<z.infer<typeof FetchRetryOptions>>;
+13 -1
View File
@@ -10,11 +10,21 @@ import {
} from "./messages";
import { TaskResource } from "./resources";
const RegexSchema = z.custom<RegExp>((val) => {
try {
// Check to see if val is a regex
return typeof (val as RegExp).test === "function";
} catch {
return false;
}
});
export const Config = z.object({
project: z.string(),
triggerDirectories: z.string().array().optional(),
triggerUrl: z.string().optional(),
projectDir: z.string().optional(),
tsconfigPath: z.string().optional(),
retries: z
.object({
enabledInDev: z.boolean().default(true),
@@ -22,12 +32,14 @@ export const Config = z.object({
})
.optional(),
additionalPackages: z.string().array().optional(),
additionalFiles: z.string().array().optional(),
dependenciesToBundle: z.array(z.union([z.string(), RegexSchema])).optional(),
});
export type Config = z.infer<typeof Config>;
export type ResolvedConfig = RequireKeys<
Config,
"triggerDirectories" | "triggerUrl" | "projectDir"
"triggerDirectories" | "triggerUrl" | "projectDir" | "tsconfigPath"
>;
export const Machine = z.object({
+22
View File
@@ -1,4 +1,5 @@
import { RetryOptions } from "../schemas";
import type { InstrumentationOption } from "@opentelemetry/instrumentation";
export interface ProjectConfig {
project: string;
@@ -9,4 +10,25 @@ export interface ProjectConfig {
default?: RetryOptions;
};
additionalPackages?: string[];
/**
* List of additional files to include in your trigger.dev bundle. e.g. ["./prisma/schema.prisma"]
*
* Supports glob patterns.
*/
additionalFiles?: string[];
/**
* List of patterns that determine if a module is included in your trigger.dev bundle. This is needed when consuming ESM only packages, since the trigger.dev bundle is currently built as a CJS module.
*/
dependenciesToBundle?: Array<string | RegExp>;
/**
* The path to your project's tsconfig.json file. Will use tsconfig.json in the project directory if not provided.
*/
tsconfigPath?: string;
/**
* The OpenTelemetry instrumentations to enable
*/
instrumentations?: InstrumentationOption[];
}
+34 -26
View File
@@ -32,9 +32,9 @@ export function flattenAttributes(
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}`));
Object.assign(result, flattenAttributes(value[i], `${newPrefix}.[${i}]`));
} else {
result[`${newPrefix}.${i}`] = value[i];
result[`${newPrefix}.[${i}]`] = value[i];
}
}
} else if (isRecord(value)) {
@@ -55,46 +55,54 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
export function unflattenAttributes(obj: Attributes): Record<string, unknown> {
if (
obj === null ||
obj === undefined ||
typeof obj === "string" ||
typeof obj === "number" ||
typeof obj === "boolean" ||
Array.isArray(obj)
) {
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return obj;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const parts = key.split(".");
let current = result;
const parts = key.split(".").reduce((acc, part) => {
// Splitting array indices as separate parts
if (detectIsArrayIndex(part)) {
acc.push(part);
} else {
acc.push(...part.split(/\.\[(.*?)\]/).filter(Boolean));
}
return acc;
}, [] as string[]);
let current: Record<string, unknown> = result;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
// Check if part is not undefined and it's a string.
if (typeof part === "string") {
const nextPart = parts[i + 1];
const isArray = nextPart ? parseInt(nextPart, 10).toString() === nextPart : false;
if (current[part] == null) {
current[part] = isArray ? [] : {};
}
current = current[part] as Record<string, unknown>;
const isArray = detectIsArrayIndex(part);
const cleanPart = isArray ? part.substring(1, part.length - 1) : part;
const nextIsArray = detectIsArrayIndex(parts[i + 1]);
if (!current[cleanPart]) {
current[cleanPart] = nextIsArray ? [] : {};
}
current = current[cleanPart] as Record<string, unknown>;
}
// For the last element, we must ensure we also check if it is not undefined and it's a string.
const lastPart = parts[parts.length - 1];
if (typeof lastPart === "string") {
current[lastPart] = value;
}
const cleanLastPart = detectIsArrayIndex(lastPart)
? parseInt(lastPart.substring(1, lastPart.length - 1), 10)
: lastPart;
current[cleanLastPart] = value;
}
return result;
}
function detectIsArrayIndex(key: string): boolean {
const match = key.match(/^\[(\d+)\]$/);
if (match) {
return true;
}
return false;
}
export function primitiveValueOrflattenedAttributes(
obj: Record<string, unknown> | Array<unknown> | string | boolean | number | undefined,
prefix: string | undefined
+12 -1
View File
@@ -1,5 +1,5 @@
import { type RetryOptions } from "../schemas";
import { calculateResetAt as calculateResetAtInternal } from "../../retry";
import { FetchRetryOptions, type RetryOptions } from "../schemas";
export const defaultRetryOptions = {
maxAttempts: 3,
@@ -9,6 +9,17 @@ export const defaultRetryOptions = {
randomize: true,
} satisfies RetryOptions;
export const defaultFetchRetryOptions = {
byStatus: {
"429,408,409,5xx": {
strategy: "backoff",
...defaultRetryOptions,
},
},
connectionError: defaultRetryOptions,
timeout: defaultRetryOptions,
} satisfies FetchRetryOptions;
/**
*
* @param options
@@ -0,0 +1,186 @@
import { flattenAttributes, unflattenAttributes } from "../src/v3/utils/flattenAttributes";
describe("flattenAttributes", () => {
it("handles null and undefined gracefully", () => {
expect(flattenAttributes(null)).toEqual({});
expect(flattenAttributes(undefined)).toEqual({});
});
it("flattens string attributes correctly", () => {
const result = flattenAttributes("testString");
expect(result).toEqual({ "": "testString" });
});
it("flattens number attributes correctly", () => {
const result = flattenAttributes(12345);
expect(result).toEqual({ "": 12345 });
});
it("flattens boolean attributes correctly", () => {
const result = flattenAttributes(true);
expect(result).toEqual({ "": true });
});
it("flattens complex objects correctly", () => {
const obj = {
level1: {
level2: {
value: "test",
},
array: [1, 2, 3],
},
};
const expected = {
"level1.level2.value": "test",
"level1.array.[0]": 1,
"level1.array.[1]": 2,
"level1.array.[2]": 3,
};
expect(flattenAttributes(obj)).toEqual(expected);
});
it("applies prefixes correctly", () => {
const obj = { key: "value" };
const expected = { "prefix.key": "value" };
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
});
it("handles arrays of objects correctly", () => {
const obj = {
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
};
const expected = {
"array.[0].key": "value",
"array.[1].key": "value",
"array.[2].key": "value",
};
expect(flattenAttributes(obj)).toEqual(expected);
});
it("handles arrays of objects correctly with prefixing correctly", () => {
const obj = {
array: [{ key: "value" }, { key: "value" }, { key: "value" }],
};
const expected = {
"prefix.array.[0].key": "value",
"prefix.array.[1].key": "value",
"prefix.array.[2].key": "value",
};
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
});
it("handles objects of objects correctly", () => {
const obj = {
level1: {
level2: {
key: "value",
},
},
};
const expected = { "level1.level2.key": "value" };
expect(flattenAttributes(obj)).toEqual(expected);
});
it("handles objects of objects correctly with prefixing", () => {
const obj = {
level1: {
level2: {
key: "value",
},
},
};
const expected = { "prefix.level1.level2.key": "value" };
expect(flattenAttributes(obj, "prefix")).toEqual(expected);
});
it("handles retry.byStatus correctly", () => {
const obj = {
"500": {
strategy: "backoff",
maxAttempts: 2,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 30_000,
randomize: false,
},
};
const expected = {
"retry.byStatus.500.strategy": "backoff",
"retry.byStatus.500.maxAttempts": 2,
"retry.byStatus.500.factor": 2,
"retry.byStatus.500.minTimeoutInMs": 1_000,
"retry.byStatus.500.maxTimeoutInMs": 30_000,
"retry.byStatus.500.randomize": false,
};
expect(flattenAttributes(obj, "retry.byStatus")).toEqual(expected);
});
});
describe("unflattenAttributes", () => {
it("returns the original object for primitive types", () => {
// @ts-expect-error
expect(unflattenAttributes("testString")).toEqual("testString");
// @ts-expect-error
expect(unflattenAttributes(12345)).toEqual(12345);
// @ts-expect-error
expect(unflattenAttributes(true)).toEqual(true);
});
it("correctly reconstructs an object from flattened attributes", () => {
const flattened = {
"level1.level2.value": "test",
"level1.array.[0]": 1,
"level1.array.[1]": 2,
"level1.array.[2]": 3,
};
const expected = {
level1: {
level2: {
value: "test",
},
array: [1, 2, 3],
},
};
expect(unflattenAttributes(flattened)).toEqual(expected);
});
it("handles complex nested objects with mixed types", () => {
const flattened = {
"user.details.name": "John Doe",
"user.details.age": 30,
"user.preferences.colors.[0]": "blue",
"user.preferences.colors.[1]": "green",
"user.active": true,
};
const expected = {
user: {
details: {
name: "John Doe",
age: 30,
},
preferences: {
colors: ["blue", "green"],
},
active: true,
},
};
expect(unflattenAttributes(flattened)).toEqual(expected);
});
it("correctly identifies arrays vs objects", () => {
const flattened = {
"array.[0]": 1,
"array.[1]": 2,
"object.key": "value",
};
const expected = {
array: [1, 2],
object: {
key: "value",
},
};
expect(unflattenAttributes(flattened)).toEqual(expected);
});
});
+2 -2
View File
@@ -30,10 +30,10 @@
"devDependencies": {
"@trigger.dev/tsconfig": "workspace:*",
"@types/node": "^18",
"@types/react": "18.2.17",
"@types/react": "18.2.69",
"typescript": "^4.9.4"
},
"engines": {
"node": ">=18.0.0"
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"extends": "@trigger.dev/tsconfig/node18.json",
"include": ["src/globals.d.ts", "./src/**/*.ts", "./src/**/*.tsx"],
"compilerOptions": {
"jsx": "react",
"jsx": "react-jsx",
"lib": ["ES2021", "dom"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"include": ["./src/**/*.ts", "./src/**/*.tsx", "tsup.config.ts"],
"compilerOptions": {
"jsx": "react",
"jsx": "react-jsx",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
+2 -2
View File
@@ -45,9 +45,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/api-logs": "^0.48.0",
"@opentelemetry/semantic-conventions": "^1.21.0",
"@opentelemetry/semantic-conventions": "^1.22.0",
"@trigger.dev/core": "workspace:^2.3.18",
"@trigger.dev/core-backend": "workspace:^2.3.18",
"chalk": "^5.2.0",
+5 -1
View File
@@ -1 +1,5 @@
export type { ProjectConfig as Config } from "@trigger.dev/core/v3";
export type {
ProjectConfig as TriggerConfig,
HandleErrorArgs,
HandleErrorFunction,
} from "@trigger.dev/core/v3";
+221 -112
View File
@@ -1,21 +1,30 @@
import { Attributes, Span, SpanStatusCode, context, trace } from "@opentelemetry/api";
import {
SEMATTRS_HTTP_HOST,
SEMATTRS_HTTP_METHOD,
SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH,
SEMATTRS_HTTP_SCHEME,
SEMATTRS_HTTP_STATUS_CODE,
SEMATTRS_HTTP_URL,
} from "@opentelemetry/semantic-conventions";
import {
FetchRetryByStatusOptions,
FetchRetryOptions,
FetchRetryStrategy,
RetryOptions,
SemanticInternalAttributes,
accessoryAttributes,
calculateNextRetryDelay,
defaultRetryOptions,
runtime,
eventFilterMatches,
calculateResetAt,
FetchTimeoutOptions,
defaultRetryOptions,
eventFilterMatches,
flattenAttributes,
runtime,
} from "@trigger.dev/core/v3";
import { tracer } from "./tracer";
import { SemanticAttributes } from "@opentelemetry/semantic-conventions";
import { AsyncLocalStorage } from "node:async_hooks";
import { Attributes, Span, SpanStatusCode, context, trace } from "@opentelemetry/api";
import { defaultFetchRetryOptions } from "@trigger.dev/core/v3/utils/retries";
import type { HttpHandler } from "msw";
import { AsyncLocalStorage } from "node:async_hooks";
import { tracer } from "./tracer";
export type { RetryOptions };
@@ -102,7 +111,7 @@ function onThrow<T>(
export interface RetryFetchRequestInit extends RequestInit {
retry?: FetchRetryOptions;
timeout?: FetchTimeoutOptions;
timeoutInMs?: number;
}
const normalizeUrlFromInput = (input: RequestInfo | URL | string): URL => {
@@ -162,75 +171,6 @@ class FetchErrorWithSpan extends Error {
}
}
const doFetchRequest = async (
input: RequestInfo | URL | string,
init?: RequestInit,
attemptCount: number = 0
): Promise<[Response, Span]> => {
const url = normalizeUrlFromInput(input);
const httpMethod = normalizeHttpMethod(input, init);
const span = tracer.startSpan(`HTTP ${httpMethod}`, {
attributes: {
[SemanticAttributes.HTTP_METHOD]: httpMethod,
[SemanticAttributes.HTTP_URL]: url.href,
[SemanticAttributes.HTTP_HOST]: url.hostname,
["server.host"]: url.hostname,
["server.port"]: url.port,
[SemanticAttributes.HTTP_SCHEME]: url.protocol.replace(":", ""),
[SemanticInternalAttributes.STYLE_ICON]: "world",
...accessoryAttributes({
items: [
{
text: `${url.hostname}${url.pathname}`,
variant: "normal",
},
],
style: "codepath",
}),
...(attemptCount > 1 ? { ["http.request.resend_count"]: attemptCount - 1 } : {}),
},
});
try {
const response = await fetchWithInterceptors(input, {
...init,
headers: {
...init?.headers,
"x-retry-count": attemptCount.toString(),
},
});
span.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, response.status);
span.setAttribute("http.status_text", response.statusText);
span.setAttribute(
SemanticAttributes.HTTP_RESPONSE_CONTENT_LENGTH,
response.headers.get("content-length") || "0"
);
span.setAttributes(createAttributesFromHeaders(response.headers));
if (!response.ok) {
span.recordException(`${response.status}: ${response.statusText}`);
span.setStatus({
code: SpanStatusCode.ERROR,
message: `${response.status}: ${response.statusText}`,
});
}
return [response, span];
} catch (e) {
if (typeof e === "string" || e instanceof Error) {
span.recordException(e);
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.setAttribute(SemanticAttributes.HTTP_STATUS_CODE, 0);
span.setAttribute("http.status_text", "This operation was aborted.");
throw new FetchErrorWithSpan(e, span);
}
};
const MAX_ATTEMPTS = 10;
async function retryFetch(
@@ -246,12 +186,12 @@ async function retryFetch(
try {
const abortController = new AbortController();
const timeoutId = init?.timeout?.durationInMs
const timeoutId = init?.timeoutInMs
? setTimeout(
() => {
abortController.abort();
},
init?.timeout?.durationInMs
init?.timeoutInMs
)
: undefined;
@@ -270,20 +210,30 @@ async function retryFetch(
}
if (response.ok) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
}
const nextRetry = await calculateRetryDelayForResponse(init?.retry, response, attempt);
const nextRetry = await calculateRetryDelayForResponse(
resolveDefaults(init?.retry, "byStatus", defaultFetchRetryOptions.byStatus),
response,
attempt
);
if (!nextRetry) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
}
if (attempt >= MAX_ATTEMPTS) {
span.setAttributes(createFetchResponseAttributes(response));
span.end();
return response;
@@ -323,38 +273,72 @@ async function retryFetch(
await runtime.waitUntil(new Date(nextRetry.value));
}
} catch (e) {
if (
e instanceof FetchErrorWithSpan &&
e.originalError instanceof Error &&
e.originalError.name === "AbortError"
) {
const nextRetryDelay = calculateNextRetryDelay(
{ ...defaultRetryOptions, ...(init?.timeout?.retry ?? {}) },
attempt
);
if (e instanceof FetchErrorWithSpan && e.originalError instanceof Error) {
if (e.originalError.name === "AbortError") {
const nextRetryDelay = calculateNextRetryDelay(
resolveDefaults(init?.retry, "timeout", defaultFetchRetryOptions.timeout),
attempt
);
if (!nextRetryDelay) {
e.span.end();
throw e;
}
if (attempt >= MAX_ATTEMPTS) {
e.span.end();
throw e;
}
e.span.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(Date.now() + nextRetryDelay).toISOString()
);
e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
if (!nextRetryDelay) {
e.span.end();
throw e;
}
if (attempt >= MAX_ATTEMPTS) {
await runtime.waitForDuration(nextRetryDelay);
continue; // Move to the next attempt
} else if (
e.originalError.name === "TypeError" &&
"cause" in e.originalError &&
e.originalError.cause instanceof Error
) {
const nextRetryDelay = calculateNextRetryDelay(
resolveDefaults(
init?.retry,
"connectionError",
defaultFetchRetryOptions.connectionError
),
attempt
);
if (!nextRetryDelay) {
e.span.end();
throw e;
}
if (attempt >= MAX_ATTEMPTS) {
e.span.end();
throw e;
}
e.span.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(Date.now() + nextRetryDelay).toISOString()
);
e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
e.span.end();
throw e;
await runtime.waitForDuration(nextRetryDelay);
continue; // Move to the next attempt
}
e.span.setAttribute(
SemanticInternalAttributes.RETRY_AT,
new Date(Date.now() + nextRetryDelay).toISOString()
);
e.span.setAttribute(SemanticInternalAttributes.RETRY_COUNT, attempt);
e.span.setAttribute(SemanticInternalAttributes.RETRY_DELAY, `${nextRetryDelay}ms`);
e.span.end();
await runtime.waitForDuration(nextRetryDelay);
continue; // Move to the next attempt
}
if (e instanceof FetchErrorWithSpan) {
@@ -370,13 +354,63 @@ async function retryFetch(
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "arrow-capsule",
...createFetchAttributes(input, init),
...createFetchRetryOptionsAttributes(init?.retry),
},
}
);
}
const doFetchRequest = async (
input: RequestInfo | URL | string,
init?: RequestInit,
attemptCount: number = 0
): Promise<[Response, Span]> => {
const httpMethod = normalizeHttpMethod(input, init);
const span = tracer.startSpan(`HTTP ${httpMethod}`, {
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "world",
...(attemptCount > 1 ? { ["http.request.resend_count"]: attemptCount - 1 } : {}),
...createFetchAttributes(input, init),
},
});
try {
const response = await fetchWithInterceptors(input, {
...init,
headers: {
...init?.headers,
"x-retry-count": attemptCount.toString(),
},
});
span.setAttributes(createFetchResponseAttributes(response));
if (!response.ok) {
span.recordException(`${response.status}: ${response.statusText}`);
span.setStatus({
code: SpanStatusCode.ERROR,
message: `${response.status}: ${response.statusText}`,
});
}
return [response, span];
} catch (e) {
if (typeof e === "string" || e instanceof Error) {
span.recordException(e);
}
span.setStatus({ code: SpanStatusCode.ERROR });
span.setAttribute(SEMATTRS_HTTP_STATUS_CODE, 0);
span.setAttribute("http.status_text", "This operation was aborted.");
throw new FetchErrorWithSpan(e, span);
}
};
const calculateRetryDelayForResponse = async (
retry: FetchRetryOptions | undefined,
retry: FetchRetryByStatusOptions | undefined,
response: Response,
attemptCount: number
): Promise<{ type: "delay"; value: number } | { type: "timestamp"; value: number } | undefined> => {
@@ -421,7 +455,7 @@ const calculateRetryDelayForResponse = async (
const getRetryStrategyForResponse = async (
response: Response,
retry: FetchRetryOptions
retry: FetchRetryByStatusOptions
): Promise<FetchRetryStrategy | undefined> => {
const statusCodes = Object.keys(retry);
const clonedResponse = response.clone();
@@ -455,7 +489,7 @@ const getRetryStrategyForResponse = async (
* The range can be a single status code (e.g. "200"),
* a range of status codes (e.g. "500-599"),
* a range of status codes with a wildcard (e.g. "4xx" for any 4xx status code),
* or a list of status codes separated by commas (e.g. "401,403,404").
* or a list of status codes separated by commas (e.g. "401,403,404,409-412,5xx").
* Returns `true` if the status code falls within the range, and `false` otherwise.
*/
const isStatusCodeInRange = (statusCode: number, statusRange: string): boolean => {
@@ -465,7 +499,8 @@ const isStatusCodeInRange = (statusCode: number, statusRange: string): boolean =
if (statusRange.includes(",")) {
const statusCodes = statusRange.split(",").map((s) => s.trim());
return statusCodes.includes(statusCode.toString());
return statusCodes.some((s) => isStatusCodeInRange(statusCode, s));
}
const [start, end] = statusRange.split("-");
@@ -527,6 +562,80 @@ const interceptFetch = (...handlers: Array<HttpHandler>) => {
};
};
// This function will resolve the defaults of a property within an options object.
// If the options object is undefined, it will return the defaults for that property (passed in as the 3rd arg).
// if the options object is defined, and the property exists, then it will return the defaults if the value of the property is undefined or null
const resolveDefaults = <
TObject extends Record<string, unknown>,
K extends keyof TObject,
TValue extends TObject[K],
>(
obj: TObject | undefined,
key: K,
defaults: TValue
): TValue => {
if (!obj) {
return defaults;
}
if (obj[key] === undefined || obj[key] === null) {
return defaults;
}
return obj[key] as TValue;
};
const createFetchAttributes = (
input: RequestInfo | URL,
init?: RetryFetchRequestInit | undefined
): Attributes => {
const url = normalizeUrlFromInput(input);
const httpMethod = normalizeHttpMethod(input, init);
return {
[SEMATTRS_HTTP_METHOD]: httpMethod,
[SEMATTRS_HTTP_URL]: url.href,
[SEMATTRS_HTTP_HOST]: url.hostname,
["server.host"]: url.hostname,
["server.port"]: url.port,
[SEMATTRS_HTTP_SCHEME]: url.protocol.replace(":", ""),
...accessoryAttributes({
items: [
{
text: url.hostname,
variant: "normal",
},
],
style: "codepath",
}),
};
};
const createFetchResponseAttributes = (response: Response): Attributes => {
return {
[SEMATTRS_HTTP_STATUS_CODE]: response.status,
"http.status_text": response.statusText,
[SEMATTRS_HTTP_RESPONSE_CONTENT_LENGTH]: response.headers.get("content-length") || "0",
...createAttributesFromHeaders(response.headers),
};
};
const createFetchRetryOptionsAttributes = (retry?: FetchRetryOptions): Attributes => {
const byStatus = resolveDefaults(retry, "byStatus", defaultFetchRetryOptions.byStatus);
const connectionError = resolveDefaults(
retry,
"connectionError",
defaultFetchRetryOptions.connectionError
);
const timeout = resolveDefaults(retry, "timeout", defaultFetchRetryOptions.timeout);
return {
...flattenAttributes(byStatus, "retry.byStatus"),
...flattenAttributes(connectionError, "retry.connectionError"),
...flattenAttributes(timeout, "retry.timeout"),
};
};
export const retry = {
onThrow,
fetch: retryFetch,
+606 -7903
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -14,6 +14,9 @@
"start:stripe": "ts-node -r tsconfig-paths/register -r dotenv/config src/stripeUsage.ts"
},
"dependencies": {
"@opentelemetry/api": "^1.8.0",
"@sindresorhus/slugify": "^2.2.1",
"@traceloop/instrumentation-openai": "^0.3.9",
"@trigger.dev/sdk": "2.3.18",
"msw": "^2.2.1",
"openai": "^4.28.0",
+1 -1
View File
@@ -1,4 +1,4 @@
import type { HandleErrorFunction } from "@trigger.dev/core/v3";
import type { HandleErrorFunction } from "@trigger.dev/sdk/v3";
export const handleError: HandleErrorFunction = async (payload, error, { ctx, retry }) => {
console.log("GOT TO handleError FUNCTION");
+2 -1
View File
@@ -1,9 +1,10 @@
import { task } from "@trigger.dev/sdk/v3";
import slugify from "@sindresorhus/slugify";
export const loggingTask = task({
id: "logging-task",
run: async () => {
console.log("Hello world 9");
console.log(`Hello world 9 ${slugify("foo bar")}`);
return null;
},
+26 -21
View File
@@ -59,15 +59,19 @@ export const taskWithFetchRetries = task({
return interceptor.run(next);
},
run: async (payload: any, { ctx }) => {
logger.info("Fetching data", { foo: [1, 2, 3], bar: [{ hello: "world" }] });
//if the fetch fails, it will retry
const headersResponse = await retry.fetch("http://my.host/test-headers", {
retry: {
"429": {
strategy: "headers",
limitHeader: "x-ratelimit-limit",
remainingHeader: "x-ratelimit-remaining",
resetHeader: "x-ratelimit-reset",
resetFormat: "unix_timestamp_in_ms",
byStatus: {
"429": {
strategy: "headers",
limitHeader: "x-ratelimit-limit",
remainingHeader: "x-ratelimit-remaining",
resetHeader: "x-ratelimit-reset",
resetFormat: "unix_timestamp_in_ms",
},
},
},
});
@@ -76,26 +80,32 @@ export const taskWithFetchRetries = task({
logger.info("Fetched headers response", { json });
const backoffResponse = await retry.fetch("http://my.host/test-backoff", {
timeoutInMs: 1000,
retry: {
"500-599": {
strategy: "backoff",
maxAttempts: 10,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 30_000,
randomize: false,
byStatus: {
"500-599": {
strategy: "backoff",
maxAttempts: 5,
factor: 2,
minTimeoutInMs: 1_000,
maxTimeoutInMs: 30_000,
randomize: false,
},
},
},
});
const json2 = await backoffResponse.json();
// This should use the defaults.
await retry.fetch("http://my.host/test-connection-errors");
logger.info("Fetched backoff response", { json2 });
const timeoutResponse = await retry.fetch("https://httpbin.org/delay/2", {
timeout: {
durationInMs: 1000,
retry: {
timeoutInMs: 1000,
retry: {
timeout: {
maxAttempts: 5,
factor: 1.8,
minTimeoutInMs: 500,
@@ -105,16 +115,11 @@ export const taskWithFetchRetries = task({
},
});
const json3 = await timeoutResponse.json();
logger.info("Fetched timeout response", { json3 });
return {
result: "successss",
payload,
json,
json2,
json3,
};
},
});
@@ -28,5 +28,14 @@ export const interceptor = retry.interceptFetch(
return new HttpResponse(null, {
status: 500,
});
}),
http.get("http://my.host/test-connection-errors", ({ request }) => {
const retryCount = request.headers.get("x-retry-count");
if (retryCount === "2") {
return HttpResponse.json({ test: "connection-errors" });
}
return HttpResponse.error();
})
);
@@ -0,0 +1,6 @@
import { task } from "@trigger.dev/sdk/v3";
export const weirdFileName = task({
id: "weird-file-name",
run: async (payload: { url: string }) => {},
});
+6 -2
View File
@@ -1,8 +1,9 @@
import type { ProjectConfig } from "@trigger.dev/core/v3";
import type { TriggerConfig } from "@trigger.dev/sdk/v3";
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
export { handleError } from "./src/handleError";
export const config: ProjectConfig = {
export const config: TriggerConfig = {
project: "yubjwjsfkxnylobaqvqz",
retries: {
enabledInDev: true,
@@ -15,4 +16,7 @@ export const config: ProjectConfig = {
},
},
additionalPackages: ["wrangler@3.35.0"],
additionalFiles: ["./wrangler/wrangler.toml"],
dependenciesToBundle: [/@sindresorhus/, "escape-string-regexp"],
instrumentations: [new OpenAIInstrumentation()],
};
@@ -0,0 +1,7 @@
name = "proxy"
main = "src/index.ts"
compatibility_date = "2023-10-30"
compatibility_flags = ["nodejs_compat"]
[env.staging]
[env.prod]