chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Trigger.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Official Build Package of Trigger.dev
|
||||
|
||||
View the full documentation [here](https://trigger.dev/docs)
|
||||
@@ -0,0 +1,216 @@
|
||||
{
|
||||
"name": "@trigger.dev/build",
|
||||
"version": "4.5.3",
|
||||
"description": "trigger.dev build extensions",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev",
|
||||
"directory": "packages/build"
|
||||
},
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"tshy": {
|
||||
"selfLink": false,
|
||||
"main": true,
|
||||
"module": true,
|
||||
"project": "./tsconfig.src.json",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/index.ts",
|
||||
"./internal": "./src/internal.ts",
|
||||
"./extensions": "./src/extensions/index.ts",
|
||||
"./extensions/core": "./src/extensions/core.ts",
|
||||
"./extensions/prisma": "./src/extensions/prisma.ts",
|
||||
"./extensions/audioWaveform": "./src/extensions/audioWaveform.ts",
|
||||
"./extensions/typescript": "./src/extensions/typescript.ts",
|
||||
"./extensions/puppeteer": "./src/extensions/puppeteer.ts",
|
||||
"./extensions/playwright": "./src/extensions/playwright.ts",
|
||||
"./extensions/lightpanda": "./src/extensions/lightpanda.ts"
|
||||
},
|
||||
"sourceDialects": [
|
||||
"@triggerdotdev/source"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"internal": [
|
||||
"dist/commonjs/internal.d.ts"
|
||||
],
|
||||
"extensions": [
|
||||
"dist/commonjs/extensions/index.d.ts"
|
||||
],
|
||||
"extensions/core": [
|
||||
"dist/commonjs/extensions/core.d.ts"
|
||||
],
|
||||
"extensions/prisma": [
|
||||
"dist/commonjs/extensions/prisma.d.ts"
|
||||
],
|
||||
"extensions/audioWaveform": [
|
||||
"dist/commonjs/extensions/audioWaveform.d.ts"
|
||||
],
|
||||
"extensions/typescript": [
|
||||
"dist/commonjs/extensions/typescript.d.ts"
|
||||
],
|
||||
"extensions/puppeteer": [
|
||||
"dist/commonjs/extensions/puppeteer.d.ts"
|
||||
],
|
||||
"extensions/playwright": [
|
||||
"dist/commonjs/extensions/playwright.d.ts"
|
||||
],
|
||||
"extensions/lightpanda": [
|
||||
"dist/commonjs/extensions/lightpanda.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
||||
"build": "tshy && pnpm run update-version",
|
||||
"dev": "tshy --watch",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.src.json",
|
||||
"update-version": "tsx ../../scripts/updateVersion.ts",
|
||||
"check-exports": "attw --pack .",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/config": "^6.10.0",
|
||||
"@trigger.dev/core": "workspace:4.5.3",
|
||||
"mlly": "^1.7.1",
|
||||
"pkg-types": "^1.1.3",
|
||||
"resolve": "^1.22.8",
|
||||
"tinyglobby": "^0.2.2",
|
||||
"tsconfck": "3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "^0.15.4",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"esbuild": "^0.23.0",
|
||||
"rimraf": "6.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
"tsx": "4.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/index.ts",
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"default": "./dist/commonjs/index.js"
|
||||
}
|
||||
},
|
||||
"./internal": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/internal.ts",
|
||||
"types": "./dist/esm/internal.d.ts",
|
||||
"default": "./dist/esm/internal.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/internal.d.ts",
|
||||
"default": "./dist/commonjs/internal.js"
|
||||
}
|
||||
},
|
||||
"./extensions": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/index.ts",
|
||||
"types": "./dist/esm/extensions/index.d.ts",
|
||||
"default": "./dist/esm/extensions/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/index.d.ts",
|
||||
"default": "./dist/commonjs/extensions/index.js"
|
||||
}
|
||||
},
|
||||
"./extensions/core": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/core.ts",
|
||||
"types": "./dist/esm/extensions/core.d.ts",
|
||||
"default": "./dist/esm/extensions/core.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/core.d.ts",
|
||||
"default": "./dist/commonjs/extensions/core.js"
|
||||
}
|
||||
},
|
||||
"./extensions/prisma": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/prisma.ts",
|
||||
"types": "./dist/esm/extensions/prisma.d.ts",
|
||||
"default": "./dist/esm/extensions/prisma.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/prisma.d.ts",
|
||||
"default": "./dist/commonjs/extensions/prisma.js"
|
||||
}
|
||||
},
|
||||
"./extensions/audioWaveform": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/audioWaveform.ts",
|
||||
"types": "./dist/esm/extensions/audioWaveform.d.ts",
|
||||
"default": "./dist/esm/extensions/audioWaveform.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/audioWaveform.d.ts",
|
||||
"default": "./dist/commonjs/extensions/audioWaveform.js"
|
||||
}
|
||||
},
|
||||
"./extensions/typescript": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/typescript.ts",
|
||||
"types": "./dist/esm/extensions/typescript.d.ts",
|
||||
"default": "./dist/esm/extensions/typescript.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/typescript.d.ts",
|
||||
"default": "./dist/commonjs/extensions/typescript.js"
|
||||
}
|
||||
},
|
||||
"./extensions/puppeteer": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/puppeteer.ts",
|
||||
"types": "./dist/esm/extensions/puppeteer.d.ts",
|
||||
"default": "./dist/esm/extensions/puppeteer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/puppeteer.d.ts",
|
||||
"default": "./dist/commonjs/extensions/puppeteer.js"
|
||||
}
|
||||
},
|
||||
"./extensions/playwright": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/playwright.ts",
|
||||
"types": "./dist/esm/extensions/playwright.d.ts",
|
||||
"default": "./dist/esm/extensions/playwright.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/playwright.d.ts",
|
||||
"default": "./dist/commonjs/extensions/playwright.js"
|
||||
}
|
||||
},
|
||||
"./extensions/lightpanda": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/extensions/lightpanda.ts",
|
||||
"types": "./dist/esm/extensions/lightpanda.d.ts",
|
||||
"default": "./dist/esm/extensions/lightpanda.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/extensions/lightpanda.d.ts",
|
||||
"default": "./dist/commonjs/extensions/lightpanda.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "./dist/commonjs/index.js",
|
||||
"types": "./dist/commonjs/index.d.ts",
|
||||
"module": "./dist/esm/index.js"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3";
|
||||
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type AudioWaveformOptions = {
|
||||
version?: string;
|
||||
checksum?: string;
|
||||
};
|
||||
|
||||
const AUDIOWAVEFORM_VERSION = "1.10.1";
|
||||
const AUDIOWAVEFORM_CHECKSUM =
|
||||
"sha256:00b41ea4d6e7a5b4affcfe4ac99951ec89da81a8cba40af19e9b98c3a8f9b4b8";
|
||||
|
||||
export function audioWaveform(options: AudioWaveformOptions = {}): BuildExtension {
|
||||
return new AudioWaveformExtension(options);
|
||||
}
|
||||
|
||||
class AudioWaveformExtension implements BuildExtension {
|
||||
public readonly name = "AudioWaveformExtension";
|
||||
|
||||
constructor(private options: AudioWaveformOptions = {}) {}
|
||||
|
||||
async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
const opts = this.options.version
|
||||
? {
|
||||
version: this.options.version,
|
||||
checksum: this.options.checksum,
|
||||
}
|
||||
: {
|
||||
version: AUDIOWAVEFORM_VERSION,
|
||||
checksum: AUDIOWAVEFORM_CHECKSUM,
|
||||
};
|
||||
|
||||
context.logger.debug("Adding audiowaveform to the build", {
|
||||
...opts,
|
||||
});
|
||||
|
||||
const instructions = [
|
||||
`ADD ${
|
||||
opts.checksum ? `--checksum=${opts.checksum}` : ""
|
||||
} https://github.com/bbc/audiowaveform/releases/download/${opts.version}/audiowaveform_${
|
||||
opts.version
|
||||
}-1-12_amd64.deb .`,
|
||||
`RUN dpkg -i audiowaveform_${opts.version}-1-12_amd64.deb || true`,
|
||||
`RUN rm audiowaveform*.deb`,
|
||||
];
|
||||
|
||||
context.addLayer({
|
||||
id: "audiowaveform",
|
||||
image: {
|
||||
pkgs: ["sox"],
|
||||
instructions,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from "./core/additionalFiles.js";
|
||||
export * from "./core/additionalPackages.js";
|
||||
export * from "./core/syncEnvVars.js";
|
||||
export * from "./core/aptGet.js";
|
||||
export * from "./core/ffmpeg.js";
|
||||
export * from "./core/neonSyncEnvVars.js";
|
||||
export * from "./core/vercelSyncEnvVars.js";
|
||||
export * from "./core/syncSupabaseEnvVars.js";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { addAdditionalFilesToBuild } from "../../internal/additionalFiles.js";
|
||||
|
||||
export type AdditionalFilesOptions = {
|
||||
files: string[];
|
||||
};
|
||||
|
||||
export function additionalFiles(options: AdditionalFilesOptions): BuildExtension {
|
||||
return {
|
||||
name: "additionalFiles",
|
||||
async onBuildComplete(context, manifest) {
|
||||
await addAdditionalFilesToBuild("additionalFiles", options, context, manifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { dirname } from "node:path";
|
||||
import { readPackageJSON } from "pkg-types";
|
||||
|
||||
export type AdditionalPackagesOptions = {
|
||||
packages: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Add additional packages to the build when deploying, useful when you are using the bin command of a package by shelling out to it.
|
||||
* You can pass the name of the package, and it's version will be resolved from the locally installed version. If the version cannot be automatically resolved, it will resolve to the latest version, or you can specify the version using `@` syntax.
|
||||
* @example
|
||||
*
|
||||
* ```ts
|
||||
* additionalPackages({
|
||||
* packages: ["wrangler", "prisma@3.0.0"]
|
||||
* });
|
||||
*/
|
||||
export function additionalPackages(options: AdditionalPackagesOptions): BuildExtension {
|
||||
return {
|
||||
name: "additionalPackages",
|
||||
async onBuildStart(context) {
|
||||
if (context.target !== "deploy") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dependencies: Record<string, string> = {};
|
||||
|
||||
for (const pkg of options.packages) {
|
||||
const { name, version } = parsePackageName(pkg);
|
||||
|
||||
if (version) {
|
||||
dependencies[name] = version;
|
||||
} else {
|
||||
try {
|
||||
// Lets try and resolve the version from the local package.json
|
||||
const modulePath = await context.resolvePath(name);
|
||||
|
||||
if (!modulePath) {
|
||||
dependencies[name] = "latest";
|
||||
continue;
|
||||
}
|
||||
|
||||
context.logger.debug("[additionalPackages] Resolved module path", { modulePath });
|
||||
|
||||
const packageJSON = await readPackageJSON(dirname(modulePath));
|
||||
|
||||
if (packageJSON.version) {
|
||||
dependencies[name] = packageJSON.version;
|
||||
} else {
|
||||
context.logger.warn(
|
||||
`Could not resolve version for package ${name}, defaulting to latest`
|
||||
);
|
||||
|
||||
dependencies[name] = "latest";
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Could not resolve version for package ${name}, defaulting to latest`,
|
||||
error
|
||||
);
|
||||
|
||||
dependencies[name] = "latest";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "additionalPackages",
|
||||
dependencies,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// This needs to handle packages like @taskhero/config@1.0.0, wrangler, wrangler@1.0.0, @taskhero/config, etc.
|
||||
function parsePackageName(pkg: string): {
|
||||
name: string;
|
||||
version?: string;
|
||||
} {
|
||||
// Regular expression to match package names and versions
|
||||
const regex = /^(@?[a-z0-9-~][a-z0-9-._~]*\/)?([a-z0-9-~][a-z0-9-._~]*)(@(.+))?$/i;
|
||||
const match = pkg.match(regex);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid package name: ${pkg}`);
|
||||
}
|
||||
|
||||
const [, scope, packageName, , version] = match;
|
||||
|
||||
if (!packageName) {
|
||||
throw new Error(`Invalid package name: ${pkg}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name: scope ? `${scope}${packageName}` : packageName,
|
||||
version,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type AptGetOptions = {
|
||||
packages: string[];
|
||||
};
|
||||
|
||||
export function aptGet(options: AptGetOptions): BuildExtension {
|
||||
return {
|
||||
name: "aptGet",
|
||||
onBuildComplete(context) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug("Adding apt-get layer", {
|
||||
pkgs: options.packages,
|
||||
});
|
||||
|
||||
context.addLayer({
|
||||
id: "apt-get",
|
||||
image: {
|
||||
pkgs: options.packages,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type FfmpegOptions = {
|
||||
/**
|
||||
* The version of ffmpeg to install. If not provided, the latest version from apt will be installed.
|
||||
* If set to '7' or starts with '7.', a static build of ffmpeg 7.x from johnvansickle.com will be used instead of apt.
|
||||
* @example
|
||||
* ffmpeg() // Installs latest ffmpeg from apt
|
||||
* ffmpeg({ version: '7' }) // Installs static build of ffmpeg 7.x
|
||||
* ffmpeg({ version: '7.0.1' }) // Installs static build of ffmpeg 7.x
|
||||
* ffmpeg({ version: '6' }) // Version ignored, installs latest ffmpeg from apt
|
||||
* ffmpeg({ version: '8' }) // Version ignored, installs latest ffmpeg from apt
|
||||
*/
|
||||
version?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Add ffmpeg to the build, and automatically set the FFMPEG_PATH and FFPROBE_PATH environment variables.
|
||||
* @param options.version The version of ffmpeg to install. If not provided, the latest version from apt will be installed.
|
||||
* If set to '7' or starts with '7.', a static build of ffmpeg 7.x from johnvansickle.com will be used instead of apt.
|
||||
*
|
||||
* @returns The build extension.
|
||||
*/
|
||||
export function ffmpeg(options: FfmpegOptions = {}): BuildExtension {
|
||||
return {
|
||||
name: "ffmpeg",
|
||||
onBuildComplete(context) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug("Adding ffmpeg", {
|
||||
options,
|
||||
});
|
||||
|
||||
// Use static build for version 7 or 7.x
|
||||
if (options.version === "7" || options.version?.startsWith("7.")) {
|
||||
context.addLayer({
|
||||
id: "ffmpeg",
|
||||
image: {
|
||||
instructions: [
|
||||
// Install ffmpeg after checksum validation
|
||||
"RUN apt-get update && apt-get install -y --no-install-recommends wget xz-utils nscd ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/* && " +
|
||||
"wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz.md5 && " +
|
||||
"wget https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz && " +
|
||||
"md5sum -c ffmpeg-git-amd64-static.tar.xz.md5 && " +
|
||||
"tar xvf ffmpeg-git-amd64-static.tar.xz -C /usr/bin --strip-components=1 --no-anchored 'ffmpeg' 'ffprobe' && " +
|
||||
"rm ffmpeg-git-amd64-static.tar.xz*",
|
||||
],
|
||||
},
|
||||
deploy: {
|
||||
env: {
|
||||
FFMPEG_PATH: "/usr/bin/ffmpeg",
|
||||
FFPROBE_PATH: "/usr/bin/ffprobe",
|
||||
},
|
||||
override: true,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} else if (options.version) {
|
||||
context.logger.warn("Custom ffmpeg version not supported, ignoring", {
|
||||
version: options.version,
|
||||
});
|
||||
}
|
||||
|
||||
// Default: use apt
|
||||
context.addLayer({
|
||||
id: "ffmpeg",
|
||||
image: {
|
||||
pkgs: ["ffmpeg"],
|
||||
},
|
||||
deploy: {
|
||||
env: {
|
||||
FFMPEG_PATH: "/usr/bin/ffmpeg",
|
||||
FFPROBE_PATH: "/usr/bin/ffprobe",
|
||||
},
|
||||
override: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { syncEnvVars } from "../core.js";
|
||||
|
||||
type EnvVar = { name: string; value: string; isParentEnv?: boolean };
|
||||
|
||||
type NeonBranch = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type NeonEndpoint = {
|
||||
id: string;
|
||||
host: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
type NeonDatabase = {
|
||||
id: number;
|
||||
name: string;
|
||||
owner_name: string;
|
||||
};
|
||||
|
||||
type NeonRole = {
|
||||
name: string;
|
||||
password?: string;
|
||||
};
|
||||
|
||||
// List of Neon DB related environment variables to sync
|
||||
export const NEON_ENV_VARS = [
|
||||
"PGUSER",
|
||||
"POSTGRES_URL_NO_SSL",
|
||||
"POSTGRES_HOST",
|
||||
"POSTGRES_URL",
|
||||
"POSTGRES_PRISMA_URL",
|
||||
"DATABASE_URL_UNPOOLED",
|
||||
"POSTGRES_URL_NON_POOLING",
|
||||
"PGHOST",
|
||||
"POSTGRES_USER",
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_PASSWORD",
|
||||
"POSTGRES_DATABASE",
|
||||
"PGPASSWORD",
|
||||
"PGDATABASE",
|
||||
"PGHOST_UNPOOLED",
|
||||
];
|
||||
|
||||
function buildNeonEnvVarMappings(options: {
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
host: string;
|
||||
poolerHost: string;
|
||||
}): Record<string, string> {
|
||||
const { user, password, database, host, poolerHost } = options;
|
||||
|
||||
return {
|
||||
PGUSER: user,
|
||||
PGPASSWORD: password,
|
||||
PGDATABASE: database,
|
||||
PGHOST: poolerHost,
|
||||
PGHOST_UNPOOLED: host,
|
||||
POSTGRES_USER: user,
|
||||
POSTGRES_PASSWORD: password,
|
||||
POSTGRES_DATABASE: database,
|
||||
POSTGRES_HOST: poolerHost,
|
||||
DATABASE_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
|
||||
DATABASE_URL_UNPOOLED: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
|
||||
POSTGRES_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require`,
|
||||
POSTGRES_URL_NO_SSL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}`,
|
||||
POSTGRES_URL_NON_POOLING: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}/${database}?sslmode=require`,
|
||||
POSTGRES_PRISMA_URL: `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncNeonEnvVars(options?: {
|
||||
projectId?: string;
|
||||
/**
|
||||
* Neon API access token for authentication.
|
||||
* It's recommended to use the NEON_ACCESS_TOKEN environment variable instead of hardcoding this value.
|
||||
*/
|
||||
neonAccessToken?: string;
|
||||
branch?: string;
|
||||
databaseName?: string;
|
||||
roleName?: string;
|
||||
envVarPrefix?: string;
|
||||
}): BuildExtension {
|
||||
const sync = syncEnvVars(async (ctx) => {
|
||||
const projectId = options?.projectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID;
|
||||
const neonAccessToken =
|
||||
options?.neonAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN;
|
||||
const branch = options?.branch ?? ctx.branch;
|
||||
const envVarPrefix = options?.envVarPrefix ?? "";
|
||||
const outputEnvVars = NEON_ENV_VARS;
|
||||
|
||||
// Skip the whole process for Vercel environments
|
||||
if (ctx.env.VERCEL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"syncNeonEnvVars: you did not pass in a projectId or set the NEON_PROJECT_ID env var."
|
||||
);
|
||||
}
|
||||
|
||||
if (!neonAccessToken) {
|
||||
throw new Error(
|
||||
"syncNeonEnvVars: you did not pass in an neonAccessToken or set the NEON_ACCESS_TOKEN env var."
|
||||
);
|
||||
}
|
||||
|
||||
// Skip branch-specific logic for production environment
|
||||
if (ctx.environment === "prod") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
"syncNeonEnvVars: you did not pass in a branch and no branch was detected from context."
|
||||
);
|
||||
}
|
||||
|
||||
const environmentMap = {
|
||||
prod: "production",
|
||||
staging: "preview",
|
||||
dev: "development",
|
||||
preview: "preview",
|
||||
} as const;
|
||||
|
||||
const environment = environmentMap[ctx.environment as keyof typeof environmentMap];
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(
|
||||
`Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', 'dev', or 'preview'.`
|
||||
);
|
||||
}
|
||||
|
||||
if (environment === "development") {
|
||||
// Skip syncing for development environment
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Search for the branch in Neon
|
||||
const branchSearchParams = new URLSearchParams({ search: branch });
|
||||
const branchesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches?${branchSearchParams}`;
|
||||
const branchesResponse = await fetch(branchesUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${neonAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!branchesResponse.ok) {
|
||||
throw new Error(`Failed to fetch Neon branches: ${branchesResponse.status}`);
|
||||
}
|
||||
|
||||
const branchesData = await branchesResponse.json();
|
||||
const branches: NeonBranch[] = branchesData.branches || [];
|
||||
|
||||
if (branches.length === 0) {
|
||||
// No matching branch found
|
||||
return [];
|
||||
}
|
||||
|
||||
// Neon branch names are prefixed with environment (e.g., "preview/branch-name")
|
||||
const expectedBranchName = `${environment}/${branch}`;
|
||||
const matchingBranch = branches.find(
|
||||
(b) => b.name === expectedBranchName || b.name === branch
|
||||
);
|
||||
|
||||
if (!matchingBranch) {
|
||||
// No exact match found
|
||||
return [];
|
||||
}
|
||||
|
||||
const neonBranchId = matchingBranch.id;
|
||||
|
||||
// Step 2: Get endpoints for the branch
|
||||
const endpointsUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/endpoints`;
|
||||
const endpointsResponse = await fetch(endpointsUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${neonAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!endpointsResponse.ok) {
|
||||
throw new Error(`Failed to fetch Neon branch endpoints: ${endpointsResponse.status}`);
|
||||
}
|
||||
|
||||
const endpointsData = await endpointsResponse.json();
|
||||
const endpoints: NeonEndpoint[] = endpointsData.endpoints || [];
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find an endpoint with type containing 'write', or take the first one
|
||||
const writeEndpoint = endpoints.find((ep) => ep.type.includes("write"));
|
||||
const endpoint = writeEndpoint || endpoints[0];
|
||||
|
||||
if (!endpoint) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 3: Get databases for the branch
|
||||
const databasesUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/databases`;
|
||||
const databasesResponse = await fetch(databasesUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${neonAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!databasesResponse.ok) {
|
||||
throw new Error(`Failed to fetch Neon branch databases: ${databasesResponse.status}`);
|
||||
}
|
||||
|
||||
const databasesData = await databasesResponse.json();
|
||||
const databases: NeonDatabase[] = databasesData.databases || [];
|
||||
|
||||
if (databases.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the specified database or use the first one
|
||||
const targetDatabase = options?.databaseName
|
||||
? databases.find((db) => db.name === options.databaseName)
|
||||
: databases[0];
|
||||
|
||||
if (!targetDatabase) {
|
||||
throw new Error(
|
||||
`syncNeonEnvVars: Database '${options?.databaseName}' not found in branch.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Get the role (user) and password
|
||||
const targetRoleName = options?.roleName ?? targetDatabase.owner_name;
|
||||
const rolePasswordUrl = `https://console.neon.tech/api/v2/projects/${projectId}/branches/${neonBranchId}/roles/${targetRoleName}/reveal_password`;
|
||||
const rolePasswordResponse = await fetch(rolePasswordUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${neonAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!rolePasswordResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Neon role password: ${rolePasswordResponse.status}. Make sure the role '${targetRoleName}' exists and has a password.`
|
||||
);
|
||||
}
|
||||
|
||||
const rolePasswordData: NeonRole = await rolePasswordResponse.json();
|
||||
const password = rolePasswordData.password;
|
||||
|
||||
if (!password) {
|
||||
throw new Error(
|
||||
`syncNeonEnvVars: No password found for role '${targetRoleName}'. The role may not have a password set.`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 5: Build new environment variables based on the endpoint host
|
||||
const newHost = endpoint.host;
|
||||
const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler.");
|
||||
|
||||
const envVarMappings = buildNeonEnvVarMappings({
|
||||
user: targetRoleName,
|
||||
password,
|
||||
database: targetDatabase.name,
|
||||
host: newHost,
|
||||
poolerHost,
|
||||
});
|
||||
|
||||
// Build output env vars
|
||||
const newEnvVars: EnvVar[] = [];
|
||||
|
||||
for (const neonEnvVar of outputEnvVars) {
|
||||
const prefixedKey = `${envVarPrefix}${neonEnvVar}`;
|
||||
if (envVarMappings[neonEnvVar]) {
|
||||
newEnvVars.push({
|
||||
name: prefixedKey,
|
||||
value: envVarMappings[neonEnvVar],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newEnvVars;
|
||||
} catch (error) {
|
||||
console.error("Error fetching Neon branch environment variables:", error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: "SyncNeonEnvVarsExtension",
|
||||
async onBuildComplete(context, manifest) {
|
||||
await sync.onBuildComplete?.(context, manifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { BuildContext, BuildLayer } from "@trigger.dev/core/v3/build";
|
||||
import { syncEnvVars, type SyncEnvVarsFunction } from "./syncEnvVars.js";
|
||||
|
||||
async function runExtension(fn: SyncEnvVarsFunction): Promise<BuildLayer | undefined> {
|
||||
let captured: BuildLayer | undefined;
|
||||
|
||||
const context = {
|
||||
target: "deploy",
|
||||
config: { project: "proj_test" },
|
||||
logger: {
|
||||
spinner: () => ({ stop: () => {} }),
|
||||
},
|
||||
addLayer: (layer: BuildLayer) => {
|
||||
captured = layer;
|
||||
},
|
||||
} as unknown as BuildContext;
|
||||
|
||||
const manifest = {
|
||||
deploy: { env: {} },
|
||||
environment: "prod",
|
||||
branch: undefined,
|
||||
} as any;
|
||||
|
||||
const extension = syncEnvVars(fn);
|
||||
await extension.onBuildComplete!(context, manifest);
|
||||
|
||||
return captured;
|
||||
}
|
||||
|
||||
describe("syncEnvVars isSecret", () => {
|
||||
it("partitions secret and non-secret vars across child and parent", async () => {
|
||||
const layer = await runExtension(() => [
|
||||
{ name: "PUBLIC_URL", value: "https://example.com" },
|
||||
{ name: "API_KEY", value: "secret-key", isSecret: true },
|
||||
{ name: "PARENT_PUBLIC", value: "parent", isParentEnv: true },
|
||||
{ name: "PARENT_SECRET", value: "parent-secret", isParentEnv: true, isSecret: true },
|
||||
]);
|
||||
|
||||
expect(layer?.deploy?.env).toEqual({ PUBLIC_URL: "https://example.com" });
|
||||
expect(layer?.deploy?.secretEnv).toEqual({ API_KEY: "secret-key" });
|
||||
expect(layer?.deploy?.parentEnv).toEqual({ PARENT_PUBLIC: "parent" });
|
||||
expect(layer?.deploy?.secretParentEnv).toEqual({ PARENT_SECRET: "parent-secret" });
|
||||
});
|
||||
|
||||
it("treats the record form as all non-secret", async () => {
|
||||
const layer = await runExtension(() => ({ DATABASE_URL: "postgres://..." }));
|
||||
|
||||
expect(layer?.deploy?.env).toEqual({ DATABASE_URL: "postgres://..." });
|
||||
expect(layer?.deploy?.secretEnv).toBeUndefined();
|
||||
expect(layer?.deploy?.secretParentEnv).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits secret buckets when no var is marked secret", async () => {
|
||||
const layer = await runExtension(() => [{ name: "PUBLIC_URL", value: "https://example.com" }]);
|
||||
|
||||
expect(layer?.deploy?.env).toEqual({ PUBLIC_URL: "https://example.com" });
|
||||
expect(layer?.deploy?.secretEnv).toBeUndefined();
|
||||
expect(layer?.deploy?.secretParentEnv).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type SyncEnvVarsBody =
|
||||
| Record<string, string>
|
||||
| Array<{ name: string; value: string; isParentEnv?: boolean; isSecret?: boolean }>;
|
||||
|
||||
export type SyncEnvVarsResult =
|
||||
| SyncEnvVarsBody
|
||||
| Promise<void | undefined | SyncEnvVarsBody>
|
||||
| void
|
||||
| undefined;
|
||||
|
||||
export type SyncEnvVarsParams = {
|
||||
projectRef: string;
|
||||
environment: string;
|
||||
branch?: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
const UNSYNCABLE_ENV_VARS = [
|
||||
"PWD",
|
||||
"MallocNanoZone",
|
||||
"USER",
|
||||
"LANG",
|
||||
"__CFBundleIdentifier",
|
||||
"COMMAND_MODE",
|
||||
"PATH",
|
||||
"LOGNAME",
|
||||
"SSLKEYLOGFILE",
|
||||
"SSH_AUTH_SOCK",
|
||||
"SHLVL",
|
||||
"SHELL",
|
||||
"HOME",
|
||||
"__CF_USER_TEXT_ENCODING",
|
||||
"XPC_SERVICE_NAME",
|
||||
"XPC_FLAGS",
|
||||
"ORIGINAL_XDG_CURRENT_DESKTOP",
|
||||
"TERM_PROGRAM",
|
||||
"TERM_PROGRAM_VERSION",
|
||||
"COLORTERM",
|
||||
"GIT_ASKPASS",
|
||||
"VSCODE_GIT_ASKPASS_NODE",
|
||||
"VSCODE_GIT_ASKPASS_EXTRA_ARGS",
|
||||
"VSCODE_GIT_ASKPASS_MAIN",
|
||||
"VSCODE_GIT_IPC_HANDLE",
|
||||
"VSCODE_INJECTION",
|
||||
"ZDOTDIR",
|
||||
"USER_ZDOTDIR",
|
||||
"TERM",
|
||||
"OLDPWD",
|
||||
"HOMEBREW_PREFIX",
|
||||
"HOMEBREW_CELLAR",
|
||||
"HOMEBREW_REPOSITORY",
|
||||
"MANPATH",
|
||||
"INFOPATH",
|
||||
"__GIT_PROMPT_DIR",
|
||||
"GIT_PROMPT_EXECUTABLE",
|
||||
"NVM_DIR",
|
||||
"NVM_CD_FLAGS",
|
||||
"NVM_BIN",
|
||||
"NVM_INC",
|
||||
"BUN_INSTALL",
|
||||
"DENO_INSTALL",
|
||||
"GITHUB_TOKEN",
|
||||
"TMPDIR",
|
||||
"_",
|
||||
];
|
||||
|
||||
const UNSYNCABLE_ENV_VARS_PREFIXES = ["TRIGGER_"];
|
||||
|
||||
export type SyncEnvVarsFunction = (params: SyncEnvVarsParams) => SyncEnvVarsResult;
|
||||
|
||||
export type SyncEnvVarsOptions = {
|
||||
override?: boolean;
|
||||
};
|
||||
|
||||
export function syncEnvVars(fn: SyncEnvVarsFunction, options?: SyncEnvVarsOptions): BuildExtension {
|
||||
return {
|
||||
name: "SyncEnvVarsExtension",
|
||||
async onBuildComplete(context, manifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
const $spinner = context.logger.spinner("Invoking syncEnvVars callback");
|
||||
|
||||
const result = await callSyncEnvVarsFn(
|
||||
fn,
|
||||
manifest.deploy.env ?? {},
|
||||
manifest.environment,
|
||||
manifest.branch,
|
||||
context
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
$spinner.stop("No env vars detected");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const env = stripUnsyncableEnvVars(result.env);
|
||||
const parentEnv = result.parentEnv ? stripUnsyncableEnvVars(result.parentEnv) : undefined;
|
||||
const secretEnv = result.secretEnv ? stripUnsyncableEnvVars(result.secretEnv) : undefined;
|
||||
const secretParentEnv = result.secretParentEnv
|
||||
? stripUnsyncableEnvVars(result.secretParentEnv)
|
||||
: undefined;
|
||||
|
||||
const numberOfEnvVars =
|
||||
Object.keys(env).length +
|
||||
(parentEnv ? Object.keys(parentEnv).length : 0) +
|
||||
(secretEnv ? Object.keys(secretEnv).length : 0) +
|
||||
(secretParentEnv ? Object.keys(secretParentEnv).length : 0);
|
||||
|
||||
if (numberOfEnvVars === 0) {
|
||||
$spinner.stop("No env vars detected");
|
||||
|
||||
return;
|
||||
} else if (numberOfEnvVars === 1) {
|
||||
$spinner.stop(`Found 1 env var`);
|
||||
} else {
|
||||
$spinner.stop(`Found ${numberOfEnvVars} env vars to sync`);
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "sync-env-vars",
|
||||
deploy: {
|
||||
env,
|
||||
parentEnv,
|
||||
secretEnv,
|
||||
secretParentEnv,
|
||||
override: options?.override ?? true,
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stripUnsyncableEnvVars(env: Record<string, string>): Record<string, string> {
|
||||
return Object.entries(env).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (UNSYNCABLE_ENV_VARS.includes(key)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Strip out any TRIGGER_ prefix env vars
|
||||
if (UNSYNCABLE_ENV_VARS_PREFIXES.some((prefix) => key.startsWith(prefix))) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
}
|
||||
|
||||
async function callSyncEnvVarsFn(
|
||||
syncEnvVarsFn: SyncEnvVarsFunction | undefined,
|
||||
env: Record<string, string>,
|
||||
environment: string,
|
||||
branch: string | undefined,
|
||||
context: BuildContext
|
||||
): Promise<
|
||||
| {
|
||||
env: Record<string, string>;
|
||||
parentEnv?: Record<string, string>;
|
||||
secretEnv?: Record<string, string>;
|
||||
secretParentEnv?: Record<string, string>;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
if (syncEnvVarsFn && typeof syncEnvVarsFn === "function") {
|
||||
let resolvedEnvVars: {
|
||||
env: Record<string, string>;
|
||||
parentEnv?: Record<string, string>;
|
||||
secretEnv?: Record<string, string>;
|
||||
secretParentEnv?: Record<string, string>;
|
||||
} = {
|
||||
env: {},
|
||||
};
|
||||
let result;
|
||||
|
||||
try {
|
||||
result = await syncEnvVarsFn({
|
||||
projectRef: context.config.project,
|
||||
environment,
|
||||
env,
|
||||
branch,
|
||||
});
|
||||
} catch (error) {
|
||||
context.logger.warn("Error calling syncEnvVars function", error);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(result)) {
|
||||
for (const item of result) {
|
||||
if (
|
||||
typeof item === "object" &&
|
||||
item !== null &&
|
||||
"name" in item &&
|
||||
"value" in item &&
|
||||
typeof item.name === "string" &&
|
||||
typeof item.value === "string"
|
||||
) {
|
||||
if (item.isParentEnv) {
|
||||
if (item.isSecret) {
|
||||
resolvedEnvVars.secretParentEnv ??= {};
|
||||
resolvedEnvVars.secretParentEnv[item.name] = item.value;
|
||||
} else {
|
||||
resolvedEnvVars.parentEnv ??= {};
|
||||
resolvedEnvVars.parentEnv[item.name] = item.value;
|
||||
}
|
||||
} else if (item.isSecret) {
|
||||
resolvedEnvVars.secretEnv ??= {};
|
||||
resolvedEnvVars.secretEnv[item.name] = item.value;
|
||||
} else {
|
||||
resolvedEnvVars.env[item.name] = item.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (result) {
|
||||
resolvedEnvVars.env = result;
|
||||
}
|
||||
|
||||
return resolvedEnvVars;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { syncEnvVars } from "../core.js";
|
||||
|
||||
type EnvVar = { name: string; value: string; isParentEnv?: boolean };
|
||||
|
||||
type SupabaseBranch = {
|
||||
id: string;
|
||||
name: string;
|
||||
project_ref: string;
|
||||
parent_project_ref: string;
|
||||
is_default: boolean;
|
||||
git_branch: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type SupabaseBranchDetail = {
|
||||
ref: string;
|
||||
db_host: string;
|
||||
db_port: number;
|
||||
db_user: string;
|
||||
db_pass: string;
|
||||
jwt_secret: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type SupabaseApiKey = {
|
||||
name: string;
|
||||
api_key: string;
|
||||
};
|
||||
|
||||
// List of Supabase related environment variables to sync
|
||||
export const SUPABASE_ENV_VARS = [
|
||||
"DATABASE_URL",
|
||||
"POSTGRES_URL",
|
||||
"SUPABASE_DB_URL",
|
||||
"PGHOST",
|
||||
"PGPORT",
|
||||
"PGUSER",
|
||||
"PGPASSWORD",
|
||||
"PGDATABASE",
|
||||
"SUPABASE_URL",
|
||||
"SUPABASE_ANON_KEY",
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"SUPABASE_JWT_SECRET",
|
||||
];
|
||||
|
||||
function buildSupabaseEnvVarMappings(options: {
|
||||
user: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
ref: string;
|
||||
jwtSecret: string;
|
||||
anonKey?: string;
|
||||
serviceRoleKey?: string;
|
||||
}): Record<string, string> {
|
||||
const { user, password, host, port, database, ref, jwtSecret, anonKey, serviceRoleKey } = options;
|
||||
|
||||
const connectionString = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}?sslmode=require`;
|
||||
|
||||
const mappings: Record<string, string> = {
|
||||
DATABASE_URL: connectionString,
|
||||
POSTGRES_URL: connectionString,
|
||||
SUPABASE_DB_URL: connectionString,
|
||||
PGHOST: host,
|
||||
PGPORT: String(port),
|
||||
PGUSER: user,
|
||||
PGPASSWORD: password,
|
||||
PGDATABASE: database,
|
||||
SUPABASE_URL: `https://${ref}.supabase.co`,
|
||||
SUPABASE_JWT_SECRET: jwtSecret,
|
||||
};
|
||||
|
||||
if (anonKey) {
|
||||
mappings.SUPABASE_ANON_KEY = anonKey;
|
||||
}
|
||||
|
||||
if (serviceRoleKey) {
|
||||
mappings.SUPABASE_SERVICE_ROLE_KEY = serviceRoleKey;
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
export function syncSupabaseEnvVars(options?: {
|
||||
projectId?: string;
|
||||
/**
|
||||
* Supabase Management API access token for authentication.
|
||||
* It's recommended to use the SUPABASE_ACCESS_TOKEN environment variable instead of hardcoding this value.
|
||||
*/
|
||||
supabaseAccessToken?: string;
|
||||
branch?: string;
|
||||
envVarPrefix?: string;
|
||||
}): BuildExtension {
|
||||
const sync = syncEnvVars(async (ctx) => {
|
||||
// Skip for development environments
|
||||
if (ctx.environment === "dev") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const projectId =
|
||||
options?.projectId ?? process.env.SUPABASE_PROJECT_ID ?? ctx.env.SUPABASE_PROJECT_ID;
|
||||
const supabaseAccessToken =
|
||||
options?.supabaseAccessToken ??
|
||||
process.env.SUPABASE_ACCESS_TOKEN ??
|
||||
ctx.env.SUPABASE_ACCESS_TOKEN;
|
||||
const branch = options?.branch ?? ctx.branch;
|
||||
const envVarPrefix = options?.envVarPrefix ?? "";
|
||||
const outputEnvVars = SUPABASE_ENV_VARS;
|
||||
|
||||
// Skip the whole process for Vercel environments
|
||||
if (ctx.env.VERCEL) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a projectId or set the SUPABASE_PROJECT_ID env var."
|
||||
);
|
||||
}
|
||||
|
||||
if (!supabaseAccessToken) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a supabaseAccessToken or set the SUPABASE_ACCESS_TOKEN env var."
|
||||
);
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${supabaseAccessToken}`,
|
||||
};
|
||||
|
||||
// Step 1: List branches
|
||||
const branchesUrl = `https://api.supabase.com/v1/projects/${projectId}/branches`;
|
||||
const [branchesFetchError, branchesResponse] = await tryCatch(fetch(branchesUrl, { headers }));
|
||||
|
||||
if (branchesFetchError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: network error fetching branches from ${branchesUrl}: ${branchesFetchError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchesResponse.ok) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to list branches from ${branchesUrl} (status ${branchesResponse.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const [branchesParseError, branches] = await tryCatch(
|
||||
branchesResponse.json() as Promise<SupabaseBranch[]>
|
||||
);
|
||||
|
||||
if (branchesParseError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to parse branches response from ${branchesUrl}: ${branchesParseError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (branches.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Step 2: Find the target branch based on environment
|
||||
let targetBranch: SupabaseBranch | undefined;
|
||||
|
||||
if (ctx.environment === "prod") {
|
||||
targetBranch = branches.find((b) => b.is_default);
|
||||
|
||||
if (!targetBranch) {
|
||||
throw new Error("syncSupabaseEnvVars: no default Supabase branch found for the project.");
|
||||
}
|
||||
} else {
|
||||
if (!branch) {
|
||||
throw new Error(
|
||||
"syncSupabaseEnvVars: you did not pass in a branch and no branch was detected from context."
|
||||
);
|
||||
}
|
||||
|
||||
targetBranch = branches.find((b) => b.git_branch === branch || b.name === branch);
|
||||
|
||||
if (!targetBranch) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Get branch configuration (connection details)
|
||||
const branchDetailUrl = `https://api.supabase.com/v1/branches/${targetBranch.id}`;
|
||||
const [detailFetchError, branchDetailResponse] = await tryCatch(
|
||||
fetch(branchDetailUrl, { headers })
|
||||
);
|
||||
|
||||
if (detailFetchError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: network error fetching branch details from ${branchDetailUrl}: ${detailFetchError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!branchDetailResponse.ok) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to fetch branch details from ${branchDetailUrl} (status ${branchDetailResponse.status})`
|
||||
);
|
||||
}
|
||||
|
||||
const [detailParseError, branchDetail] = await tryCatch(
|
||||
branchDetailResponse.json() as Promise<SupabaseBranchDetail>
|
||||
);
|
||||
|
||||
if (detailParseError) {
|
||||
throw new Error(
|
||||
`syncSupabaseEnvVars: failed to parse branch details response from ${branchDetailUrl}: ${detailParseError.message}`
|
||||
);
|
||||
}
|
||||
|
||||
// Step 4: Get API keys for the branch project
|
||||
const apiKeysUrl = `https://api.supabase.com/v1/projects/${branchDetail.ref}/api-keys`;
|
||||
const [apiKeysFetchError, apiKeysResponse] = await tryCatch(fetch(apiKeysUrl, { headers }));
|
||||
|
||||
let anonKey: string | undefined;
|
||||
let serviceRoleKey: string | undefined;
|
||||
|
||||
if (apiKeysFetchError) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to fetch API keys from ${apiKeysUrl}: ${apiKeysFetchError.message}`
|
||||
);
|
||||
} else if (!apiKeysResponse.ok) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to fetch API keys from ${apiKeysUrl} (status ${apiKeysResponse.status})`
|
||||
);
|
||||
} else {
|
||||
const [apiKeysParseError, apiKeys] = await tryCatch(
|
||||
apiKeysResponse.json() as Promise<SupabaseApiKey[]>
|
||||
);
|
||||
|
||||
if (apiKeysParseError) {
|
||||
console.warn(
|
||||
`syncSupabaseEnvVars: failed to parse API keys response from ${apiKeysUrl}: ${apiKeysParseError.message}`
|
||||
);
|
||||
} else {
|
||||
anonKey = apiKeys.find((k) => k.name === "anon")?.api_key;
|
||||
serviceRoleKey = apiKeys.find((k) => k.name === "service_role")?.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Build environment variable mappings
|
||||
const envVarMappings = buildSupabaseEnvVarMappings({
|
||||
user: branchDetail.db_user,
|
||||
password: branchDetail.db_pass,
|
||||
host: branchDetail.db_host,
|
||||
port: branchDetail.db_port,
|
||||
database: "postgres",
|
||||
ref: branchDetail.ref,
|
||||
jwtSecret: branchDetail.jwt_secret,
|
||||
anonKey,
|
||||
serviceRoleKey,
|
||||
});
|
||||
|
||||
const newEnvVars: EnvVar[] = [];
|
||||
|
||||
for (const supabaseEnvVar of outputEnvVars) {
|
||||
const prefixedKey = `${envVarPrefix}${supabaseEnvVar}`;
|
||||
if (envVarMappings[supabaseEnvVar]) {
|
||||
newEnvVars.push({
|
||||
name: prefixedKey,
|
||||
value: envVarMappings[supabaseEnvVar],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newEnvVars;
|
||||
});
|
||||
|
||||
return {
|
||||
name: "SyncSupabaseEnvVarsExtension",
|
||||
async onBuildComplete(context, manifest) {
|
||||
await sync.onBuildComplete?.(context, manifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { syncEnvVars } from "../core.js";
|
||||
|
||||
type EnvVar = { name: string; value: string; isParentEnv?: boolean };
|
||||
|
||||
type VercelEnvVar = {
|
||||
key: string;
|
||||
value: string;
|
||||
type: string;
|
||||
target: string[];
|
||||
gitBranch?: string;
|
||||
};
|
||||
|
||||
export function syncVercelEnvVars(options?: {
|
||||
projectId?: string;
|
||||
/**
|
||||
* Vercel API access token for authentication.
|
||||
* It's recommended to use the VERCEL_ACCESS_TOKEN environment variable instead of hardcoding this value.
|
||||
*/
|
||||
vercelAccessToken?: string;
|
||||
vercelTeamId?: string;
|
||||
branch?: string;
|
||||
}): BuildExtension {
|
||||
const sync = syncEnvVars(async (ctx) => {
|
||||
const projectId =
|
||||
options?.projectId ?? process.env.VERCEL_PROJECT_ID ?? ctx.env.VERCEL_PROJECT_ID;
|
||||
const vercelAccessToken =
|
||||
options?.vercelAccessToken ??
|
||||
process.env.VERCEL_ACCESS_TOKEN ??
|
||||
ctx.env.VERCEL_ACCESS_TOKEN ??
|
||||
process.env.VERCEL_TOKEN;
|
||||
const vercelTeamId =
|
||||
options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID;
|
||||
const branch =
|
||||
options?.branch ??
|
||||
process.env.VERCEL_PREVIEW_BRANCH ??
|
||||
ctx.env.VERCEL_PREVIEW_BRANCH ??
|
||||
ctx.branch;
|
||||
const isVercelEnv = !!ctx.env.VERCEL;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error(
|
||||
"syncVercelEnvVars: you did not pass in a projectId or set the VERCEL_PROJECT_ID env var."
|
||||
);
|
||||
}
|
||||
|
||||
if (!vercelAccessToken) {
|
||||
throw new Error(
|
||||
"syncVercelEnvVars: you did not pass in a vercelAccessToken or set the VERCEL_ACCESS_TOKEN env var."
|
||||
);
|
||||
}
|
||||
|
||||
const environmentMap = {
|
||||
prod: "production",
|
||||
staging: "preview",
|
||||
dev: "development",
|
||||
preview: "preview",
|
||||
} as const;
|
||||
|
||||
const vercelEnvironment = environmentMap[ctx.environment as keyof typeof environmentMap];
|
||||
|
||||
if (!vercelEnvironment) {
|
||||
throw new Error(
|
||||
`Invalid environment '${ctx.environment}'. Expected 'prod', 'staging', or 'dev'.`
|
||||
);
|
||||
}
|
||||
const params = new URLSearchParams({ decrypt: "true" });
|
||||
if (vercelTeamId) params.set("teamId", vercelTeamId);
|
||||
params.set("target", vercelEnvironment);
|
||||
const vercelApiUrl = `https://api.vercel.com/v8/projects/${projectId}/env?${params}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(vercelApiUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${vercelAccessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const isBranchable = ctx.environment === "preview";
|
||||
|
||||
const filteredEnvs: EnvVar[] = data.envs
|
||||
.filter((env: VercelEnvVar) => {
|
||||
if (!env.target.includes(vercelEnvironment)) return false;
|
||||
if (isBranchable && env.gitBranch && env.gitBranch !== branch) return false;
|
||||
// When running in Vercel, prefer process.env but fall back to API value
|
||||
const value = isVercelEnv ? (process.env[env.key] ?? env.value) : env.value;
|
||||
if (!value) return false;
|
||||
return true;
|
||||
})
|
||||
.map((env: VercelEnvVar) => {
|
||||
// When running in Vercel, prefer process.env but fall back to API value
|
||||
const value = isVercelEnv ? (process.env[env.key] ?? env.value) : env.value;
|
||||
return {
|
||||
name: env.key,
|
||||
value,
|
||||
isParentEnv: isBranchable && !env.gitBranch,
|
||||
};
|
||||
});
|
||||
|
||||
return filteredEnvs;
|
||||
} catch (error) {
|
||||
console.error("Error fetching or processing Vercel environment variables:", error);
|
||||
throw error; // Re-throw the error to be handled by the caller
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
name: "SyncVercelEnvVarsExtension",
|
||||
async onBuildComplete(context, manifest) {
|
||||
await sync.onBuildComplete?.(context, manifest);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Barrel no more
|
||||
export type {
|
||||
BuildContext,
|
||||
BuildExtension,
|
||||
BuildLayer,
|
||||
BuildLogger,
|
||||
BuildSpinner,
|
||||
RegisteredPlugin,
|
||||
RegisterPluginOptions,
|
||||
PluginPlacement,
|
||||
ResolvedConfig,
|
||||
} from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type { BuildManifest, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
export { binaryForRuntime, esbuildPlugin } from "@trigger.dev/core/v3/build";
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
type LightpandaOpts = {
|
||||
version?: "nightly" | "latest";
|
||||
disableTelemetry?: boolean;
|
||||
};
|
||||
|
||||
export const lightpanda = ({
|
||||
version = "latest",
|
||||
disableTelemetry = false,
|
||||
}: LightpandaOpts = {}): BuildExtension => ({
|
||||
name: "lightpanda",
|
||||
onBuildComplete: async (context) => {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`Adding lightpanda`, { version, disableTelemetry });
|
||||
|
||||
const instructions = [
|
||||
`COPY --from=lightpanda/browser:${version} /usr/bin/lightpanda /usr/local/bin/lightpanda`,
|
||||
`RUN /usr/local/bin/lightpanda version || (echo "lightpanda binary is not functional" && exit 1)`,
|
||||
] satisfies string[];
|
||||
|
||||
context.addLayer({
|
||||
id: "lightpanda",
|
||||
image: {
|
||||
instructions,
|
||||
},
|
||||
deploy: {
|
||||
env: {
|
||||
...(disableTelemetry ? { LIGHTPANDA_DISABLE_TELEMETRY: "true" } : {}),
|
||||
},
|
||||
override: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import type { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import type { BuildManifest, BuildTarget } from "@trigger.dev/core/v3";
|
||||
|
||||
type PlaywrightBrowser = "chromium" | "firefox" | "webkit";
|
||||
|
||||
interface PlaywrightExtensionOptions {
|
||||
/**
|
||||
* Browsers to install. Select only needed browsers to optimize build time and size.
|
||||
* @default ["chromium"]
|
||||
*/
|
||||
browsers?: PlaywrightBrowser[];
|
||||
|
||||
/**
|
||||
* Run the browsers in headless mode (Recommended)
|
||||
* @default true
|
||||
*/
|
||||
headless?: boolean;
|
||||
|
||||
/**
|
||||
* Playwright version override. If not provided, we will try to detect the version automatically.
|
||||
*/
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* This list is from the official playwright registry.
|
||||
*
|
||||
* @see https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts
|
||||
*/
|
||||
const debian12Deps = {
|
||||
tools: [
|
||||
"xvfb",
|
||||
"fonts-noto-color-emoji",
|
||||
"fonts-unifont",
|
||||
"libfontconfig1",
|
||||
"libfreetype6",
|
||||
"xfonts-scalable",
|
||||
"fonts-liberation",
|
||||
"fonts-ipafont-gothic",
|
||||
"fonts-wqy-zenhei",
|
||||
"fonts-tlwg-loma-otf",
|
||||
"fonts-freefont-ttf",
|
||||
],
|
||||
chromium: [
|
||||
"libasound2",
|
||||
"libatk-bridge2.0-0",
|
||||
"libatk1.0-0",
|
||||
"libatspi2.0-0",
|
||||
"libcairo2",
|
||||
"libcups2",
|
||||
"libdbus-1-3",
|
||||
"libdrm2",
|
||||
"libgbm1",
|
||||
"libglib2.0-0",
|
||||
"libnspr4",
|
||||
"libnss3",
|
||||
"libpango-1.0-0",
|
||||
"libx11-6",
|
||||
"libxcb1",
|
||||
"libxcomposite1",
|
||||
"libxdamage1",
|
||||
"libxext6",
|
||||
"libxfixes3",
|
||||
"libxkbcommon0",
|
||||
"libxrandr2",
|
||||
],
|
||||
firefox: [
|
||||
"libasound2",
|
||||
"libatk1.0-0",
|
||||
"libcairo-gobject2",
|
||||
"libcairo2",
|
||||
"libdbus-1-3",
|
||||
"libdbus-glib-1-2",
|
||||
"libfontconfig1",
|
||||
"libfreetype6",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libglib2.0-0",
|
||||
"libgtk-3-0",
|
||||
"libharfbuzz0b",
|
||||
"libpango-1.0-0",
|
||||
"libpangocairo-1.0-0",
|
||||
"libx11-6",
|
||||
"libx11-xcb1",
|
||||
"libxcb-shm0",
|
||||
"libxcb1",
|
||||
"libxcomposite1",
|
||||
"libxcursor1",
|
||||
"libxdamage1",
|
||||
"libxext6",
|
||||
"libxfixes3",
|
||||
"libxi6",
|
||||
"libxrandr2",
|
||||
"libxrender1",
|
||||
"libxtst6",
|
||||
],
|
||||
webkit: [
|
||||
"libsoup-3.0-0",
|
||||
"gstreamer1.0-libav",
|
||||
"gstreamer1.0-plugins-bad",
|
||||
"gstreamer1.0-plugins-base",
|
||||
"gstreamer1.0-plugins-good",
|
||||
"libatk-bridge2.0-0",
|
||||
"libatk1.0-0",
|
||||
"libcairo2",
|
||||
"libdbus-1-3",
|
||||
"libdrm2",
|
||||
"libegl1",
|
||||
"libenchant-2-2",
|
||||
"libepoxy0",
|
||||
"libevdev2",
|
||||
"libfontconfig1",
|
||||
"libfreetype6",
|
||||
"libgbm1",
|
||||
"libgdk-pixbuf-2.0-0",
|
||||
"libgles2",
|
||||
"libglib2.0-0",
|
||||
"libglx0",
|
||||
"libgstreamer-gl1.0-0",
|
||||
"libgstreamer-plugins-base1.0-0",
|
||||
"libgstreamer1.0-0",
|
||||
"libgtk-4-1",
|
||||
"libgudev-1.0-0",
|
||||
"libharfbuzz-icu0",
|
||||
"libharfbuzz0b",
|
||||
"libhyphen0",
|
||||
"libicu72",
|
||||
"libjpeg62-turbo",
|
||||
"liblcms2-2",
|
||||
"libmanette-0.2-0",
|
||||
"libnotify4",
|
||||
"libopengl0",
|
||||
"libopenjp2-7",
|
||||
"libopus0",
|
||||
"libpango-1.0-0",
|
||||
"libpng16-16",
|
||||
"libproxy1v5",
|
||||
"libsecret-1-0",
|
||||
"libwayland-client0",
|
||||
"libwayland-egl1",
|
||||
"libwayland-server0",
|
||||
"libwebp7",
|
||||
"libwebpdemux2",
|
||||
"libwoff1",
|
||||
"libx11-6",
|
||||
"libxcomposite1",
|
||||
"libxdamage1",
|
||||
"libxkbcommon0",
|
||||
"libxml2",
|
||||
"libxslt1.1",
|
||||
"libatomic1",
|
||||
"libevent-2.1-7",
|
||||
"libavif15",
|
||||
],
|
||||
lib2package: {
|
||||
"libavif.so.15": "libavif15",
|
||||
"libsoup-3.0.so.0": "libsoup-3.0-0",
|
||||
"libasound.so.2": "libasound2",
|
||||
"libatk-1.0.so.0": "libatk1.0-0",
|
||||
"libatk-bridge-2.0.so.0": "libatk-bridge2.0-0",
|
||||
"libatspi.so.0": "libatspi2.0-0",
|
||||
"libcairo.so.2": "libcairo2",
|
||||
"libcups.so.2": "libcups2",
|
||||
"libdbus-1.so.3": "libdbus-1-3",
|
||||
"libdrm.so.2": "libdrm2",
|
||||
"libgbm.so.1": "libgbm1",
|
||||
"libgio-2.0.so.0": "libglib2.0-0",
|
||||
"libglib-2.0.so.0": "libglib2.0-0",
|
||||
"libgobject-2.0.so.0": "libglib2.0-0",
|
||||
"libnspr4.so": "libnspr4",
|
||||
"libnss3.so": "libnss3",
|
||||
"libnssutil3.so": "libnss3",
|
||||
"libpango-1.0.so.0": "libpango-1.0-0",
|
||||
"libsmime3.so": "libnss3",
|
||||
"libX11.so.6": "libx11-6",
|
||||
"libxcb.so.1": "libxcb1",
|
||||
"libXcomposite.so.1": "libxcomposite1",
|
||||
"libXdamage.so.1": "libxdamage1",
|
||||
"libXext.so.6": "libxext6",
|
||||
"libXfixes.so.3": "libxfixes3",
|
||||
"libxkbcommon.so.0": "libxkbcommon0",
|
||||
"libXrandr.so.2": "libxrandr2",
|
||||
"libgtk-4.so.1": "libgtk-4-1",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs Playwright browsers and dependencies for your Trigger.dev deployments.
|
||||
*
|
||||
* @param options - Configuration options for the Playwright extension.
|
||||
* @param options.browsers Browsers to install. Accepts an array of strings. Default: `["chromium"]`
|
||||
* @param options.headless Whether to run browsers in headless mode. Default: `true`
|
||||
* @param options.version Playwright version to use for browser installation. When not set, will automatically detect the version (recommended). Default: `undefined`
|
||||
*
|
||||
*/
|
||||
export function playwright(options: PlaywrightExtensionOptions = {}) {
|
||||
return new PlaywrightExtension(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Background:
|
||||
*
|
||||
* Running `npx playwright install --with-deps` normally will install the browsers and the dependencies.
|
||||
* However, this is not possible in a build context, because we don't have sudo access.
|
||||
*
|
||||
* So we need to install the dependencies manually and then download and install the browsers.
|
||||
* This has a few challenges:
|
||||
* 1. We don't want to download all browsers, only the ones we need with it's dependencies
|
||||
* The less dependencies we have to install, the faster the build, and the smaller the image.
|
||||
* 2. We need to know where to download the browsers from
|
||||
* while we can hardcode the download url it might change over time (as it has in the past)
|
||||
* so we need to download the browser info first and then parse the output to get the download url.
|
||||
*
|
||||
* Note: While this looks like we are downloading & installing a lot of stuff, it's actually not that bad
|
||||
* since running `npx playwright install --with-deps` will result in the same amount of downloads.
|
||||
*/
|
||||
class PlaywrightExtension implements BuildExtension {
|
||||
public readonly name = "PlaywrightExtension";
|
||||
private moduleExternals: string[];
|
||||
|
||||
private readonly options: Required<Omit<PlaywrightExtensionOptions, "version">> & {
|
||||
version?: string;
|
||||
};
|
||||
|
||||
constructor({
|
||||
browsers = ["chromium"],
|
||||
headless = true,
|
||||
version,
|
||||
}: PlaywrightExtensionOptions = {}) {
|
||||
if (browsers && browsers.length === 0) {
|
||||
throw new Error("At least one browser must be specified");
|
||||
}
|
||||
this.options = { browsers, headless, version };
|
||||
this.moduleExternals = ["playwright"];
|
||||
}
|
||||
|
||||
externalsForTarget(target: BuildTarget) {
|
||||
if (target === "dev") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.moduleExternals;
|
||||
}
|
||||
|
||||
onBuildComplete(context: BuildContext, manifest: BuildManifest) {
|
||||
if (context.target === "dev") return;
|
||||
|
||||
// Detect Playwright version from manifest.externals or use override
|
||||
const playwrightExternal = manifest.externals?.find(
|
||||
(external: any) => external.name === "playwright" || external.name === "@playwright/test"
|
||||
);
|
||||
const version = playwrightExternal?.version ?? this.options.version;
|
||||
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
"PlaywrightExtension could not determine the version of playwright. Please provide a version in the PlaywrightExtension options."
|
||||
);
|
||||
}
|
||||
|
||||
context.logger.debug(
|
||||
`Adding ${this.name} to the build with browsers: ${this.options.browsers.join(
|
||||
", "
|
||||
)}, version: ${version}`
|
||||
);
|
||||
|
||||
const instructions: string[] = [
|
||||
// Base dependencies, we need these to download the browsers
|
||||
`RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
unzip \
|
||||
jq \
|
||||
grep \
|
||||
sed \
|
||||
npm \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*`,
|
||||
|
||||
// Install Playwright globally with detected version
|
||||
`RUN npm install -g playwright@${version}`,
|
||||
];
|
||||
|
||||
const deps = [...debian12Deps.tools, ...Object.values(debian12Deps.lib2package)];
|
||||
if (this.options.browsers.includes("chromium")) deps.push(...debian12Deps.chromium);
|
||||
if (this.options.browsers.includes("firefox")) deps.push(...debian12Deps.firefox);
|
||||
if (this.options.browsers.includes("webkit")) deps.push(...debian12Deps.webkit);
|
||||
|
||||
instructions.push(
|
||||
`RUN apt-get update && apt-get install -y --no-install-recommends ${deps.join(" ")} \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*`
|
||||
);
|
||||
|
||||
// Setup directory for playwright browsers
|
||||
instructions.push(`RUN mkdir -p /ms-playwright`);
|
||||
|
||||
/**
|
||||
* `npx playwright install --dry-run` prints the download urls for the browsers.
|
||||
* We save this output to a file and then parse it to get the download urls for the browsers.
|
||||
*/
|
||||
instructions.push(`RUN npx playwright install --dry-run > /tmp/browser-info.txt`);
|
||||
|
||||
// Determine which browsers to actually install
|
||||
const browsersToInstall = new Set<PlaywrightBrowser | "chromium-headless-shell">();
|
||||
|
||||
this.options.browsers.forEach((browser) => {
|
||||
if (browser === "chromium") {
|
||||
if (this.options.headless) {
|
||||
// For headless mode, only install chromium-headless-shell
|
||||
browsersToInstall.add("chromium-headless-shell");
|
||||
} else {
|
||||
// For non-headless mode, install both chromium and chromium-headless-shell
|
||||
// This allows users to easily switch between headless and non-headless mode
|
||||
browsersToInstall.add("chromium");
|
||||
browsersToInstall.add("chromium-headless-shell");
|
||||
}
|
||||
} else {
|
||||
browsersToInstall.add(browser);
|
||||
}
|
||||
});
|
||||
|
||||
Array.from(browsersToInstall).forEach((browser) => {
|
||||
instructions.push(
|
||||
`RUN grep -A5 -m1 "browser: ${browser}" /tmp/browser-info.txt > /tmp/${browser}-info.txt`,
|
||||
|
||||
`RUN INSTALL_DIR=$(grep "Install location:" /tmp/${browser}-info.txt | cut -d':' -f2- | xargs) && \
|
||||
DIR_NAME=$(basename "$INSTALL_DIR") && \
|
||||
if [ -z "$DIR_NAME" ]; then echo "Failed to extract installation directory for ${browser}"; exit 1; fi && \
|
||||
MS_DIR="/ms-playwright/$DIR_NAME" && \
|
||||
mkdir -p "$MS_DIR"`,
|
||||
|
||||
`RUN DOWNLOAD_URL=$(grep "Download url:" /tmp/${browser}-info.txt | cut -d':' -f2- | xargs | sed "s/mac-arm64/linux/g" | sed "s/mac-15-arm64/ubuntu-20.04/g") && \
|
||||
if [ -z "$DOWNLOAD_URL" ]; then echo "Failed to extract download URL for ${browser}"; exit 1; fi && \
|
||||
echo "Downloading ${browser} from $DOWNLOAD_URL" && \
|
||||
curl -L -o /tmp/${browser}.zip "$DOWNLOAD_URL" && \
|
||||
if [ $? -ne 0 ]; then echo "Failed to download ${browser}"; exit 1; fi && \
|
||||
unzip -q /tmp/${browser}.zip -d "/ms-playwright/$(basename $(grep "Install location:" /tmp/${browser}-info.txt | cut -d':' -f2- | xargs))" && \
|
||||
if [ $? -ne 0 ]; then echo "Failed to extract ${browser}"; exit 1; fi && \
|
||||
chmod -R +x "/ms-playwright/$(basename $(grep "Install location:" /tmp/${browser}-info.txt | cut -d':' -f2- | xargs))" && \
|
||||
rm /tmp/${browser}.zip`
|
||||
);
|
||||
});
|
||||
|
||||
// Environment variables
|
||||
const envVars: Record<string, string> = {
|
||||
PLAYWRIGHT_BROWSERS_PATH: "/ms-playwright", // where playwright will find the browsers
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1", // we already downloaded the browsers
|
||||
PLAYWRIGHT_SKIP_BROWSER_VALIDATION: "1", // we already downloaded the browsers
|
||||
};
|
||||
|
||||
if (!this.options.headless) {
|
||||
instructions.push(
|
||||
`RUN echo '#!/bin/sh' > /usr/local/bin/xvfb-exec`,
|
||||
`RUN echo 'Xvfb :99 -screen 0 1024x768x24 &' >> /usr/local/bin/xvfb-exec`,
|
||||
`RUN echo 'exec "$@"' >> /usr/local/bin/xvfb-exec`,
|
||||
`RUN chmod +x /usr/local/bin/xvfb-exec`
|
||||
);
|
||||
|
||||
envVars.DISPLAY = ":99"; // Virtual display for the browsers
|
||||
}
|
||||
|
||||
context.addLayer({
|
||||
id: "playwright",
|
||||
image: {
|
||||
instructions,
|
||||
},
|
||||
deploy: {
|
||||
env: envVars,
|
||||
override: true,
|
||||
},
|
||||
dependencies: {
|
||||
playwright: version,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3";
|
||||
import { binaryForRuntime, BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import assert from "node:assert";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { cp, readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { resolvePathSync as esmResolveSync } from "../imports/mlly.js";
|
||||
import { resolvePackageJSON } from "pkg-types";
|
||||
import { LoadConfigFromFileError } from "@prisma/config";
|
||||
|
||||
export type PrismaLegacyModeExtensionOptions = {
|
||||
/**
|
||||
* Legacy mode configuration for Prisma 5.x/6.x with the `prisma-client-js` provider.
|
||||
*
|
||||
* **Use this mode when:**
|
||||
* - Using Prisma 5.x or 6.x with `prisma-client-js` provider
|
||||
* - You want automatic `prisma generate` during deployment
|
||||
* - You need migration support
|
||||
*
|
||||
* **Key features:**
|
||||
* - Automatic client generation
|
||||
* - Multi-file schema support (Prisma 6.7+)
|
||||
* - Config file support (`prisma.config.ts`)
|
||||
* - TypedSQL support
|
||||
* - Automatic version detection
|
||||
*/
|
||||
mode: "legacy";
|
||||
/**
|
||||
* Path to your Prisma schema file or directory.
|
||||
*
|
||||
* **Examples:**
|
||||
* - Single file: `"./prisma/schema.prisma"`
|
||||
* - Multi-file (Prisma 6.7+): `"./prisma"`
|
||||
*
|
||||
* **Note:** Either `schema` or `configFile` must be specified, but not both.
|
||||
*/
|
||||
schema?: string;
|
||||
/**
|
||||
* Path to your Prisma config file (`prisma.config.ts`).
|
||||
*
|
||||
* Uses `@prisma/config` to automatically extract schema and migrations paths.
|
||||
* Requires Prisma 6+ with config file support.
|
||||
*
|
||||
* **Example:**
|
||||
* ```ts
|
||||
* prismaExtension({
|
||||
* mode: "legacy",
|
||||
* configFile: "./prisma.config.ts",
|
||||
* migrate: true,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* **Note:** Either `schema` or `configFile` must be specified, but not both.
|
||||
*/
|
||||
configFile?: string;
|
||||
/**
|
||||
* Enable automatic database migrations during deployment.
|
||||
*
|
||||
* Runs `prisma migrate deploy` before generating the client.
|
||||
* Requires `directUrlEnvVarName` to be set.
|
||||
*/
|
||||
migrate?: boolean;
|
||||
/**
|
||||
* Override the auto-detected Prisma version.
|
||||
*
|
||||
* **Auto-detection:** Checks externals, then `@prisma/client` in node_modules, then `prisma` package.
|
||||
*/
|
||||
version?: string;
|
||||
/**
|
||||
* Enable TypedSQL support. Adds `--sql` flag to `prisma generate`.
|
||||
*
|
||||
* Requires Prisma 5.19+ and `previewFeatures = ["typedSql"]` in your schema.
|
||||
*/
|
||||
typedSql?: boolean;
|
||||
/**
|
||||
* Specify which generator to use when you have multiple generators.
|
||||
*
|
||||
* Adds `--generator=<name>` to only generate the specified generator.
|
||||
* Useful for skipping extra generators like `typegraphql-prisma`.
|
||||
*/
|
||||
clientGenerator?: string;
|
||||
/**
|
||||
* Environment variable name for the direct (unpooled) database connection.
|
||||
*
|
||||
* Required for migrations. Common values: `"DATABASE_URL_UNPOOLED"`, `"DIRECT_URL"`.
|
||||
*/
|
||||
directUrlEnvVarName?: string;
|
||||
};
|
||||
|
||||
export type PrismaEngineOnlyModeExtensionOptions = {
|
||||
/**
|
||||
* Engine-only mode for custom Prisma client output paths.
|
||||
*
|
||||
* **Use this mode when:**
|
||||
* - You're using a custom output path for Prisma Client
|
||||
* - You want to control when `prisma generate` runs
|
||||
* - You run `prisma generate` in your build pipeline
|
||||
*
|
||||
* **What it does:**
|
||||
* - Installs engine binaries only (no client generation)
|
||||
* - Sets `PRISMA_QUERY_ENGINE_LIBRARY` and `PRISMA_QUERY_ENGINE_SCHEMA_ENGINE` env vars
|
||||
* - Auto-detects version from filesystem
|
||||
*
|
||||
* **You must:** Run `prisma generate` yourself and include correct `binaryTargets` in your schema.
|
||||
*/
|
||||
mode: "engine-only";
|
||||
/**
|
||||
* Prisma version to use. Auto-detected from `@prisma/client` or `prisma` package if omitted.
|
||||
*
|
||||
* **Recommended:** Specify explicitly for reproducible builds.
|
||||
*/
|
||||
version?: string;
|
||||
|
||||
/**
|
||||
* Binary target platform for Prisma engines.
|
||||
*
|
||||
* **Default:** `"debian-openssl-3.0.x"` (for Trigger.dev Cloud)
|
||||
* **Local Docker on ARM:** `"linux-arm64-openssl-3.0.x"`
|
||||
*/
|
||||
binaryTarget?: string;
|
||||
|
||||
/**
|
||||
* Suppress progress messages during the build.
|
||||
*/
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
export type PrismaEngineModernModeExtensionOptions = {
|
||||
/**
|
||||
* Modern mode for Prisma 6.16+ (with `prisma-client` provider) and Prisma 7.
|
||||
*
|
||||
* **Use this mode when:**
|
||||
* - Using Prisma 6.16+ with `provider = "prisma-client"` and `engineType = "client"`
|
||||
* - Using Prisma 7 beta or later
|
||||
* - Using database adapters (e.g., `@prisma/adapter-pg`)
|
||||
*
|
||||
* **What it does:**
|
||||
* - Marks `@prisma/client` as external (zero config)
|
||||
* - Works with TypeScript-only client (no Rust binaries)
|
||||
*
|
||||
* **You must:** Run `prisma generate` yourself and install database adapters.
|
||||
*/
|
||||
mode: "modern";
|
||||
};
|
||||
|
||||
export type PrismaExtensionOptions =
|
||||
| PrismaLegacyModeExtensionOptions
|
||||
| PrismaEngineOnlyModeExtensionOptions
|
||||
| PrismaEngineModernModeExtensionOptions;
|
||||
|
||||
const BINARY_TARGET = "linux-arm64-openssl-3.0.x";
|
||||
|
||||
/**
|
||||
* Attempts to resolve the Prisma client version from the project.
|
||||
* Tries @prisma/client first, then falls back to the prisma package.
|
||||
*/
|
||||
async function resolvePrismaClientVersion(
|
||||
workingDir: string,
|
||||
logger: {
|
||||
debug: (message: string, data?: any) => void;
|
||||
}
|
||||
): Promise<string | undefined> {
|
||||
// Try @prisma/client first
|
||||
const clientVersion = await tryResolvePrismaPackageVersion("@prisma/client", workingDir, logger);
|
||||
if (clientVersion) {
|
||||
return clientVersion;
|
||||
}
|
||||
|
||||
// Fall back to prisma package
|
||||
const prismaVersion = await tryResolvePrismaPackageVersion("prisma", workingDir, logger);
|
||||
if (prismaVersion) {
|
||||
return prismaVersion;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to resolve a specific Prisma package and extract its version
|
||||
*/
|
||||
async function tryResolvePrismaPackageVersion(
|
||||
packageName: string,
|
||||
workingDir: string,
|
||||
logger: {
|
||||
debug: (message: string, data?: any) => void;
|
||||
}
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
// Try to resolve the package using esmResolveSync
|
||||
const resolvedPath = esmResolveSync(packageName, {
|
||||
url: workingDir,
|
||||
});
|
||||
|
||||
logger.debug(`Resolved ${packageName} module path`, {
|
||||
resolvedPath,
|
||||
workingDir,
|
||||
packageName,
|
||||
});
|
||||
|
||||
// Find the package.json for this resolved module
|
||||
const packageJsonPath = await resolvePackageJSON(dirname(resolvedPath), {
|
||||
test: async (filePath) => {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const candidate = JSON.parse(content);
|
||||
|
||||
// Exclude esm type markers
|
||||
return Object.keys(candidate).length > 1 || !candidate.type;
|
||||
} catch (error) {
|
||||
logger.debug("Error during package.json test", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
filePath,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!packageJsonPath) {
|
||||
logger.debug(`No package.json found for ${packageName}`, {
|
||||
resolvedPath,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
logger.debug(`Found package.json for ${packageName}`, {
|
||||
packageJsonPath,
|
||||
});
|
||||
|
||||
// Read and parse the package.json
|
||||
const packageJsonContent = await readFile(packageJsonPath, "utf-8");
|
||||
const packageJson = JSON.parse(packageJsonContent);
|
||||
|
||||
if (packageJson.name === packageName && packageJson.version) {
|
||||
logger.debug(`Detected ${packageName} version`, {
|
||||
version: packageJson.version,
|
||||
});
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
logger.debug(`Package name mismatch or no version in package.json for ${packageName}`, {
|
||||
expectedName: packageName,
|
||||
actualName: packageJson.name,
|
||||
version: packageJson.version,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
logger.debug(`Failed to resolve ${packageName}`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Prisma config file using @prisma/config and extracts the schema path and other configuration
|
||||
*/
|
||||
async function loadPrismaConfig(
|
||||
configFilePath: string,
|
||||
workingDir: string,
|
||||
logger: {
|
||||
debug: (message: string, data?: any) => void;
|
||||
}
|
||||
): Promise<{
|
||||
schema: string;
|
||||
migrationsPath?: string;
|
||||
}> {
|
||||
try {
|
||||
// Resolve the config file path relative to the working directory
|
||||
const resolvedConfigPath = resolve(workingDir, configFilePath);
|
||||
|
||||
logger.debug(`[PrismaExtension] loadPrismaConfig called`, {
|
||||
configFilePath,
|
||||
resolvedConfigPath,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
// Check that the config file exists
|
||||
if (!existsSync(resolvedConfigPath)) {
|
||||
throw new Error(
|
||||
`Prisma config file not found at ${resolvedConfigPath}. Make sure the path is correct: ${configFilePath}, relative to the working dir ${workingDir}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`[PrismaExtension] Config file exists, loading with @prisma/config`);
|
||||
|
||||
// Dynamically import @prisma/config
|
||||
const { loadConfigFromFile } = await import("@prisma/config");
|
||||
|
||||
// Load the config using @prisma/config
|
||||
const configResult = await loadConfigFromFile({
|
||||
configFile: resolvedConfigPath,
|
||||
configRoot: workingDir,
|
||||
});
|
||||
|
||||
logger.debug(`[PrismaExtension] loadConfigFromFile completed`, {
|
||||
hasError: !!configResult.error,
|
||||
errorTag: configResult.error?._tag,
|
||||
});
|
||||
|
||||
function prettyConfigError(error: LoadConfigFromFileError): string {
|
||||
switch (error._tag) {
|
||||
case "ConfigFileNotFound":
|
||||
return `Config file not found at ${resolvedConfigPath}`;
|
||||
case "ConfigLoadError":
|
||||
return `Config file parse error: ${error.error.message}`;
|
||||
case "ConfigFileSyntaxError":
|
||||
return `Config file syntax error: ${error.error.message}`;
|
||||
default:
|
||||
return `Unknown config error: ${String(error.error.message)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (configResult.error) {
|
||||
throw new Error(
|
||||
`Failed to load Prisma config from ${resolvedConfigPath}: ${prettyConfigError(
|
||||
configResult.error
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug(`[PrismaExtension] Config parsed successfully`, {
|
||||
schema: configResult.config.schema,
|
||||
migrationsPath: configResult.config.migrations?.path,
|
||||
fullMigrations: configResult.config.migrations,
|
||||
});
|
||||
|
||||
// Extract the schema path
|
||||
if (!configResult.config.schema) {
|
||||
throw new Error(`Prisma config file at ${resolvedConfigPath} does not specify a schema path`);
|
||||
}
|
||||
|
||||
const result = {
|
||||
schema: configResult.config.schema,
|
||||
migrationsPath: configResult.config.migrations?.path,
|
||||
};
|
||||
|
||||
logger.debug(`[PrismaExtension] Returning config result`, result);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.debug(`[PrismaExtension] Error loading config`, {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to load Prisma config from ${configFilePath}: ${
|
||||
error instanceof Error ? error.message : error
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prisma build extension for Trigger.dev deployments.
|
||||
*
|
||||
* **Three modes available:**
|
||||
* - `"legacy"` - Prisma 5.x/6.x with `prisma-client-js`, automatic generation
|
||||
* - `"engine-only"` - Custom output paths, manual generation control
|
||||
* - `"modern"` - Prisma 6.16+/7.x with `prisma-client` provider
|
||||
*
|
||||
* @example Legacy mode (most common)
|
||||
* ```ts
|
||||
* prismaExtension({
|
||||
* mode: "legacy",
|
||||
* schema: "prisma/schema.prisma",
|
||||
* migrate: true,
|
||||
* typedSql: true,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Engine-only mode (custom output)
|
||||
* ```ts
|
||||
* prismaExtension({
|
||||
* mode: "engine-only",
|
||||
* version: "6.19.0",
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example Modern mode (Prisma 7)
|
||||
* ```ts
|
||||
* prismaExtension({
|
||||
* mode: "modern",
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function prismaExtension(options: PrismaExtensionOptions): BuildExtension {
|
||||
switch (options.mode) {
|
||||
case "legacy":
|
||||
return new PrismaLegacyModeExtension(options);
|
||||
case "engine-only":
|
||||
return new PrismaEngineOnlyModeExtension(options);
|
||||
case "modern":
|
||||
return new PrismaEngineModernModeExtension(options);
|
||||
default:
|
||||
return new PrismaLegacyModeExtension(options);
|
||||
}
|
||||
}
|
||||
|
||||
export class PrismaLegacyModeExtension implements BuildExtension {
|
||||
moduleExternals: string[];
|
||||
|
||||
public readonly name = "PrismaExtension";
|
||||
|
||||
private _resolvedSchemaPath?: string;
|
||||
private _loadedConfig?: {
|
||||
schema: string;
|
||||
migrationsPath?: string;
|
||||
};
|
||||
|
||||
constructor(private options: PrismaLegacyModeExtensionOptions) {
|
||||
this.moduleExternals = ["@prisma/client", "@prisma/engines"];
|
||||
}
|
||||
|
||||
externalsForTarget(target: BuildTarget) {
|
||||
if (target === "dev") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.moduleExternals;
|
||||
}
|
||||
|
||||
async onBuildStart(context: BuildContext) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`[PrismaExtension] onBuildStart called`, {
|
||||
workingDir: context.workingDir,
|
||||
options: {
|
||||
schema: this.options.schema,
|
||||
configFile: this.options.configFile,
|
||||
migrate: this.options.migrate,
|
||||
version: this.options.version,
|
||||
typedSql: this.options.typedSql,
|
||||
clientGenerator: this.options.clientGenerator,
|
||||
directUrlEnvVarName: this.options.directUrlEnvVarName,
|
||||
},
|
||||
});
|
||||
|
||||
// Validate that either schema or configFile is provided, but not both
|
||||
if (!this.options.schema && !this.options.configFile) {
|
||||
throw new Error(
|
||||
`PrismaExtension requires either 'schema' or 'configFile' to be specified in the options`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.options.schema && this.options.configFile) {
|
||||
throw new Error(
|
||||
`PrismaExtension cannot have both 'schema' and 'configFile' specified. Please use only one.`
|
||||
);
|
||||
}
|
||||
|
||||
let schemaPath: string;
|
||||
|
||||
// If configFile is specified, load it and extract the schema path
|
||||
if (this.options.configFile) {
|
||||
context.logger.debug(
|
||||
`[PrismaExtension] Loading Prisma config from ${this.options.configFile}`
|
||||
);
|
||||
|
||||
this._loadedConfig = await loadPrismaConfig(
|
||||
this.options.configFile,
|
||||
context.workingDir,
|
||||
context.logger
|
||||
);
|
||||
|
||||
schemaPath = this._loadedConfig.schema;
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Config loaded successfully`, {
|
||||
schema: this._loadedConfig.schema,
|
||||
migrationsPath: this._loadedConfig.migrationsPath,
|
||||
});
|
||||
} else {
|
||||
schemaPath = this.options.schema!;
|
||||
context.logger.debug(`[PrismaExtension] Using schema from options: ${schemaPath}`);
|
||||
}
|
||||
|
||||
// Resolve the path to the prisma schema, relative to the config.directory
|
||||
this._resolvedSchemaPath = resolve(context.workingDir, schemaPath);
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Resolved schema path`, {
|
||||
schemaPath,
|
||||
resolvedSchemaPath: this._resolvedSchemaPath,
|
||||
workingDir: context.workingDir,
|
||||
});
|
||||
|
||||
// Check that the prisma schema exists
|
||||
if (!existsSync(this._resolvedSchemaPath)) {
|
||||
throw new Error(
|
||||
`PrismaExtension could not find the prisma schema at ${this._resolvedSchemaPath}. Make sure the path is correct: ${schemaPath}, relative to the working dir ${context.workingDir}`
|
||||
);
|
||||
}
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Schema file exists at ${this._resolvedSchemaPath}`);
|
||||
}
|
||||
|
||||
async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`[PrismaExtension] onBuildComplete called`);
|
||||
|
||||
assert(this._resolvedSchemaPath, "Resolved schema path is not set");
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Looking for @prisma/client in the externals`, {
|
||||
externals: manifest.externals,
|
||||
});
|
||||
|
||||
const prismaExternal = manifest.externals?.find(
|
||||
(external) => external.name === "@prisma/client"
|
||||
);
|
||||
|
||||
let version = prismaExternal?.version ?? this.options.version;
|
||||
|
||||
// If we couldn't find the version in externals or options, try to resolve it from the filesystem
|
||||
if (!version) {
|
||||
context.logger.debug(
|
||||
`[PrismaExtension] Version not found in externals, attempting to detect from filesystem`
|
||||
);
|
||||
|
||||
version = await resolvePrismaClientVersion(context.workingDir, context.logger);
|
||||
|
||||
if (version) {
|
||||
context.logger.debug(`[PrismaExtension] Detected version from filesystem: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`PrismaExtension could not determine the version of @prisma/client. It's possible that the @prisma/client was not used in the project. If this isn't the case, please provide a version in the PrismaExtension options.`
|
||||
);
|
||||
}
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Using Prisma version ${version}`, {
|
||||
source: prismaExternal ? "externals" : this.options.version ? "options" : "filesystem",
|
||||
});
|
||||
|
||||
// Detect if this is a multi-file schema (directory) or single file schema
|
||||
const isMultiFileSchema = statSync(this._resolvedSchemaPath).isDirectory();
|
||||
const usingSchemaFolder =
|
||||
!isMultiFileSchema && dirname(this._resolvedSchemaPath).endsWith("schema");
|
||||
|
||||
context.logger.debug(`Schema detection`, {
|
||||
isMultiFileSchema,
|
||||
usingSchemaFolder,
|
||||
resolvedSchemaPath: this._resolvedSchemaPath,
|
||||
});
|
||||
|
||||
let commands: string[] = [];
|
||||
|
||||
let prismaDir: string | undefined;
|
||||
|
||||
const generatorFlags: string[] = [];
|
||||
|
||||
if (this.options.clientGenerator) {
|
||||
generatorFlags.push(`--generator=${this.options.clientGenerator}`);
|
||||
}
|
||||
|
||||
if (this.options.typedSql) {
|
||||
generatorFlags.push(`--sql`);
|
||||
|
||||
// Determine the prisma directory based on the schema structure
|
||||
let prismaDirForSql: string;
|
||||
if (isMultiFileSchema) {
|
||||
// For multi-file schemas, the resolved path IS the prisma directory
|
||||
prismaDirForSql = this._resolvedSchemaPath;
|
||||
} else if (usingSchemaFolder) {
|
||||
// For schema folders (e.g., prisma/schema/*.prisma), go up two levels
|
||||
prismaDirForSql = dirname(dirname(this._resolvedSchemaPath));
|
||||
} else {
|
||||
// For single file schemas (e.g., prisma/schema.prisma), go up one level
|
||||
prismaDirForSql = dirname(this._resolvedSchemaPath);
|
||||
}
|
||||
|
||||
context.logger.debug(`Using typedSql`, {
|
||||
prismaDirForSql,
|
||||
});
|
||||
|
||||
// Find all the files prisma/sql/*.sql
|
||||
const sqlFiles = await readdir(join(prismaDirForSql, "sql")).then((files) =>
|
||||
files.filter((file) => file.endsWith(".sql"))
|
||||
);
|
||||
|
||||
context.logger.debug(`Found sql files`, {
|
||||
sqlFiles,
|
||||
});
|
||||
|
||||
const sqlDestinationPath = join(manifest.outputPath, "prisma", "sql");
|
||||
|
||||
for (const file of sqlFiles) {
|
||||
const destination = join(sqlDestinationPath, file);
|
||||
const source = join(prismaDirForSql, "sql", file);
|
||||
|
||||
context.logger.debug(`Copying the sql from ${source} to ${destination}`);
|
||||
|
||||
await cp(source, destination);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMultiFileSchema) {
|
||||
// For multi-file schemas, the resolved path IS the prisma directory
|
||||
prismaDir = this._resolvedSchemaPath;
|
||||
|
||||
context.logger.debug(`Using multi-file schema directory: ${prismaDir}`);
|
||||
|
||||
// Copy the entire prisma directory to the build output path
|
||||
const prismaDestinationPath = join(manifest.outputPath, "prisma");
|
||||
|
||||
context.logger.debug(
|
||||
`Copying the prisma directory from ${prismaDir} to ${prismaDestinationPath}`
|
||||
);
|
||||
|
||||
const prismaDirForFilter = prismaDir;
|
||||
await cp(prismaDir, prismaDestinationPath, {
|
||||
recursive: true,
|
||||
// Filter out migrations and sql directories as they're handled separately if needed
|
||||
filter: (source) => {
|
||||
const relativePath = source.replace(prismaDirForFilter, "");
|
||||
// Skip migrations and sql directories during initial copy
|
||||
return !relativePath.startsWith("/migrations") && !relativePath.startsWith("/sql");
|
||||
},
|
||||
});
|
||||
|
||||
commands.push(
|
||||
`${binaryForRuntime(
|
||||
manifest.runtime
|
||||
)} node_modules/prisma/build/index.js generate ${generatorFlags.join(" ")}` // Don't add the --schema flag when using directory
|
||||
);
|
||||
} else if (usingSchemaFolder) {
|
||||
const schemaDir = dirname(this._resolvedSchemaPath);
|
||||
|
||||
prismaDir = dirname(schemaDir);
|
||||
|
||||
context.logger.debug(`Using the schema folder: ${schemaDir}`);
|
||||
|
||||
// Find all the files in schemaDir that end with .prisma (excluding the schema.prisma file)
|
||||
const prismaFiles = await readdir(schemaDir).then((files) =>
|
||||
files.filter((file) => file.endsWith(".prisma"))
|
||||
);
|
||||
|
||||
context.logger.debug(`Found prisma files in the schema folder`, {
|
||||
prismaFiles,
|
||||
});
|
||||
|
||||
const schemaDestinationPath = join(manifest.outputPath, "prisma", "schema");
|
||||
|
||||
const allPrismaFiles = [...prismaFiles];
|
||||
|
||||
for (const file of allPrismaFiles) {
|
||||
const destination = join(schemaDestinationPath, file);
|
||||
const source = join(schemaDir, file);
|
||||
|
||||
context.logger.debug(`Copying the prisma schema from ${source} to ${destination}`);
|
||||
|
||||
await cp(source, destination);
|
||||
}
|
||||
|
||||
commands.push(
|
||||
`${binaryForRuntime(
|
||||
manifest.runtime
|
||||
)} node_modules/prisma/build/index.js generate ${generatorFlags.join(" ")}` // Don't add the --schema flag or this will fail
|
||||
);
|
||||
} else {
|
||||
prismaDir = dirname(this._resolvedSchemaPath);
|
||||
// Now we need to add a layer that:
|
||||
// Copies the prisma schema to the build outputPath
|
||||
// Adds the `prisma` CLI dependency to the dependencies
|
||||
// Adds the `prisma generate` command, which generates the Prisma client
|
||||
const schemaDestinationPath = join(manifest.outputPath, "prisma", "schema.prisma");
|
||||
// Copy the prisma schema to the build output path
|
||||
context.logger.debug(
|
||||
`Copying the prisma schema from ${this._resolvedSchemaPath} to ${schemaDestinationPath}`
|
||||
);
|
||||
|
||||
await cp(this._resolvedSchemaPath, schemaDestinationPath);
|
||||
|
||||
commands.push(
|
||||
`${binaryForRuntime(
|
||||
manifest.runtime
|
||||
)} node_modules/prisma/build/index.js generate --schema=./prisma/schema.prisma ${generatorFlags.join(
|
||||
" "
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const env: Record<string, string | undefined> = {};
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Checking if migrations are enabled`, {
|
||||
migrate: this.options.migrate,
|
||||
loadedConfigMigrationsPath: this._loadedConfig?.migrationsPath,
|
||||
prismaDir,
|
||||
});
|
||||
|
||||
if (this.options.migrate) {
|
||||
context.logger.debug(
|
||||
`[PrismaExtension] Migrations enabled, determining migrations directory`
|
||||
);
|
||||
|
||||
// Determine the migrations directory path
|
||||
let migrationsDir: string;
|
||||
|
||||
if (this._loadedConfig?.migrationsPath) {
|
||||
// Use the migrations path from the config file
|
||||
migrationsDir = resolve(context.workingDir, this._loadedConfig.migrationsPath);
|
||||
context.logger.debug(`[PrismaExtension] Using migrations path from config`, {
|
||||
configMigrationsPath: this._loadedConfig.migrationsPath,
|
||||
resolvedMigrationsDir: migrationsDir,
|
||||
workingDir: context.workingDir,
|
||||
});
|
||||
} else {
|
||||
// Fall back to the default migrations directory
|
||||
migrationsDir = join(prismaDir, "migrations");
|
||||
context.logger.debug(`[PrismaExtension] Using default migrations path`, {
|
||||
prismaDir,
|
||||
migrationsDir,
|
||||
});
|
||||
}
|
||||
|
||||
const migrationsDestinationPath = join(manifest.outputPath, "prisma", "migrations");
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Checking if migrations directory exists`, {
|
||||
migrationsDir,
|
||||
exists: existsSync(migrationsDir),
|
||||
});
|
||||
|
||||
if (!existsSync(migrationsDir)) {
|
||||
context.logger.warn(
|
||||
`[PrismaExtension] Migrations directory not found at ${migrationsDir}. Skipping migrations copy.`
|
||||
);
|
||||
} else {
|
||||
context.logger.debug(
|
||||
`[PrismaExtension] Copying prisma migrations from ${migrationsDir} to ${migrationsDestinationPath}`
|
||||
);
|
||||
|
||||
await cp(migrationsDir, migrationsDestinationPath, { recursive: true });
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Migrations copied successfully`);
|
||||
|
||||
commands = [
|
||||
`${binaryForRuntime(manifest.runtime)} node_modules/prisma/build/index.js migrate deploy`,
|
||||
...commands,
|
||||
];
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Added migrate deploy command to commands array`);
|
||||
}
|
||||
} else {
|
||||
context.logger.debug(
|
||||
`[PrismaExtension] Migrations not enabled (migrate: ${this.options.migrate})`
|
||||
);
|
||||
}
|
||||
|
||||
env.DATABASE_URL = manifest.deploy.env?.DATABASE_URL;
|
||||
|
||||
// Handle directUrl environment variable configuration
|
||||
if (this.options.directUrlEnvVarName) {
|
||||
env[this.options.directUrlEnvVarName] =
|
||||
manifest.deploy.env?.[this.options.directUrlEnvVarName] ??
|
||||
process.env[this.options.directUrlEnvVarName];
|
||||
|
||||
if (!env[this.options.directUrlEnvVarName]) {
|
||||
context.logger.warn(
|
||||
`prismaExtension could not resolve the ${this.options.directUrlEnvVarName} environment variable. Make sure you add it to your environment variables or provide it as an environment variable to the deploy CLI command. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
env.DIRECT_URL = manifest.deploy.env?.DIRECT_URL;
|
||||
env.DIRECT_DATABASE_URL = manifest.deploy.env?.DIRECT_DATABASE_URL;
|
||||
}
|
||||
|
||||
if (!env.DATABASE_URL) {
|
||||
context.logger.warn(
|
||||
`prismaExtension could not resolve the DATABASE_URL environment variable. Make sure you add it to your environment variables. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables`
|
||||
);
|
||||
}
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Final layer configuration`, {
|
||||
commands,
|
||||
commandsCount: commands.length,
|
||||
env: Object.keys(env),
|
||||
dependencies: {
|
||||
prisma: version,
|
||||
},
|
||||
});
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Commands to be executed:`, {
|
||||
commands: commands.map((cmd, idx) => `${idx + 1}. ${cmd}`),
|
||||
});
|
||||
|
||||
context.addLayer({
|
||||
id: "prisma",
|
||||
commands,
|
||||
dependencies: {
|
||||
prisma: version,
|
||||
},
|
||||
build: {
|
||||
env,
|
||||
},
|
||||
});
|
||||
|
||||
context.logger.debug(`[PrismaExtension] Layer added successfully`);
|
||||
}
|
||||
}
|
||||
|
||||
export class PrismaEngineOnlyModeExtension implements BuildExtension {
|
||||
public readonly name = "PrismaEngineOnlyModeExtension";
|
||||
private _binaryTarget: string;
|
||||
|
||||
constructor(private options: PrismaEngineOnlyModeExtensionOptions) {
|
||||
this._binaryTarget = options.binaryTarget ?? "debian-openssl-3.0.x";
|
||||
}
|
||||
|
||||
async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to detect the version if not provided
|
||||
let version = this.options.version;
|
||||
|
||||
if (!version) {
|
||||
context.logger.debug("Attempting to detect @prisma/client version from the project");
|
||||
|
||||
version = await resolvePrismaClientVersion(context.workingDir, context.logger);
|
||||
|
||||
if (version) {
|
||||
// Log a nice message to the user which version was detected, and give them instructions on how to override it
|
||||
context.logger.progress(
|
||||
`prismaExtension: detected prisma ${version}. Override via prismaExtension({ mode: "engine-only", version: "6.19.0" })`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
throw new Error(
|
||||
`PrismaEngineOnlyModeExtension could not determine the version of @prisma/client. Please provide a version in the PrismaExtension options: prismaExtension({ mode: "engine-only", version: "6.19.0" })`
|
||||
);
|
||||
}
|
||||
|
||||
context.logger.debug(
|
||||
`PrismaEngineOnlyModeExtension is installing engines for version ${version}`
|
||||
);
|
||||
|
||||
const commands: string[] = [
|
||||
// Install the engines package
|
||||
`npm install @prisma/engines@${version}`,
|
||||
...generateCpCommandsForLocation("/app/prisma-engines", this._binaryTarget),
|
||||
];
|
||||
|
||||
context.addLayer({
|
||||
id: "prisma-engines",
|
||||
commands,
|
||||
deploy: {
|
||||
env: {
|
||||
PRISMA_QUERY_ENGINE_LIBRARY: `/app/prisma-engines/libquery_engine-${this._binaryTarget}.so.node`,
|
||||
PRISMA_QUERY_ENGINE_SCHEMA_ENGINE: `/app/prisma-engines/schema-engine-${this._binaryTarget}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!this.options.silent) {
|
||||
context.logger.progress(
|
||||
"prismaExtension: setting PRISMA_QUERY_ENGINE_LIBRARY and PRISMA_QUERY_ENGINE_SCHEMA_ENGINE env variables"
|
||||
);
|
||||
// Now logs output a pretty message to the user that they need to make sure they have already run `prisma generate` and they also have added
|
||||
// the binary target to the prisma schema file like so: binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
context.logger.progress(
|
||||
`prismaExtension: in engine-only mode you are required to run \`prisma generate\` and ensure your schema.prisma file has binaryTargets = ["native", "${this._binaryTarget}"]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateCpCommandsForLocation(location: string, binaryTarget: string) {
|
||||
return [
|
||||
`mkdir -p ${location} && cp node_modules/@prisma/engines/libquery_engine-${binaryTarget}.so.node ${location}/`,
|
||||
`mkdir -p ${location} && cp node_modules/@prisma/engines/schema-engine-${binaryTarget} ${location}/`,
|
||||
];
|
||||
}
|
||||
|
||||
export class PrismaEngineModernModeExtension implements BuildExtension {
|
||||
moduleExternals: string[];
|
||||
|
||||
public readonly name = "PrismaEngineModernModeExtension";
|
||||
|
||||
constructor(private options: PrismaEngineModernModeExtensionOptions) {
|
||||
this.moduleExternals = ["@prisma/client"];
|
||||
}
|
||||
|
||||
externalsForTarget(target: BuildTarget) {
|
||||
return this.moduleExternals;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3";
|
||||
import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export function puppeteer() {
|
||||
return new PuppeteerExtension();
|
||||
}
|
||||
|
||||
class PuppeteerExtension implements BuildExtension {
|
||||
public readonly name = "PuppeteerExtension";
|
||||
|
||||
async onBuildComplete(context: BuildContext, manifest: BuildManifest) {
|
||||
if (context.target === "dev") {
|
||||
return;
|
||||
}
|
||||
|
||||
context.logger.debug(`Adding ${this.name} to the build`);
|
||||
|
||||
const instructions = [
|
||||
`RUN apt-get update && apt-get install curl gnupg -y \
|
||||
&& curl --location --silent https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
|
||||
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
|
||||
&& apt-get update \
|
||||
&& apt-get install google-chrome-stable -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*`,
|
||||
];
|
||||
|
||||
context.addLayer({
|
||||
id: "puppeteer",
|
||||
image: {
|
||||
instructions,
|
||||
},
|
||||
deploy: {
|
||||
env: {
|
||||
PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable",
|
||||
},
|
||||
override: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BuildExtension } from "@trigger.dev/core/v3/build";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import typescriptPkg from "typescript";
|
||||
|
||||
const { transpileModule, ModuleKind } = typescriptPkg;
|
||||
|
||||
const decoratorMatcher = new RegExp(/((?<![(\s]\s*['"])@\w[.[\]\w\d]*\s*(?![;])[((?=\s)])/);
|
||||
|
||||
export function emitDecoratorMetadata(): BuildExtension {
|
||||
return {
|
||||
name: "emitDecoratorMetadata",
|
||||
onBuildStart(context) {
|
||||
context.registerPlugin({
|
||||
name: "emitDecoratorMetadata",
|
||||
async setup(build) {
|
||||
const { parseNative, TSConfckCache } = await import("tsconfck");
|
||||
const cache = new TSConfckCache<any>();
|
||||
|
||||
build.onLoad({ filter: /\.ts$/ }, async (args) => {
|
||||
context.logger.debug("emitDecoratorMetadata onLoad", { args });
|
||||
|
||||
const { tsconfigFile, tsconfig } = await parseNative(args.path, {
|
||||
ignoreNodeModules: true,
|
||||
cache,
|
||||
});
|
||||
|
||||
context.logger.debug("emitDecoratorMetadata parsed native tsconfig", {
|
||||
tsconfig,
|
||||
tsconfigFile,
|
||||
args,
|
||||
});
|
||||
|
||||
if (tsconfig.compilerOptions?.emitDecoratorMetadata !== true) {
|
||||
context.logger.debug("emitDecoratorMetadata skipping", {
|
||||
args,
|
||||
tsconfig,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ts = await readFile(args.path, "utf8");
|
||||
|
||||
if (!ts) return undefined;
|
||||
|
||||
// Find the decorator and if there isn't one, return out
|
||||
if (!decoratorMatcher.test(ts)) {
|
||||
context.logger.debug("emitDecoratorMetadata skipping, no decorators found", {
|
||||
args,
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const program = transpileModule(ts, {
|
||||
fileName: args.path,
|
||||
compilerOptions: {
|
||||
...tsconfig.compilerOptions,
|
||||
module: ModuleKind.ES2022,
|
||||
},
|
||||
});
|
||||
|
||||
return { contents: program.outputText };
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// @ts-ignore
|
||||
const { resolvePathSync } = require("mlly");
|
||||
|
||||
// @ts-ignore
|
||||
module.exports.resolvePathSync = resolvePathSync;
|
||||
@@ -0,0 +1,5 @@
|
||||
// @ts-ignore
|
||||
import { resolvePathSync } from "mlly";
|
||||
|
||||
// @ts-ignore
|
||||
export { resolvePathSync };
|
||||
@@ -0,0 +1,2 @@
|
||||
// Barrel no more
|
||||
export * from "./extensions/index.js";
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./internal/additionalFiles.js";
|
||||
export * from "./internal/copyFiles.js";
|
||||
@@ -0,0 +1,32 @@
|
||||
import { BuildManifest } from "@trigger.dev/core/v3";
|
||||
import { BuildContext } from "@trigger.dev/core/v3/build";
|
||||
import { copyMatcherResults, findFilesByMatchers, type MatcherResult } from "./copyFiles.js";
|
||||
|
||||
export type AdditionalFilesOptions = {
|
||||
files: string[];
|
||||
};
|
||||
|
||||
export async function addAdditionalFilesToBuild(
|
||||
source: string,
|
||||
options: AdditionalFilesOptions,
|
||||
context: BuildContext,
|
||||
manifest: BuildManifest
|
||||
) {
|
||||
const matcherResults: MatcherResult[] = await findFilesByMatchers(
|
||||
options.files ?? [],
|
||||
manifest.outputPath,
|
||||
{ cwd: context.workingDir }
|
||||
);
|
||||
|
||||
for (const { assets, matcher } of matcherResults) {
|
||||
if (assets.length === 0) {
|
||||
context.logger.warn(`[${source}] No files found for matcher`, matcher);
|
||||
} else {
|
||||
context.logger.debug(`[${source}] Found ${assets.length} files for matcher`, matcher);
|
||||
}
|
||||
}
|
||||
|
||||
await copyMatcherResults(matcherResults, (pair) => {
|
||||
context.logger.debug(`[${source}] Copying ${pair.source} to ${pair.destination}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { cp, copyFile, mkdir } from "node:fs/promises";
|
||||
import { dirname, join, posix, relative } from "node:path";
|
||||
import { glob } from "tinyglobby";
|
||||
|
||||
/**
|
||||
* A single matched asset — source file and its destination inside the
|
||||
* build output directory.
|
||||
*/
|
||||
export type CopyPair = { source: string; destination: string };
|
||||
|
||||
/**
|
||||
* Result of a single matcher's glob, grouped with the matcher that
|
||||
* produced it so callers can warn on empty matches.
|
||||
*/
|
||||
export type MatcherResult = {
|
||||
matcher: string;
|
||||
assets: CopyPair[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Glob a set of matchers relative to `cwd` and return pairs describing
|
||||
* where each matched file should be copied to under `destinationDir`.
|
||||
*
|
||||
* Relative paths are preserved under `destinationDir`. Leading `..`
|
||||
* segments (from `../shared/file.txt` style patterns) are stripped so
|
||||
* files always land inside the destination.
|
||||
*/
|
||||
export async function findFilesByMatchers(
|
||||
matchers: string[],
|
||||
destinationDir: string,
|
||||
options?: { cwd?: string; ignore?: string[] }
|
||||
): Promise<MatcherResult[]> {
|
||||
const result: MatcherResult[] = [];
|
||||
const cwd = options?.cwd ?? process.cwd();
|
||||
|
||||
for (const matcher of matchers) {
|
||||
const files = await glob({
|
||||
patterns: [matcher],
|
||||
cwd,
|
||||
ignore: options?.ignore ?? [],
|
||||
onlyFiles: true,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
const assets: CopyPair[] = files.map((file) => {
|
||||
const pathInsideDestinationDir = relative(cwd, file)
|
||||
.split(posix.sep)
|
||||
.filter((p) => p !== "..")
|
||||
.join(posix.sep);
|
||||
return {
|
||||
source: file,
|
||||
destination: join(destinationDir, pathInsideDestinationDir),
|
||||
};
|
||||
});
|
||||
|
||||
result.push({ matcher, assets });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a single file, creating parent directories as needed.
|
||||
*/
|
||||
export async function copyFileEnsuringDir(source: string, destination: string): Promise<void> {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await copyFile(source, destination);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy every pair in the given matcher results. Parent directories are
|
||||
* created automatically. Returns the total number of files copied.
|
||||
*/
|
||||
export async function copyMatcherResults(
|
||||
matcherResults: MatcherResult[],
|
||||
onCopy?: (pair: CopyPair) => void
|
||||
): Promise<number> {
|
||||
let count = 0;
|
||||
for (const { assets } of matcherResults) {
|
||||
for (const pair of assets) {
|
||||
onCopy?.(pair);
|
||||
await copyFileEnsuringDir(pair.source, pair.destination);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively copy a directory to another location. Preserves structure;
|
||||
* overwrites existing files at the destination.
|
||||
*
|
||||
* Used by the built-in skill bundler — we copy entire skill folders as a
|
||||
* unit, not file-by-file.
|
||||
*/
|
||||
export async function copyDirectoryRecursive(source: string, destination: string): Promise<void> {
|
||||
await mkdir(destination, { recursive: true });
|
||||
await cp(source, destination, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const VERSION = "0.0.0";
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../.configs/tsconfig.base.json",
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.src.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["./src/**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"isolatedDeclarations": false,
|
||||
"composite": true,
|
||||
"sourceMap": true,
|
||||
"customConditions": ["@triggerdotdev/source"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
||||
# CLI Package
|
||||
|
||||
The `trigger.dev` CLI package, published as `trigger.dev` on npm. Executable: `trigger`.
|
||||
|
||||
## Dev vs Deploy
|
||||
|
||||
### Dev Mode (`src/dev/`)
|
||||
Runs tasks locally in the user's Node.js process. No containers involved. Uses `src/dev/` for the dev command, connects to the local webapp for coordination.
|
||||
|
||||
### Deploy Mode (`src/deploy/`)
|
||||
Bundles task code and builds Docker images for production:
|
||||
1. **Bundle**: `src/build/` bundles worker code using the build system
|
||||
2. **Archive**: `src/deploy/archiveContext.ts` packages files for deployment
|
||||
3. **Build image**: `src/deploy/buildImage.ts` creates Docker images (local Docker/Depot or remote builds)
|
||||
4. **Push**: Pushes image to registry, registers with webapp API
|
||||
|
||||
## Customer Task Images
|
||||
|
||||
Code in `src/entryPoints/` runs **inside customer containers** - this is a different runtime environment from the CLI itself. Changes here affect deployed task execution directly.
|
||||
|
||||
The build system (`src/build/`) uses the config from `trigger.config.ts` in user projects to determine what to bundle, which build extensions to apply, and how to structure the output.
|
||||
|
||||
## Commands
|
||||
|
||||
CLI command definitions live in `src/commands/`. Key commands:
|
||||
- `dev.ts` - Local development mode
|
||||
- `deploy.ts` - Production deployment
|
||||
- `init.ts` - Project initialization
|
||||
- `login.ts` - Authentication
|
||||
- `promote.ts` - Deployment promotion
|
||||
|
||||
## MCP Server
|
||||
|
||||
`src/mcp/` provides an MCP server for AI-assisted task development.
|
||||
|
||||
## SDK Documentation Rules
|
||||
|
||||
The `rules/` directory at the repo root contains versioned SDK documentation that gets installed alongside customer projects. Update both `rules/` and `.claude/skills/trigger-dev-tasks/` when SDK features change.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Running the CLI from source
|
||||
|
||||
1. Run the CLI and watch for changes
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
2. Test the local CLI using the job-catalogs located in the `/references` directory
|
||||
|
||||
```sh
|
||||
pnpm i
|
||||
pnpm exec trigger <command>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you want to use it in a new folder, you need to first add it as a dev dependency in package.json:
|
||||
|
||||
```json
|
||||
//...
|
||||
"devDependencies": {
|
||||
"trigger.dev": "workspace:*",
|
||||
//...
|
||||
}
|
||||
//...
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Trigger.dev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,51 @@
|
||||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/3f5ad4c1-c4c8-4277-b622-290e7f37bd00/public">
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
[](https://www.npmjs.com/package/trigger.dev)
|
||||
[](https://www.npmjs.com/package/trigger.dev)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/triggerdotdev/trigger.dev)
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs) | [Examples](https://trigger.dev/docs/examples)
|
||||
|
||||
</div>
|
||||
|
||||
# Trigger.dev CLI
|
||||
|
||||
A CLI that allows you to create, run locally and deploy Trigger.dev background tasks.
|
||||
|
||||
Note: this only works with Trigger.dev v3 projects and later. For older projects use the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package.
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Trigger.dev is an open source platform for building and deploying fully-managed AI agents and workflows. Write workflows in normal async TypeScript for everything from simple tasks to long-running AI agents, heavy media processing, complex real-time systems and more. Complete with full observability, managed queues, and elastic infrastructure which handles the horizontal scaling.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| :------------------------------------------------------------------- | :----------------------------------------------------------------- |
|
||||
| [login](https://trigger.dev/docs/cli-login-commands) | Login with Trigger.dev so you can perform authenticated actions. |
|
||||
| [init](https://trigger.dev/docs/cli-init-commands) | Initialize your existing project for development with Trigger.dev. |
|
||||
| [dev](https://trigger.dev/docs/cli-dev-commands) | Run your Trigger.dev tasks locally. |
|
||||
| [deploy](https://trigger.dev/docs/cli-deploy-commands) | Deploy your Trigger.dev v3 project to the cloud. |
|
||||
| [whoami](https://trigger.dev/docs/cli-whoami-commands) | Display the current logged in user and project details. |
|
||||
| [logout](https://trigger.dev/docs/cli-logout-commands) | Logout of Trigger.dev. |
|
||||
| [list-profiles](https://trigger.dev/docs/cli-list-profiles-commands) | List all of your CLI profiles. |
|
||||
| [preview archive](https://trigger.dev/docs/cli-preview-archive) | Archive a preview branch. |
|
||||
| [promote](https://trigger.dev/docs/cli-promote-commands) | Promote a previously deployed version to the current version. |
|
||||
| [switch](https://trigger.dev/docs/cli-switch) | Switch between CLI profiles. |
|
||||
| [update](https://trigger.dev/docs/cli-update-commands) | Updates all `@trigger.dev/*` packages to match the CLI version. |
|
||||
|
||||
## CLI documentation
|
||||
|
||||
For more information on the CLI, please refer to our [docs](https://trigger.dev/docs/cli-introduction).
|
||||
|
||||
## Support
|
||||
|
||||
If you have any questions, please reach out to us on [Discord](https://trigger.dev/discord) and we'll be happy to help.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Trigger.dev CLI E2E suite
|
||||
|
||||
E2E test suite for the Trigger.dev v3 CLI.
|
||||
|
||||
Note: this only works with Trigger.dev v3 projects and later. There is no E2E test suite for the [@trigger.dev/cli](https://www.npmjs.com/package/@trigger.dev/cli) package yet.
|
||||
|
||||
Trigger.dev is an open source platform that makes it easy to create event-driven background tasks directly in your existing project.
|
||||
|
||||
## Description
|
||||
|
||||
This suite aims to test the outputs fo the `triggerdev deploy` command.
|
||||
To do so, it runs the deploy code against fixture projects that are located under `packages/cli-v3/e2e/fixtures/`.
|
||||
Those fixtures reproduce minimal project structure and contents, in order to reproduce known bugs and run fast.
|
||||
|
||||
**Notes**
|
||||
|
||||
- The suite uses vitest
|
||||
- Everything happens locally
|
||||
- There is no login required
|
||||
- There is not real project reference needed
|
||||
- No docker image is created or built, instead, the bundled worker file is started with node directly inside the vitest process
|
||||
|
||||
## Usage
|
||||
|
||||
If you have not done it yet, build the CLI:
|
||||
|
||||
```sh
|
||||
pnpm run build --filter trigger.dev
|
||||
```
|
||||
|
||||
Then, run the v3 CLI E2E test suite:
|
||||
|
||||
```sh
|
||||
pnpm --filter trigger.dev run test:e2e
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------------------- |
|
||||
| `MOD=<fixture-name>` | The name of any folder directly nested under `packages/cli-v3/e2e/fixtures/` |
|
||||
| `PM=<package-manager>` | The package manager to use. One of `npm`, `pnpm`, `yarn`. Defaults to `npm` |
|
||||
|
||||
Example:
|
||||
|
||||
```sh
|
||||
MOD=server-only PM=yarn pnpm --filter trigger.dev run test:e2e
|
||||
```
|
||||
|
||||
This will run the test suite for the `server-only` fixture using `yarn` to install and resolve dependencies.
|
||||
|
||||
## Debugging
|
||||
|
||||
When debugging an issue with the `triggerdev deploy` or `triggerdev dev` command, it is recommended to reproduce it with a minimal project fixture in the e2e suite.
|
||||
Check [Adding a fixture](#adding-a-fixture) for more information.
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
MOD=<fixture-name> pnpm run test:e2e
|
||||
```
|
||||
|
||||
This will test your fixture project, and generate outputs in the `packages/cli-v3/e2e/fixtures/<fixture-name>/.trigger` folder, so you can easily debug.
|
||||
|
||||
## Adding a fixture
|
||||
|
||||
1. Create a new `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
It will hold the project to test.
|
||||
|
||||
2. Add a `package.json` file in your `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
Use the following template:
|
||||
|
||||
```json package.json
|
||||
{
|
||||
"name": "<fixture-name>",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"packageManager": "pnpm@8.15.5"
|
||||
}
|
||||
```
|
||||
|
||||
> The `engines` field is used to store the versions of pnpm and yarn to use when running the suite.
|
||||
|
||||
3. Add an empty `pnpm-workspace.yaml` in your `packages/cli-v3/e2e/fixtures/<fixture-name>` folder.
|
||||
|
||||
This is necessary to prevent the Trigger.dev monorepo from handling this project.
|
||||
Please check https://github.com/pnpm/pnpm/issues/2412 for more inforation.
|
||||
|
||||
4. Add an empty `yarn.lock` in your fixture folder.
|
||||
|
||||
This is necessary to allow to use `yarn` without having a warning on the current project being a `pnpm` project.
|
||||
|
||||
5. Add the following `.yarnrc.yaml` in your fixture folder.
|
||||
|
||||
This will avoid having `.pnp.cjs` and `.pnp.loader.mjs` and keep versioned files to a minimum.
|
||||
|
||||
```yaml .yarnrc.yml
|
||||
nodeLinker: node-modules
|
||||
```
|
||||
|
||||
6. Install the fixture dependencies and generate lockfiles.
|
||||
|
||||
Like you would in any project.
|
||||
E.g. if your fixture contains a trigger task that uses the `jsdom` library:
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
corepack use pnpm@8.15.5
|
||||
pnpm install jsdom
|
||||
```
|
||||
|
||||
> This will update the `package.json` and generate the `pnpm-lock.yaml` file.
|
||||
|
||||
7. Make sure typescript is installed in the fixture project.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
corepack use pnpm@8.15.5
|
||||
pnpm install typescript
|
||||
```
|
||||
|
||||
> This is necessary to typecheck the project during the test suite.
|
||||
|
||||
8. Add a tsconfig.json file similar to the one below:
|
||||
|
||||
```json tsconfig.json
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
9. To run the test suite against multiple package manager, we need to generate the other lockfiles.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
rm -rf **/node_modules
|
||||
npm install
|
||||
rm -rf **/node_modules
|
||||
corepack use yarn@4.2.2 # will update the yarn lockfile
|
||||
```
|
||||
|
||||
> Do it in this order, otherwise `npm install` will update the existing `yarn.lock` file with legacy version 1.
|
||||
|
||||
10. Create a new `packages/cli-v3/e2e/fixtures/trigger` folder, and create a trigger task in it.
|
||||
|
||||
Here is an example:
|
||||
|
||||
```javascript
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const helloWorldTask = task({
|
||||
id: "hello-world",
|
||||
run: async (payload) => {
|
||||
console.log("Hello, World!", payload);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
11. Add a trigger configuration file.
|
||||
|
||||
The configuration file is mandatory here, the E2E suite does not execute `trigger.dev` commands.
|
||||
|
||||
```javascript
|
||||
export const config = {
|
||||
project: "<fixture-name>",
|
||||
triggerDirectories: ["./trigger"],
|
||||
};
|
||||
```
|
||||
|
||||
> The project reference can be anything here, as the suite runs locally without connecting to the platform.
|
||||
|
||||
12. Commit your changes.
|
||||
|
||||
13. Add your fixture test configuration in `fixtures.config.js`.
|
||||
|
||||
```javascript fixtures.config.js
|
||||
export const fixturesConfig = [
|
||||
// ...
|
||||
{
|
||||
id: "<fixture-name>",
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
> You might expect a specific error for a specific test, so use those configuration option at your discretion.
|
||||
|
||||
## Updating the SDK in the fixtures
|
||||
|
||||
The `@trigger.dev/sdk` package is installed in the fixtures as a real dependency (not from the monorepo).
|
||||
|
||||
To update it, you'll need to update the version in the `package.json` file, and then run the following commands:
|
||||
|
||||
> NOTE: Some fixtures don't support all the package managers, like the monorepo-react-email only supports yarn and pnpm.
|
||||
|
||||
```sh
|
||||
cd packages/cli-v3/e2e/fixtures/<fixture-name>
|
||||
rm -rf **/node_modules
|
||||
corepack use pnpm@8.15.5
|
||||
rm -rf **/node_modules
|
||||
npm install
|
||||
rm -rf **/node_modules
|
||||
corepack use yarn@4.2.2
|
||||
rm -rf **/node_modules
|
||||
```
|
||||
@@ -0,0 +1,337 @@
|
||||
import { alwaysExternal } from "@trigger.dev/core/v3/build";
|
||||
import type { BuildManifest, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import * as fs from "node:fs";
|
||||
import { mkdir, rename, rm } from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { rimraf } from "rimraf";
|
||||
import { buildWorker, rewriteBuildManifestPaths } from "../src/build/buildWorker.js";
|
||||
import { loadConfig } from "../src/config.js";
|
||||
import { indexWorkerManifest } from "../src/indexing/indexWorkerManifest.js";
|
||||
import { writeJSONFile } from "../src/utilities/fileSystem.js";
|
||||
import { logger } from "../src/utilities/logger.js";
|
||||
import { normalizeImportPath } from "../src/utilities/normalizeImportPath.js";
|
||||
import { getTmpDir } from "../src/utilities/tempDirectories.js";
|
||||
import type { TestCase } from "./fixtures.js";
|
||||
import { fixturesConfig } from "./fixtures.js";
|
||||
import type { E2EOptions } from "./schemas.js";
|
||||
import { E2EOptionsSchema } from "./schemas.js";
|
||||
import type { PackageManager } from "./utils.js";
|
||||
import {
|
||||
executeTestCaseRun,
|
||||
installFixtureDeps,
|
||||
LOCKFILES,
|
||||
parsePackageManager,
|
||||
runTsc,
|
||||
} from "./utils.js";
|
||||
|
||||
const TIMEOUT = 120_000;
|
||||
|
||||
interface E2EFixtureTest extends TestCase {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
tempDir: string;
|
||||
workspaceDir: string;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = process.env.MOD
|
||||
? fixturesConfig.filter(({ id }) => process.env.MOD === id)
|
||||
: fixturesConfig;
|
||||
|
||||
let options: E2EOptions;
|
||||
|
||||
try {
|
||||
options = E2EOptionsSchema.parse({
|
||||
logLevel: process.env.LOG,
|
||||
packageManager: process.env.PM,
|
||||
});
|
||||
} catch (_e) {
|
||||
options = {
|
||||
logLevel: "log",
|
||||
};
|
||||
}
|
||||
|
||||
logger.loggerLevel = options.logLevel;
|
||||
|
||||
if (testCases.length === 0) {
|
||||
if (process.env.MOD) {
|
||||
throw new Error(`No test case found for ${process.env.MOD}`);
|
||||
} else {
|
||||
throw new Error("Nothing to test");
|
||||
}
|
||||
}
|
||||
|
||||
describe("buildWorker", async () => {
|
||||
beforeEach<E2EFixtureTest>(async ({ fixtureDir, skip, packageManager, workspaceDir }) => {
|
||||
await rimraf(path.join(workspaceDir, "**/node_modules"), {
|
||||
glob: true,
|
||||
});
|
||||
|
||||
await rimraf(path.join(workspaceDir, ".yarn"), { glob: true });
|
||||
|
||||
if (
|
||||
packageManager === "npm" &&
|
||||
(fs.existsSync(path.resolve(path.join(workspaceDir, "yarn.lock"))) ||
|
||||
fs.existsSync(path.resolve(path.join(workspaceDir, "yarn.lock.copy"))))
|
||||
) {
|
||||
// `npm ci` & `npm install` will update an existing yarn.lock
|
||||
try {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy"))
|
||||
);
|
||||
} catch (_e) {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
options.packageManager &&
|
||||
!fs.existsSync(path.resolve(fixtureDir, LOCKFILES[options.packageManager]))
|
||||
) {
|
||||
skip();
|
||||
}
|
||||
|
||||
await installFixtureDeps({ fixtureDir, packageManager, workspaceDir });
|
||||
}, TIMEOUT);
|
||||
|
||||
afterEach<E2EFixtureTest>(async ({ packageManager, workspaceDir }) => {
|
||||
if (packageManager === "npm") {
|
||||
try {
|
||||
await rename(
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock.copy")),
|
||||
path.resolve(path.join(workspaceDir, "yarn.lock"))
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
for (let testCase of testCases) {
|
||||
test.extend<E2EFixtureTest>({
|
||||
// Seed `workspaceRelativeDir` before the spread so the key always exists.
|
||||
// vitest 4 resolves fixture-to-fixture dependencies strictly at
|
||||
// `test.extend()` time: the `workspaceDir` fixture below destructures
|
||||
// `workspaceRelativeDir`, so it must be a defined fixture even for test
|
||||
// cases that don't set it (it's an optional `TestCase` field). The spread
|
||||
// overrides this default when the case provides its own value.
|
||||
workspaceRelativeDir: "",
|
||||
...testCase,
|
||||
fixtureDir: async ({ id }, use) =>
|
||||
await use(path.resolve(path.join(process.cwd(), "e2e/fixtures", id))),
|
||||
workspaceDir: async ({ fixtureDir, workspaceRelativeDir }, use) =>
|
||||
await use(path.resolve(path.join(fixtureDir, workspaceRelativeDir))),
|
||||
packageManager: async ({ workspaceDir }, use) =>
|
||||
await use(await parsePackageManager(options.packageManager, workspaceDir)),
|
||||
tempDir: async ({ workspaceDir }, use) => {
|
||||
const existingTempDir = path.resolve(path.join(workspaceDir, ".trigger"));
|
||||
|
||||
if (fs.existsSync(existingTempDir)) {
|
||||
await rm(existingTempDir, { force: true, recursive: true });
|
||||
}
|
||||
await use(
|
||||
(await mkdir(path.join(workspaceDir, ".trigger"), { recursive: true })) as string
|
||||
);
|
||||
},
|
||||
})(
|
||||
`fixture ${testCase.id}`,
|
||||
{ timeout: TIMEOUT },
|
||||
async ({
|
||||
id,
|
||||
tempDir,
|
||||
tsconfig,
|
||||
packageManager,
|
||||
fixtureDir,
|
||||
workspaceDir,
|
||||
wantConfigInvalidError,
|
||||
wantConfigNotFoundError,
|
||||
wantBuildWorkerError,
|
||||
wantIndexingError,
|
||||
buildManifestMatcher,
|
||||
workerManifestMatcher,
|
||||
runs,
|
||||
}) => {
|
||||
let resolvedConfig: Awaited<ReturnType<typeof loadConfig>>;
|
||||
|
||||
const configExpect = expect(
|
||||
(async () => {
|
||||
resolvedConfig = await loadConfig({
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
})(),
|
||||
wantConfigNotFoundError || wantConfigInvalidError
|
||||
? "does not resolve config"
|
||||
: "resolves config"
|
||||
);
|
||||
|
||||
if (wantConfigNotFoundError) {
|
||||
await configExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await configExpect.resolves.not.toThrowError();
|
||||
|
||||
if (wantConfigInvalidError) {
|
||||
expect(resolvedConfig!).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
|
||||
expect(resolvedConfig!).toBeTruthy();
|
||||
|
||||
if (tsconfig) {
|
||||
const tscResult = await runTsc(
|
||||
workspaceDir,
|
||||
tsconfig,
|
||||
packageManager === "yarn" ? fixtureDir : undefined
|
||||
);
|
||||
|
||||
expect(tscResult.success).toBe(true);
|
||||
}
|
||||
|
||||
const destination = getTmpDir(workspaceDir, "build");
|
||||
|
||||
let buildManifest: BuildManifest;
|
||||
|
||||
const buildExpect = expect(
|
||||
(async () => {
|
||||
buildManifest = await buildWorker({
|
||||
target: "deploy",
|
||||
environment: "test",
|
||||
destination: destination.path,
|
||||
resolvedConfig: resolvedConfig!,
|
||||
rewritePaths: false,
|
||||
forcedExternals: alwaysExternal,
|
||||
});
|
||||
})(),
|
||||
wantBuildWorkerError ? "does not build" : "builds"
|
||||
);
|
||||
|
||||
if (wantBuildWorkerError) {
|
||||
await buildExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await buildExpect.resolves.not.toThrowError();
|
||||
|
||||
if (buildManifestMatcher) {
|
||||
for (const external of buildManifestMatcher.externals ?? []) {
|
||||
expect(buildManifest!.externals).toContainEqual(external);
|
||||
}
|
||||
|
||||
for (const file of buildManifestMatcher.files ?? []) {
|
||||
const found = (buildManifestMatcher.files ?? []).find((f) => f?.entry === file?.entry);
|
||||
expect(found).toBeTruthy();
|
||||
}
|
||||
} else {
|
||||
expect(buildManifest!).toBeTruthy();
|
||||
}
|
||||
|
||||
logger.debug("Build manifest", buildManifest!);
|
||||
|
||||
const rewrittenManifest = rewriteBuildManifestPaths(buildManifest!, destination.path);
|
||||
|
||||
if (resolvedConfig!.instrumentedPackageNames?.length ?? 0 > 0) {
|
||||
expect(rewrittenManifest.loaderEntryPoint).toBe("/app/src/entryPoints/loader.mjs");
|
||||
} else {
|
||||
expect(rewrittenManifest.loaderEntryPoint).toBeUndefined();
|
||||
}
|
||||
|
||||
expect(rewrittenManifest.indexWorkerEntryPoint).toBe(
|
||||
"/app/src/entryPoints/managed-index-worker.mjs"
|
||||
);
|
||||
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
|
||||
let workerManifest: WorkerManifest;
|
||||
|
||||
const indexExpect = expect(
|
||||
(async () => {
|
||||
workerManifest = await indexWorkerManifest({
|
||||
runtime: buildManifest!.runtime,
|
||||
indexWorkerPath: buildManifest!.indexWorkerEntryPoint,
|
||||
buildManifestPath: path.join(destination.path, "build.json"),
|
||||
nodeOptions: buildManifest!.loaderEntryPoint
|
||||
? `--import=${normalizeImportPath(buildManifest!.loaderEntryPoint)}`
|
||||
: undefined,
|
||||
env: testCase.envVars ?? {},
|
||||
otelHookExclude: buildManifest!.otelImportHook?.exclude,
|
||||
otelHookInclude: buildManifest!.otelImportHook?.include,
|
||||
handleStdout(data) {
|
||||
stdout.push(data);
|
||||
logger.debug("indexWorkerManifest handleStdout");
|
||||
logger.debug(data);
|
||||
},
|
||||
handleStderr(data) {
|
||||
if (!data.includes("DeprecationWarning")) {
|
||||
stderr.push(data);
|
||||
logger.debug("indexWorkerManifest handleStderr");
|
||||
logger.debug(data);
|
||||
}
|
||||
},
|
||||
});
|
||||
})(),
|
||||
wantIndexingError ? "does not index" : "indexes"
|
||||
);
|
||||
|
||||
if (wantIndexingError) {
|
||||
await indexExpect.rejects.toThrowError();
|
||||
return;
|
||||
}
|
||||
|
||||
await indexExpect.resolves.not.toThrowError();
|
||||
|
||||
if (workerManifestMatcher) {
|
||||
expect(workerManifest!).toMatchObject(workerManifestMatcher);
|
||||
} else {
|
||||
expect(workerManifest!).toBeTruthy();
|
||||
}
|
||||
|
||||
logger.debug("Worker manifest", workerManifest!);
|
||||
|
||||
if (runs && runs.length > 0) {
|
||||
await writeJSONFile(path.join(destination.path, "index.json"), workerManifest!);
|
||||
}
|
||||
|
||||
for (const taskRun of runs || []) {
|
||||
const { result, totalDurationMs, spans } = await executeTestCaseRun({
|
||||
run: taskRun,
|
||||
testCase,
|
||||
destination: destination.path,
|
||||
workerManifest: workerManifest!,
|
||||
contentHash: buildManifest!.contentHash,
|
||||
});
|
||||
|
||||
logger.debug("Task run result", result);
|
||||
|
||||
expect(result.ok).toBe(taskRun.result.ok);
|
||||
|
||||
if (result.ok) {
|
||||
if (taskRun.result.durationMs) {
|
||||
expect(totalDurationMs).toBeGreaterThanOrEqual(taskRun.result.durationMs);
|
||||
}
|
||||
|
||||
if (taskRun.result.output) {
|
||||
expect(result.output).toEqual(taskRun.result.output);
|
||||
}
|
||||
|
||||
if (taskRun.result.outputType) {
|
||||
expect(result.outputType).toEqual(taskRun.result.outputType);
|
||||
}
|
||||
|
||||
if (taskRun.result.spans) {
|
||||
for (const spanName of taskRun.result.spans) {
|
||||
const foundSpan = spans.find((span) => span.name === spanName);
|
||||
|
||||
expect(foundSpan).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { BuildManifest, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
|
||||
type DeepPartial<T> = T extends object
|
||||
? {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
export interface TestCaseRun {
|
||||
task: {
|
||||
id: string;
|
||||
filePath: string;
|
||||
exportName?: string;
|
||||
};
|
||||
payload: string;
|
||||
payloadType?: string;
|
||||
result: {
|
||||
ok: boolean;
|
||||
durationMs?: number;
|
||||
output?: string;
|
||||
outputType?: string;
|
||||
spans?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface TestCase {
|
||||
resolveEnv?: { [key: string]: string };
|
||||
id: string;
|
||||
workspaceRelativeDir?: string;
|
||||
wantConfigNotFoundError?: boolean;
|
||||
wantConfigInvalidError?: boolean;
|
||||
wantBuildWorkerError?: boolean;
|
||||
wantIndexingError?: boolean;
|
||||
wantWorkerError?: boolean;
|
||||
wantDependenciesError?: boolean;
|
||||
wantInstallationError?: boolean;
|
||||
buildManifestMatcher?: DeepPartial<BuildManifest>;
|
||||
workerManifestMatcher?: DeepPartial<WorkerManifest>;
|
||||
runs?: TestCaseRun[];
|
||||
tsconfig?: string;
|
||||
envVars?: { [key: string]: string };
|
||||
}
|
||||
|
||||
export const fixturesConfig: TestCase[] = [
|
||||
{
|
||||
id: "hello-world",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/helloWorld.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "helloWorld",
|
||||
filePath: "src/trigger/helloWorld.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "helloWorld", filePath: "src/trigger/helloWorld.ts", exportName: "helloWorld" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 500 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
{
|
||||
id: "otel-telemetry-loader",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "openai",
|
||||
version: "4.47.0",
|
||||
},
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/ai.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "ai",
|
||||
filePath: "src/trigger/ai.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "ai", filePath: "src/trigger/ai.ts" },
|
||||
payload: '{"prompt":"be funny"}',
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
envVars: {
|
||||
OPENAI_API_KEY: "my-api-key",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "emit-decorator-metadata",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/decorators.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "decoratorsTask",
|
||||
filePath: "src/trigger/decorators.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "decoratorsTask", filePath: "src/trigger/decorators.ts" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
{
|
||||
id: "monorepo-react-email",
|
||||
workspaceRelativeDir: "packages/trigger",
|
||||
tsconfig: "tsconfig.json",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/reactEmail.tsx" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "react-email",
|
||||
filePath: "src/reactEmail.tsx",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "react-email", filePath: "src/reactEmail.tsx" },
|
||||
payload: "{}",
|
||||
result: {
|
||||
ok: true,
|
||||
output:
|
||||
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><!--$--><html dir="ltr" lang="en"><a href="https://example.com" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background:#000;color:#fff;padding:12px 20px 12px 20px" target="_blank"><span><!--[if mso]><i style="mso-font-width:500%;mso-text-raise:18" hidden>  </i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Click me</span><span><!--[if mso]><i style="mso-font-width:500%" hidden>  ​</i><![endif]--></span></a></html><!--/$-->',
|
||||
outputType: "text/plain",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "esm-only-external",
|
||||
buildManifestMatcher: {
|
||||
runtime: "node",
|
||||
externals: [
|
||||
{
|
||||
name: "import-in-the-middle",
|
||||
version: "3.0.1",
|
||||
},
|
||||
{
|
||||
name: "mupdf",
|
||||
version: "0.3.0",
|
||||
},
|
||||
],
|
||||
files: [{ entry: "src/trigger/helloWorld.ts" }],
|
||||
},
|
||||
workerManifestMatcher: {
|
||||
tasks: [
|
||||
{
|
||||
id: "helloWorld",
|
||||
filePath: "src/trigger/helloWorld.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
runs: [
|
||||
{
|
||||
task: { id: "helloWorld", filePath: "src/trigger/helloWorld.ts" },
|
||||
payload: "{}",
|
||||
result: { ok: true, durationMs: 1 },
|
||||
},
|
||||
],
|
||||
tsconfig: "tsconfig.json",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "emit-decorator-metadata",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha512.c44e283c54e02de9d1da8687025b030078c1b9648d2895a65aab8e64225bfb7becba87e1809fc0b4b6778bbd47a1e2ab6ac647de4c5e383a53a7c17db6c3ff4b",
|
||||
"engines": {
|
||||
"pnpm": "10.33.2",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"reflect-metadata": "0.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trigger.dev/build": "0.0.0-prerelease-20250321122618",
|
||||
"@types/node": "^22.20.0",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,56 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import "reflect-metadata";
|
||||
|
||||
class Point {
|
||||
constructor(
|
||||
public x: number,
|
||||
public y: number
|
||||
) {}
|
||||
}
|
||||
|
||||
class Line {
|
||||
private _start: Point;
|
||||
private _end: Point;
|
||||
|
||||
@validate
|
||||
set start(value: Point) {
|
||||
this._start = value;
|
||||
}
|
||||
|
||||
get start() {
|
||||
return this._start;
|
||||
}
|
||||
|
||||
@validate
|
||||
set end(value: Point) {
|
||||
this._end = value;
|
||||
}
|
||||
|
||||
get end() {
|
||||
return this._end;
|
||||
}
|
||||
}
|
||||
|
||||
function validate<T>(target: any, propertyKey: string, descriptor: TypedPropertyDescriptor<T>) {
|
||||
let set = descriptor.set!;
|
||||
|
||||
descriptor.set = function (value: T) {
|
||||
let type = Reflect.getMetadata("design:type", target, propertyKey);
|
||||
|
||||
if (!(value instanceof type)) {
|
||||
throw new TypeError(`Invalid type, got ${typeof value} not ${type.name}.`);
|
||||
}
|
||||
|
||||
set.call(this, value);
|
||||
};
|
||||
}
|
||||
|
||||
export const decoratorsTask = task({
|
||||
id: "decoratorsTask",
|
||||
run: async () => {
|
||||
const line = new Line();
|
||||
line.start = new Point(0, 0);
|
||||
|
||||
console.log("Hello, World!", { line });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
build: {
|
||||
extensions: [emitDecoratorMetadata()],
|
||||
},
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"extends": "./tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "esm-only-external",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"mupdf": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,11 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import * as mupdf from "mupdf";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "helloWorld",
|
||||
run: async () => {
|
||||
console.log("Hello, World!", {
|
||||
metaformat: mupdf.PDFDocument.META_FORMAT,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
build: {
|
||||
external: ["mupdf"],
|
||||
},
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
+2035
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "hello-world",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
+1180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,10 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "helloWorld",
|
||||
run: async () => {
|
||||
await setTimeout(1000);
|
||||
console.log("Hello, World!");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "monorepo-react-email",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@8.15.5+sha256.4b4efa12490e5055d59b9b9fc9438b7d581a6b7af3b5675eb5c5f447cee1a589",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@repo/email",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.24",
|
||||
"@react-email/render": "1.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-email": "^3.0.1"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Button, Html } from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
function ExampleEmail(props: {}) {
|
||||
return (
|
||||
<Html>
|
||||
<Button
|
||||
href="https://example.com"
|
||||
style={{ background: "#000", color: "#fff", padding: "12px 20px" }}
|
||||
>
|
||||
Click me
|
||||
</Button>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderExampleEmail() {
|
||||
return render(<ExampleEmail />);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./emails";
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@repo/trigger",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@repo/email": "workspace:*",
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
import { renderExampleEmail } from "@repo/email";
|
||||
|
||||
export const reactEmail = task({
|
||||
id: "react-email",
|
||||
run: async () => {
|
||||
return await renderExampleEmail();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src"],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "otel-telemetry-loader",
|
||||
"private": true,
|
||||
"packageManager": "yarn@4.2.2+sha256.1aa43a5304405be7a7cb9cb5de7b97de9c4e8ddd3273e4dad00d6ae3eb39f0ef",
|
||||
"engines": {
|
||||
"pnpm": "8.15.5",
|
||||
"yarn": "4.2.2"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"dependencies": {
|
||||
"@trigger.dev/sdk": "0.0.0-prerelease-20250321122618",
|
||||
"openai": "4.47.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@traceloop/instrumentation-openai": "^0.10.0",
|
||||
"typescript": "5.5.4"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# https://github.com/pnpm/pnpm/issues/2412
|
||||
packages:
|
||||
- "packages/*"
|
||||
@@ -0,0 +1,20 @@
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
import OpenAI from "openai";
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
export const aiTask = task({
|
||||
id: "ai",
|
||||
run: async (payload: { prompt: string }) => {
|
||||
const chatCompletion = await openai.chat.completions.create({
|
||||
messages: [{ role: "user", content: payload.prompt }],
|
||||
model: "gpt-3.5-turbo",
|
||||
});
|
||||
|
||||
return chatCompletion.choices[0];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk/v3";
|
||||
import { OpenAIInstrumentation } from "@traceloop/instrumentation-openai";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<fixture project>",
|
||||
dirs: ["./src/trigger"],
|
||||
instrumentations: [new OpenAIInstrumentation()],
|
||||
maxDuration: 3600,
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"include": ["src/**/*.ts", "trigger.config.ts"],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"verbatimModuleSyntax": false,
|
||||
"jsx": "react",
|
||||
"strict": true,
|
||||
"alwaysStrict": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitAny": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true,
|
||||
"removeComments": false,
|
||||
"esModuleInterop": true,
|
||||
"emitDecoratorMetadata": false,
|
||||
"experimentalDecorators": false,
|
||||
"downlevelIteration": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"pretty": true
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const LogLevelSchema = z.enum(["debug", "info", "log", "warn", "error", "none"]).default("log");
|
||||
const PackageManagerSchema = z.enum(["npm", "pnpm", "yarn"]);
|
||||
export const E2EOptionsSchema = z.object({
|
||||
logLevel: LogLevelSchema,
|
||||
packageManager: PackageManagerSchema.optional(),
|
||||
});
|
||||
export type E2EOptions = z.infer<typeof E2EOptionsSchema>;
|
||||
@@ -0,0 +1,485 @@
|
||||
import { execa } from "execa";
|
||||
import * as nodePath from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { logger } from "../src/utilities/logger.js";
|
||||
import { findUpMultiple, findUp } from "find-up";
|
||||
import type { TaskRunExecutionResult, WorkerManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { TaskRunProcess } from "../src/executions/taskRunProcess.js";
|
||||
import { createTestHttpServer } from "@epic-web/test-server/http";
|
||||
import type { TestCase, TestCaseRun } from "./fixtures.js";
|
||||
import { access } from "node:fs/promises";
|
||||
import type { MachinePreset } from "@trigger.dev/core/v3";
|
||||
|
||||
export type PackageManager = "npm" | "pnpm" | "yarn";
|
||||
|
||||
export const LOCKFILES = {
|
||||
npm: "package-lock.json",
|
||||
npmShrinkwrap: "npm-shrinkwrap.json",
|
||||
pnpm: "pnpm-lock.yaml",
|
||||
yarn: "yarn.lock",
|
||||
bun: "bun.lockb",
|
||||
};
|
||||
|
||||
export async function installFixtureDeps(options: {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
workspaceDir: string;
|
||||
}) {
|
||||
const { packageManager, workspaceDir } = options;
|
||||
|
||||
if (["pnpm", "yarn"].includes(packageManager)) {
|
||||
const version = await detectPackageManagerVersion(options);
|
||||
|
||||
debug(`Detected ${packageManager}@${version} from package.json 'engines' field`);
|
||||
|
||||
const { stdout, stderr } = await execa("corepack", ["use", `${packageManager}@${version}`], {
|
||||
cwd: workspaceDir,
|
||||
});
|
||||
|
||||
debug(stdout);
|
||||
|
||||
if (stderr) console.error(stderr);
|
||||
} else {
|
||||
const { stdout, stderr } = await execa(packageManager, installArgs(packageManager), {
|
||||
cwd: workspaceDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_PATH: nodePath.resolve(nodePath.join(workspaceDir, "node_modules")),
|
||||
},
|
||||
});
|
||||
|
||||
debug(stdout);
|
||||
|
||||
if (stderr) console.error(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
export async function parsePackageManager(
|
||||
packageManager: PackageManager | undefined,
|
||||
fixtureDir: string
|
||||
): Promise<PackageManager> {
|
||||
let $packageManager: PackageManager;
|
||||
|
||||
if (packageManager) {
|
||||
$packageManager = packageManager;
|
||||
} else {
|
||||
$packageManager = await detectPackageManagerFromArtifacts(fixtureDir);
|
||||
}
|
||||
|
||||
return $packageManager;
|
||||
}
|
||||
|
||||
export async function detectPackageManagerFromArtifacts(path: string): Promise<PackageManager> {
|
||||
const foundPath = await findUp(Object.values(LOCKFILES), { cwd: path });
|
||||
|
||||
if (!foundPath) {
|
||||
throw new Error("Could not detect package manager from artifacts");
|
||||
}
|
||||
|
||||
logger.debug("Found path from package manager artifacts", { foundPath });
|
||||
|
||||
switch (nodePath.basename(foundPath)) {
|
||||
case LOCKFILES.yarn:
|
||||
logger.debug("Found yarn artifact", { foundPath });
|
||||
return "yarn";
|
||||
case LOCKFILES.pnpm:
|
||||
logger.debug("Found pnpm artifact", { foundPath });
|
||||
return "pnpm";
|
||||
case LOCKFILES.npm:
|
||||
case LOCKFILES.npmShrinkwrap:
|
||||
logger.debug("Found npm artifact", { foundPath });
|
||||
return "npm";
|
||||
case LOCKFILES.bun:
|
||||
logger.debug("Found bun artifact", { foundPath });
|
||||
return "npm";
|
||||
default:
|
||||
throw new Error(`Unhandled package manager detection path: ${foundPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function debug(message: string) {
|
||||
if (logger.loggerLevel === "debug") {
|
||||
console.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
function installArgs(packageManager: string) {
|
||||
switch (packageManager) {
|
||||
case "bun":
|
||||
return ["install", "--frozen-lockfile"];
|
||||
case "pnpm":
|
||||
case "yarn":
|
||||
throw new Error("pnpm and yarn must install using `corepack use`");
|
||||
case "npm":
|
||||
return ["ci", "--no-audit"];
|
||||
default:
|
||||
throw new Error(`Unknown package manager '${packageManager}'`);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectPackageManagerVersion(options: {
|
||||
fixtureDir: string;
|
||||
packageManager: PackageManager;
|
||||
workspaceDir: string;
|
||||
}): Promise<string> {
|
||||
const { fixtureDir, packageManager, workspaceDir } = options;
|
||||
const pkgPaths = await findUpMultiple("package.json", { cwd: workspaceDir, stopAt: fixtureDir });
|
||||
for (let pkgPath of pkgPaths) {
|
||||
const buffer = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkgJSON = JSON.parse(buffer.toString());
|
||||
if (!pkgJSON.engines) continue;
|
||||
const version = pkgJSON.engines[packageManager];
|
||||
if (version) return version;
|
||||
}
|
||||
|
||||
throw new Error(`No version found for package manager ${packageManager}`);
|
||||
}
|
||||
|
||||
export interface TypeScriptError {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TscResult {
|
||||
success: boolean;
|
||||
errors: TypeScriptError[];
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export async function runTsc(
|
||||
cwd: string,
|
||||
tsconfigName: string = "tsconfig.json",
|
||||
binBasePath: string = cwd
|
||||
): Promise<TscResult> {
|
||||
const tsconfigPath = nodePath.join(cwd, tsconfigName);
|
||||
const tscPath = nodePath.join(binBasePath, "node_modules", ".bin", "tsc");
|
||||
|
||||
// Ensure the tsconfig file exists
|
||||
try {
|
||||
await access(tsconfigPath);
|
||||
} catch (_error) {
|
||||
throw new Error(`TSConfig file not found: ${tsconfigPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug(`Running TypeScript compiler: ${tscPath} --project ${tsconfigPath} --noEmit`, {
|
||||
cwd,
|
||||
});
|
||||
|
||||
const result = await execa(tscPath, ["--project", tsconfigPath, "--noEmit"], {
|
||||
cwd,
|
||||
reject: false,
|
||||
});
|
||||
|
||||
const success = result.exitCode === 0;
|
||||
const errors = success ? [] : parseTypeScriptErrors(result.stderr);
|
||||
|
||||
logger.debug(result.stdout);
|
||||
logger.debug(result.stderr);
|
||||
|
||||
return {
|
||||
success,
|
||||
errors,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run TypeScript compiler: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseTypeScriptErrors(stderr: string): TypeScriptError[] {
|
||||
const errorRegex = /(.+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g;
|
||||
const errors: TypeScriptError[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = errorRegex.exec(stderr)) !== null) {
|
||||
errors.push({
|
||||
file: match[1]!,
|
||||
line: parseInt(match[2]!, 10),
|
||||
column: parseInt(match[3]!, 10),
|
||||
message: match[4]!,
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type ExecuteTaskCaseRunOptions = {
|
||||
testCase: TestCase;
|
||||
run: TestCaseRun;
|
||||
destination: string;
|
||||
workerManifest: WorkerManifest;
|
||||
contentHash: string;
|
||||
};
|
||||
|
||||
export type ExecuteTaskRunUsageReport = {
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type ExecuteTaskRunResult = {
|
||||
result: TaskRunExecutionResult;
|
||||
usageReports: Array<ExecuteTaskRunUsageReport>;
|
||||
totalDurationMs: number;
|
||||
spans: Array<ExecuteTaskTraceEvent>;
|
||||
};
|
||||
|
||||
export type ExecuteTaskTraceEvent = {
|
||||
name: string;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId?: string;
|
||||
durationMs: number;
|
||||
attributes?: { [key: string]: string | number | boolean | undefined };
|
||||
};
|
||||
|
||||
export async function executeTestCaseRun({
|
||||
run,
|
||||
testCase,
|
||||
destination,
|
||||
workerManifest,
|
||||
contentHash,
|
||||
}: ExecuteTaskCaseRunOptions): Promise<ExecuteTaskRunResult> {
|
||||
const usageReports: Array<ExecuteTaskRunUsageReport> = [];
|
||||
const spans: Array<ExecuteTaskTraceEvent> = [];
|
||||
|
||||
// Create a disposable "server" instance.
|
||||
const server = await createTestHttpServer({
|
||||
defineRoutes(router) {
|
||||
router.post("/usage", async ({ req }) => {
|
||||
const jsonBody = await req.json();
|
||||
|
||||
usageReports.push({
|
||||
durationMs: jsonBody.durationMs,
|
||||
});
|
||||
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/traces", async ({ req }) => {
|
||||
const jsonBody = await req.json();
|
||||
|
||||
spans.push(...parseTraceBodyIntoEvents(jsonBody));
|
||||
// TODO: Implement trace endpoint
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/logs", () => {
|
||||
// TODO: Implement logs endpoint
|
||||
return Response.json({});
|
||||
});
|
||||
router.post("/v1/chat/completions", async ({ req }) => {
|
||||
return Response.json({
|
||||
id: "chatcmpl-7XYZ123ABC456DEF789GHI",
|
||||
object: "chat.completion",
|
||||
created: 1631619199,
|
||||
model: "gpt-3.5-turbo-0613",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content:
|
||||
"The capital of France is Paris. Paris is not only the political capital but also the cultural and economic center of France. It's known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 29,
|
||||
completion_tokens: 48,
|
||||
total_tokens: 77,
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const machine = {
|
||||
name: "small-1x",
|
||||
cpu: 1,
|
||||
memory: 256,
|
||||
centsPerMs: 0.0000001,
|
||||
} satisfies MachinePreset;
|
||||
|
||||
try {
|
||||
const taskRunProcess = new TaskRunProcess({
|
||||
workerManifest: workerManifest!,
|
||||
cwd: destination,
|
||||
env: {
|
||||
USAGE_EVENT_URL: server.http.url("/usage").href,
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: server.http.url().origin,
|
||||
TRIGGER_JWT: "test-jwt",
|
||||
TRIGGER_SECRET_KEY: "test-secret",
|
||||
TRIGGER_API_URL: server.http.url().origin,
|
||||
USAGE_HEARTBEAT_INTERVAL_MS: "500",
|
||||
OPENAI_API_KEY: "api-key",
|
||||
OPENAI_BASE_URL: server.http.url().origin + "/v1",
|
||||
},
|
||||
serverWorker: {
|
||||
id: "test",
|
||||
version: "1.0.0",
|
||||
contentHash,
|
||||
},
|
||||
machineResources: machine,
|
||||
}).initialize();
|
||||
|
||||
const result = await taskRunProcess.execute({
|
||||
payload: {
|
||||
traceContext: {
|
||||
traceparent: "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
|
||||
},
|
||||
environment: {},
|
||||
execution: {
|
||||
task: run.task,
|
||||
attempt: {
|
||||
id: "attempt_1234",
|
||||
status: "RUNNING",
|
||||
number: 1,
|
||||
startedAt: new Date(),
|
||||
backgroundWorkerId: "worker_1234",
|
||||
backgroundWorkerTaskId: "task_1234",
|
||||
},
|
||||
run: {
|
||||
id: "run_1234",
|
||||
startedAt: new Date(),
|
||||
payload: run.payload,
|
||||
payloadType: run.payloadType ?? "application/json",
|
||||
tags: [],
|
||||
context: {},
|
||||
isTest: false,
|
||||
createdAt: new Date(),
|
||||
durationMs: 0,
|
||||
costInCents: 0,
|
||||
baseCostInCents: 0,
|
||||
version: "1.0.0",
|
||||
},
|
||||
queue: {
|
||||
id: "queue_1234",
|
||||
name: "test",
|
||||
},
|
||||
environment: {
|
||||
type: "DEVELOPMENT",
|
||||
id: "env_1234",
|
||||
slug: "dev",
|
||||
},
|
||||
organization: {
|
||||
id: "org_1234",
|
||||
slug: "test",
|
||||
name: "test",
|
||||
},
|
||||
project: {
|
||||
id: "project_1234",
|
||||
slug: "test",
|
||||
ref: "main",
|
||||
name: "test",
|
||||
},
|
||||
machine,
|
||||
},
|
||||
},
|
||||
messageId: "run_1234",
|
||||
});
|
||||
|
||||
await taskRunProcess.cleanup(true);
|
||||
|
||||
return {
|
||||
result,
|
||||
usageReports,
|
||||
totalDurationMs: usageReports.reduce((acc, report) => acc + report.durationMs, 0),
|
||||
spans,
|
||||
};
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
|
||||
function parseTraceBodyIntoEvents(body: any): ExecuteTaskTraceEvent[] {
|
||||
return body.resourceSpans.flatMap(parseResourceSpanIntoEvents);
|
||||
}
|
||||
|
||||
function parseResourceSpanIntoEvents(resourceSpan: any): ExecuteTaskTraceEvent[] {
|
||||
return resourceSpan.scopeSpans.flatMap((scopeSpan: any) =>
|
||||
parseScopeSpanIntoEvents(scopeSpan, resourceSpan.resource)
|
||||
);
|
||||
}
|
||||
|
||||
function parseScopeSpanIntoEvents(scopeSpan: any, resource: any): ExecuteTaskTraceEvent[] {
|
||||
return scopeSpan.spans.flatMap((span: any) => parseSpanInEvent(span, resource));
|
||||
}
|
||||
|
||||
function parseSpanInEvent(span: any, resource: any): ExecuteTaskTraceEvent {
|
||||
return {
|
||||
name: span.name,
|
||||
traceId: span.traceId,
|
||||
spanId: span.spanId,
|
||||
parentSpanId: span.parentSpanId,
|
||||
durationMs: calculateSpanDurationMs(span),
|
||||
attributes: {
|
||||
...parseAttributes(resource.attributes),
|
||||
...parseAttributes(span.attributes),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function calculateSpanDurationMs(span: any): number {
|
||||
return Number(BigInt(span.endTimeUnixNano) - BigInt(span.startTimeUnixNano) / BigInt(1e6));
|
||||
}
|
||||
|
||||
function parseAttributes(attributes: any): ExecuteTaskTraceEvent["attributes"] {
|
||||
if (!attributes) return {};
|
||||
|
||||
return attributes.reduce((acc: any, attribute: any) => {
|
||||
acc[attribute.key] = isStringValue(attribute.value)
|
||||
? attribute.value.stringValue
|
||||
: isIntValue(attribute.value)
|
||||
? Number(attribute.value.intValue)
|
||||
: isDoubleValue(attribute.value)
|
||||
? attribute.value.doubleValue
|
||||
: isBoolValue(attribute.value)
|
||||
? attribute.value.boolValue
|
||||
: isBytesValue(attribute.value)
|
||||
? binaryToHex(attribute.value.bytesValue)
|
||||
: undefined;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function isBoolValue(value: any | undefined): value is { boolValue: boolean } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.boolValue === "boolean";
|
||||
}
|
||||
|
||||
function isStringValue(value: any | undefined): value is { stringValue: string } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.stringValue === "string";
|
||||
}
|
||||
|
||||
function isIntValue(value: any | undefined): value is { intValue: bigint } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.intValue === "number";
|
||||
}
|
||||
|
||||
function isDoubleValue(value: any | undefined): value is { doubleValue: number } {
|
||||
if (!value) return false;
|
||||
|
||||
return typeof value.doubleValue === "number";
|
||||
}
|
||||
|
||||
function isBytesValue(value: any | undefined): value is { bytesValue: Buffer } {
|
||||
if (!value) return false;
|
||||
|
||||
return Buffer.isBuffer(value.bytesValue);
|
||||
}
|
||||
function binaryToHex(buffer: Buffer | string): string;
|
||||
function binaryToHex(buffer: Buffer | string | undefined): string | undefined;
|
||||
function binaryToHex(buffer: Buffer | string | undefined): string | undefined {
|
||||
if (!buffer) return undefined;
|
||||
if (typeof buffer === "string") return buffer;
|
||||
|
||||
return Buffer.from(Array.from(buffer)).toString("hex");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
exclude: [...configDefaults.exclude, "src/**/*"],
|
||||
},
|
||||
});
|
||||
Executable
+582
@@ -0,0 +1,582 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Default target
|
||||
TARGET="all"
|
||||
|
||||
# Parse command line arguments
|
||||
show_help() {
|
||||
echo "🚀 Trigger.dev MCP Server Installer"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -t, --target TARGET Install target: claude, claude-desktop, cursor, vscode, crush, windsurf, or all (default: all)"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Targets:"
|
||||
echo " claude Install for Claude Code (~/.claude.json)"
|
||||
echo " claude-desktop Install for Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json)"
|
||||
echo " cursor Install for Cursor (~/.cursor/mcp.json)"
|
||||
echo " vscode Install for VS Code (~/Library/Application Support/Code/User/mcp.json)"
|
||||
echo " crush Install for Crush (~/.config/crush/crush.json)"
|
||||
echo " windsurf Install for Windsurf (~/.codeium/windsurf/mcp_config.json)"
|
||||
echo " all Install for all supported targets"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install for all targets"
|
||||
echo " $0 -t claude # Install only for Claude Code"
|
||||
echo " $0 -t claude-desktop # Install only for Claude Desktop"
|
||||
echo " $0 -t cursor # Install only for Cursor"
|
||||
echo " $0 -t vscode # Install only for VS Code"
|
||||
echo " $0 -t crush # Install only for Crush"
|
||||
echo " $0 -t windsurf # Install only for Windsurf"
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-t|--target)
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
show_help
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unknown option: $1"
|
||||
echo "Use -h or --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate target
|
||||
case $TARGET in
|
||||
claude|claude-desktop|cursor|vscode|crush|windsurf|all)
|
||||
;;
|
||||
*)
|
||||
echo "❌ Invalid target: $TARGET"
|
||||
echo "Valid targets are: claude, claude-desktop, cursor, vscode, crush, windsurf, all"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "🚀 Installing Trigger.dev MCP Server for target: $TARGET"
|
||||
|
||||
# Get the absolute path to the node binary
|
||||
NODE_PATH=$(which node)
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
echo "❌ Error: Node.js not found in PATH"
|
||||
echo "Please ensure Node.js is installed and available in your PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Construct the path to the CLI index.js file
|
||||
CLI_PATH="$SCRIPT_DIR/dist/esm/index.js"
|
||||
|
||||
# Construct the path to the MCP log file
|
||||
MCP_LOG_FILE="$SCRIPT_DIR/.mcp.log"
|
||||
|
||||
# Make sure the MCP log file exists
|
||||
touch "$MCP_LOG_FILE"
|
||||
|
||||
# Check if the CLI file exists
|
||||
if [ ! -f "$CLI_PATH" ]; then
|
||||
echo "❌ Error: CLI file not found at $CLI_PATH"
|
||||
echo "Make sure to build the CLI first with: pnpm run build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the CLI is executable
|
||||
chmod +x "$CLI_PATH"
|
||||
|
||||
echo "✅ Found Node.js at: $NODE_PATH"
|
||||
echo "✅ Found CLI at: $CLI_PATH"
|
||||
|
||||
# Function to install for Claude Code
|
||||
install_claude() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Claude Code..."
|
||||
|
||||
local CLAUDE_CONFIG="$HOME/.claude.json"
|
||||
echo "📁 Claude configuration file: $CLAUDE_CONFIG"
|
||||
|
||||
# Check if Claude config exists, create if it doesn't
|
||||
if [ ! -f "$CLAUDE_CONFIG" ]; then
|
||||
echo "📝 Creating new Claude configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CLAUDE_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Claude configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CLAUDE_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Claude Code');
|
||||
console.log('');
|
||||
console.log('📋 Claude Code Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 Try typing @ in Claude Code and select \"triggerdev\" to get started.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Claude configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Claude Desktop
|
||||
install_claude_desktop() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Claude Desktop..."
|
||||
|
||||
local CLAUDE_DESKTOP_DIR="$HOME/Library/Application Support/Claude"
|
||||
local CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_DIR/claude_desktop_config.json"
|
||||
|
||||
echo "📁 Claude Desktop configuration file: $CLAUDE_DESKTOP_CONFIG"
|
||||
|
||||
# Create Claude Desktop directory if it doesn't exist
|
||||
if [ ! -d "$CLAUDE_DESKTOP_DIR" ]; then
|
||||
echo "📝 Creating Claude Desktop configuration directory..."
|
||||
mkdir -p "$CLAUDE_DESKTOP_DIR"
|
||||
fi
|
||||
|
||||
# Check if Claude Desktop config exists, create if it doesn't
|
||||
if [ ! -f "$CLAUDE_DESKTOP_CONFIG" ]; then
|
||||
echo "📝 Creating new Claude Desktop configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CLAUDE_DESKTOP_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Claude Desktop configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CLAUDE_DESKTOP_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Claude Desktop');
|
||||
console.log('');
|
||||
console.log('📋 Claude Desktop Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Claude Desktop.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Claude Desktop configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Cursor
|
||||
install_cursor() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Cursor..."
|
||||
|
||||
local CURSOR_DIR="$HOME/.cursor"
|
||||
local CURSOR_CONFIG="$CURSOR_DIR/mcp.json"
|
||||
|
||||
echo "📁 Cursor configuration file: $CURSOR_CONFIG"
|
||||
|
||||
# Create Cursor directory if it doesn't exist
|
||||
if [ ! -d "$CURSOR_DIR" ]; then
|
||||
echo "📝 Creating Cursor configuration directory..."
|
||||
mkdir -p "$CURSOR_DIR"
|
||||
fi
|
||||
|
||||
# Check if Cursor config exists, create if it doesn't
|
||||
if [ ! -f "$CURSOR_CONFIG" ]; then
|
||||
echo "📝 Creating new Cursor configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$CURSOR_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Cursor configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CURSOR_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Cursor');
|
||||
console.log('');
|
||||
console.log('📋 Cursor Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Cursor.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Cursor configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for VS Code
|
||||
install_vscode() {
|
||||
echo ""
|
||||
echo "🔧 Installing for VS Code..."
|
||||
|
||||
local VSCODE_DIR="$HOME/Library/Application Support/Code/User"
|
||||
local VSCODE_CONFIG="$VSCODE_DIR/mcp.json"
|
||||
|
||||
echo "📁 VS Code configuration file: $VSCODE_CONFIG"
|
||||
|
||||
# Create VS Code User directory if it doesn't exist
|
||||
if [ ! -d "$VSCODE_DIR" ]; then
|
||||
echo "📝 Creating VS Code User configuration directory..."
|
||||
mkdir -p "$VSCODE_DIR"
|
||||
fi
|
||||
|
||||
# Check if VS Code config exists, create if it doesn't
|
||||
if [ ! -f "$VSCODE_CONFIG" ]; then
|
||||
echo "📝 Creating new VS Code configuration file..."
|
||||
echo '{"servers": {}}' > "$VSCODE_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating VS Code configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$VSCODE_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure servers object exists
|
||||
if (!config.servers) {
|
||||
config.servers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.servers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to VS Code');
|
||||
console.log('');
|
||||
console.log('📋 VS Code Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in VS Code.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating VS Code configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Crush
|
||||
install_crush() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Crush..."
|
||||
|
||||
local CRUSH_DIR="$HOME/.config/crush"
|
||||
local CRUSH_CONFIG="$CRUSH_DIR/crush.json"
|
||||
|
||||
echo "📁 Crush configuration file: $CRUSH_CONFIG"
|
||||
|
||||
# Create Crush config directory if it doesn't exist
|
||||
if [ ! -d "$CRUSH_DIR" ]; then
|
||||
echo "📝 Creating Crush configuration directory..."
|
||||
mkdir -p "$CRUSH_DIR"
|
||||
fi
|
||||
|
||||
# Check if Crush config exists, create if it doesn't
|
||||
if [ ! -f "$CRUSH_CONFIG" ]; then
|
||||
echo "📝 Creating new Crush configuration file..."
|
||||
echo '{"$schema": "https://charm.land/crush.json", "mcp": {}}' > "$CRUSH_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Crush configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$CRUSH_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure schema and mcp object exists
|
||||
if (!config['\$schema']) {
|
||||
config['\$schema'] = 'https://charm.land/crush.json';
|
||||
}
|
||||
if (!config.mcp) {
|
||||
config.mcp = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcp['trigger'] = {
|
||||
type: 'stdio',
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Crush');
|
||||
console.log('');
|
||||
console.log('📋 Crush Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Crush.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Crush configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Function to install for Windsurf
|
||||
install_windsurf() {
|
||||
echo ""
|
||||
echo "🔧 Installing for Windsurf..."
|
||||
|
||||
local WINDSURF_DIR="$HOME/.codeium/windsurf"
|
||||
local WINDSURF_CONFIG="$WINDSURF_DIR/mcp_config.json"
|
||||
|
||||
echo "📁 Windsurf configuration file: $WINDSURF_CONFIG"
|
||||
|
||||
# Create Windsurf config directory if it doesn't exist
|
||||
if [ ! -d "$WINDSURF_DIR" ]; then
|
||||
echo "📝 Creating Windsurf configuration directory..."
|
||||
mkdir -p "$WINDSURF_DIR"
|
||||
fi
|
||||
|
||||
# Check if Windsurf config exists, create if it doesn't
|
||||
if [ ! -f "$WINDSURF_CONFIG" ]; then
|
||||
echo "📝 Creating new Windsurf configuration file..."
|
||||
echo '{"mcpServers": {}}' > "$WINDSURF_CONFIG"
|
||||
fi
|
||||
|
||||
# Use Node.js to manipulate the JSON
|
||||
echo "🔧 Updating Windsurf configuration..."
|
||||
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const configPath = '$WINDSURF_CONFIG';
|
||||
const nodePath = '$NODE_PATH';
|
||||
const cliPath = '$CLI_PATH';
|
||||
const logFile = '$MCP_LOG_FILE';
|
||||
|
||||
try {
|
||||
// Read existing config
|
||||
let config;
|
||||
try {
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
config = JSON.parse(configContent);
|
||||
} catch (error) {
|
||||
console.log('📝 Creating new configuration structure...');
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Ensure mcpServers object exists
|
||||
if (!config.mcpServers) {
|
||||
config.mcpServers = {};
|
||||
}
|
||||
|
||||
// Add/update trigger.dev entry
|
||||
config.mcpServers['trigger'] = {
|
||||
command: nodePath,
|
||||
args: [cliPath, 'mcp', '--log-file', logFile, '--api-url', 'http://localhost:3030']
|
||||
};
|
||||
|
||||
// Write back to file with proper formatting
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
console.log('✅ Successfully installed Trigger.dev MCP server to Windsurf');
|
||||
console.log('');
|
||||
console.log('📋 Windsurf Configuration:');
|
||||
console.log(' • Config file:', configPath);
|
||||
console.log(' • Node.js path:', nodePath);
|
||||
console.log(' • CLI path:', cliPath);
|
||||
console.log('');
|
||||
console.log('💡 You can now use Trigger.dev MCP commands in Windsurf.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating Windsurf configuration:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
"
|
||||
}
|
||||
|
||||
# Install based on target
|
||||
case $TARGET in
|
||||
claude)
|
||||
install_claude
|
||||
;;
|
||||
claude-desktop)
|
||||
install_claude_desktop
|
||||
;;
|
||||
cursor)
|
||||
install_cursor
|
||||
;;
|
||||
vscode)
|
||||
install_vscode
|
||||
;;
|
||||
crush)
|
||||
install_crush
|
||||
;;
|
||||
windsurf)
|
||||
install_windsurf
|
||||
;;
|
||||
all)
|
||||
install_claude
|
||||
install_claude_desktop
|
||||
install_cursor
|
||||
install_vscode
|
||||
install_crush
|
||||
install_windsurf
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "🎉 Installation complete!"
|
||||
echo ""
|
||||
echo "🔍 You can test the MCP server with:"
|
||||
echo " pnpm run inspector"
|
||||
@@ -0,0 +1,166 @@
|
||||
{
|
||||
"name": "trigger.dev",
|
||||
"version": "4.5.3",
|
||||
"description": "A Command-Line Interface for Trigger.dev projects",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/triggerdotdev/trigger.dev",
|
||||
"directory": "packages/cli-v3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"mcpName": "io.github.triggerdotdev/trigger.dev",
|
||||
"keywords": [
|
||||
"typescript",
|
||||
"trigger.dev",
|
||||
"workflows",
|
||||
"orchestration",
|
||||
"events",
|
||||
"webhooks",
|
||||
"integrations",
|
||||
"apis",
|
||||
"jobs",
|
||||
"background jobs",
|
||||
"nextjs",
|
||||
"tanstack-intent"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"skills"
|
||||
],
|
||||
"bin": {
|
||||
"trigger": "./dist/esm/index.js"
|
||||
},
|
||||
"tshy": {
|
||||
"selfLink": false,
|
||||
"main": false,
|
||||
"module": false,
|
||||
"dialects": [
|
||||
"esm"
|
||||
],
|
||||
"project": "./tsconfig.src.json",
|
||||
"exclude": [
|
||||
"**/*.test.ts"
|
||||
],
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@epic-web/test-server": "^0.1.0",
|
||||
"@types/eventsource": "^1.1.15",
|
||||
"@types/gradient-string": "^1.1.2",
|
||||
"@types/ini": "^4.1.1",
|
||||
"@types/object-hash": "3.0.6",
|
||||
"@types/polka": "^0.5.7",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/resolve": "^1.20.6",
|
||||
"@types/rimraf": "^4.0.5",
|
||||
"@types/semver": "^7.5.0",
|
||||
"@types/source-map-support": "0.5.10",
|
||||
"@types/ws": "^8.5.3",
|
||||
"cpy-cli": "^5.0.0",
|
||||
"execa": "^8.0.1",
|
||||
"find-up": "^7.0.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"ts-essentials": "10.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
"tsx": "4.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .tshy .tshy-build .turbo",
|
||||
"typecheck": "tsc -p tsconfig.src.json --noEmit",
|
||||
"build": "tshy && pnpm run update-version",
|
||||
"dev": "tshy --watch",
|
||||
"test": "vitest",
|
||||
"test:e2e": "vitest --run -c ./e2e/vitest.config.ts",
|
||||
"update-version": "tsx ../../scripts/updateVersion.ts",
|
||||
"install-mcp": "./install-mcp.sh",
|
||||
"inspector": "npx @modelcontextprotocol/inspector dist/esm/index.js mcp --log-file .mcp.log --api-url http://localhost:3030",
|
||||
"mcp:test": "tsx src/mcp/tools.test.ts",
|
||||
"mcp:smoke": "tsx src/mcp/smoke.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "0.11.0",
|
||||
"@depot/cli": "0.0.1-cli.2.80.0",
|
||||
"@modelcontextprotocol/sdk": "^1.25.2",
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/api-logs": "0.218.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
|
||||
"@opentelemetry/instrumentation": "0.218.0",
|
||||
"@opentelemetry/instrumentation-fetch": "0.218.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1",
|
||||
"@opentelemetry/semantic-conventions": "1.41.1",
|
||||
"@s2-dev/streamstore": "^0.22.10",
|
||||
"@trigger.dev/build": "workspace:4.5.3",
|
||||
"@trigger.dev/core": "workspace:4.5.3",
|
||||
"@trigger.dev/schema-to-json": "workspace:4.5.3",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"braces": "^3.0.3",
|
||||
"c12": "^1.11.1",
|
||||
"chalk": "^5.2.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"cli-table3": "^0.6.3",
|
||||
"commander": "^9.4.1",
|
||||
"confbox": "^0.2.2",
|
||||
"defu": "^6.1.4",
|
||||
"dotenv": "^16.4.5",
|
||||
"esbuild": "^0.23.0",
|
||||
"eventsource": "^3.0.2",
|
||||
"evt": "^2.4.13",
|
||||
"fast-npm-meta": "^0.2.2",
|
||||
"git-last-commit": "^1.0.1",
|
||||
"gradient-string": "^2.0.2",
|
||||
"has-flag": "^5.0.1",
|
||||
"ignore": "^7.0.5",
|
||||
"import-in-the-middle": "3.0.1",
|
||||
"import-meta-resolve": "^4.1.0",
|
||||
"ini": "^5.0.0",
|
||||
"json-stable-stringify": "^1.3.0",
|
||||
"jsonc-parser": "3.2.1",
|
||||
"magicast": "^0.3.4",
|
||||
"minimatch": "^10.0.1",
|
||||
"mlly": "^1.7.1",
|
||||
"nypm": "^0.5.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"open": "^10.0.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"p-retry": "^6.1.0",
|
||||
"partysocket": "^1.0.2",
|
||||
"pkg-types": "^1.1.3",
|
||||
"polka": "^0.5.2",
|
||||
"resolve": "^1.22.8",
|
||||
"semver": "^7.5.0",
|
||||
"signal-exit": "^4.1.0",
|
||||
"socket.io-client": "4.7.5",
|
||||
"source-map-support": "0.5.21",
|
||||
"std-env": "^3.7.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"supports-color": "^10.0.0",
|
||||
"tar": "^7.5.13",
|
||||
"tiny-invariant": "^1.2.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinyglobby": "^0.2.10",
|
||||
"ws": "^8.18.0",
|
||||
"xdg-app-paths": "^8.3.0",
|
||||
"zod": "3.25.76",
|
||||
"zod-validation-error": "^1.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.20.0"
|
||||
},
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/esm/index.d.ts",
|
||||
"default": "./dist/esm/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: trigger-authoring-chat-agent
|
||||
description: >
|
||||
Author and run a durable AI chat agent with chat.agent from @trigger.dev/sdk/ai: the per-turn
|
||||
run loop, why you MUST spread ...chat.toStreamTextOptions() first, returning a StreamTextResult
|
||||
vs calling chat.pipe(), the two server actions (chat.createStartSessionAction +
|
||||
auth.createPublicToken), and wiring useChat to useTriggerChatTransport. Load this when building,
|
||||
modifying, or debugging a chat backend (the agent task or its lifecycle hooks) or its React
|
||||
transport, when declaring typed tools or custom data parts, or when migrating a plain AI SDK
|
||||
streamText route to chat.agent.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Authoring a chat.agent
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-authoring-chat-agent/SKILL.md` — the per-turn run loop, `chat.toStreamTextOptions()`, the two server actions, typed tools/data parts, and the React transport.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "toStreamTextOptions" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **CRITICAL: forgetting `...chat.toStreamTextOptions()`.**
|
||||
```ts
|
||||
// Wrong - compaction / steering / background injection silently no-op
|
||||
return streamText({ model, messages, abortSignal: signal });
|
||||
// Correct - spread FIRST so explicit overrides win
|
||||
return streamText({ ...chat.toStreamTextOptions(), model, messages, abortSignal: signal });
|
||||
```
|
||||
It wires the `prepareStep` callback behind compaction, mid-turn steering, and background
|
||||
injection, injects the system prompt from `chat.prompt()`, resolves the registry model, and adds
|
||||
telemetry. Omitting it makes all of those silently no-op with no error.
|
||||
|
||||
- **Declaring tools only on `streamText`.** Also declare them on `chat.agent({ tools })`, read them
|
||||
back from `run`, and pass `chat.toStreamTextOptions({ tools })`. Otherwise each tool's
|
||||
`toModelOutput` runs on turn 1 but is dropped when history is re-converted on later turns.
|
||||
|
||||
- **Not forwarding `signal` for stop.** Without `abortSignal: signal`, Stop updates the UI but the
|
||||
model keeps generating server-side.
|
||||
|
||||
- **Initializing `chat.local` in `onChatStart`.** Initialize it in `onBoot`. `onChatStart` fires
|
||||
once per chat, so continuation runs skip it and crash with
|
||||
`chat.local can only be modified after initialization`. `onBoot` fires on every fresh worker.
|
||||
|
||||
- **Minting tokens in the browser.** Never expose the environment secret key client-side. Mint via
|
||||
the two server actions; the transport calls them.
|
||||
|
||||
- **Clearing `lastEventId` on `chat.endRun()`.** Keep the cursor for the Session lifetime; clear it
|
||||
only when the Session itself closes. It is sessionId-keyed, so clearing forces a resubscribe from
|
||||
`seq_num=0` that can hit the prior turn's stale `turn-complete` and close the stream empty.
|
||||
|
||||
- **Returning the raw error from `uiMessageStreamOptions.onError`.** It leaks internals (keys,
|
||||
stack traces). Return a sanitized string instead.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-chat-agent-advanced** (Sessions primitive, custom transports, sub-agents, HITL, fast starts, resilience, testing, upgrades), **trigger-authoring-tasks** and **trigger-realtime-and-frontend** (the task + frontend foundations chat builds on).
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: trigger-authoring-tasks
|
||||
description: >
|
||||
Covers writing backend Trigger.dev tasks with @trigger.dev/sdk: defining task() and
|
||||
schemaTask(), the run function and its ctx, retries, waits, queues and concurrency,
|
||||
idempotency keys, run metadata, logging, triggering other tasks (and the Result shape),
|
||||
scheduled/cron tasks, and the essentials of trigger.config.ts. Load this whenever you are
|
||||
authoring or editing code inside a /trigger directory, defining a task, or writing backend
|
||||
code that triggers tasks. Realtime/React hooks and AI chat are covered by separate skills.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Authoring Trigger.dev Tasks
|
||||
|
||||
The full, version-pinned reference for authoring tasks ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-authoring-tasks/SKILL.md` — the complete guide (setup, `schemaTask`, retries, triggering + the Result shape, idempotency, waits, metadata, scheduled tasks, queues/concurrency, `trigger.config.ts`).
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "schemaTask" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
Always import from `@trigger.dev/sdk` — never `@trigger.dev/sdk/v3` (deprecated alias) or `@trigger.dev/core`.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **CRITICAL: Treating the wait result as the output.** `triggerAndWait` and `wait.forToken` return a Result object, not the raw output.
|
||||
- Wrong: `const out = await childTask.triggerAndWait(p); use(out.foo);`
|
||||
- Correct: `const r = await childTask.triggerAndWait(p); if (r.ok) use(r.output.foo);` (or `.unwrap()`).
|
||||
|
||||
2. **Wrapping `triggerAndWait` / `batchTriggerAndWait` / `wait` in `Promise.all`.**
|
||||
- Wrong: `await Promise.all([childTask.triggerAndWait(a), childTask.triggerAndWait(b)]);`
|
||||
- Correct: `await childTask.batchTriggerAndWait([{ payload: a }, { payload: b }]);` (or a sequential for-loop).
|
||||
|
||||
3. **Importing the task instance into backend code.**
|
||||
- Wrong: `import { emailSequence } from "~/trigger/emails";` in a route handler.
|
||||
- Correct: `import type { emailSequence }` plus `tasks.trigger<typeof emailSequence>("email-sequence", payload)`.
|
||||
|
||||
4. **Calling `metadata.set/get` outside `run()`.**
|
||||
- Wrong: setting metadata at module scope or in unrelated backend code (a no-op; `get` returns `undefined`).
|
||||
- Correct: call inside `run()` or a task lifecycle hook.
|
||||
|
||||
5. **Assuming child tasks inherit the parent's queue or metadata.**
|
||||
- Wrong: expecting a subtask to share the parent's `concurrencyLimit` or see its metadata.
|
||||
- Correct: subtasks run on their own queue; pass metadata explicitly via `{ metadata: metadata.current() }`, or push up with `metadata.parent.*`.
|
||||
|
||||
6. **Bundling native/WASM packages.**
|
||||
- Wrong: leaving `sharp`, `re2`, `sqlite3`, or WASM packages in the default bundle.
|
||||
- Correct: add them to `build.external` in `trigger.config.ts`.
|
||||
|
||||
7. **Relying on a raw string idempotency key being global.**
|
||||
- Wrong: `trigger(p, { idempotencyKey: "welcome-email" })` expecting once-ever (true only in v4.3.0 and earlier).
|
||||
- Correct: `await idempotencyKeys.create("welcome-email", { scope: "global" })`.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-realtime-and-frontend** (subscribe to runs, trigger from the frontend), **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (AI chat agents).
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
name: trigger-chat-agent-advanced
|
||||
description: >
|
||||
Advanced and operational chat.agent capabilities for Trigger.dev, loaded on demand. Load this when
|
||||
working on the raw Sessions primitive (sessions / SessionHandle), a custom chat transport or the
|
||||
realtime wire protocol, durable sub-agents (AgentChat, chat.stream.writer), human-in-the-loop,
|
||||
steering, actions, background injection (chat.defer / chat.inject), fast starts (preload, Head
|
||||
Start via @trigger.dev/sdk/chat-server), context resilience (compaction, recovery boot, OOM, large
|
||||
payloads), chat.local run-scoped state, offline testing with mockChatAgent, or prerelease/version
|
||||
upgrades. For the everyday chat.agent({...}) definition and the useTriggerChatTransport happy path,
|
||||
use the trigger-authoring-chat-agent skill instead.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# chat.agent — advanced & operational
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-chat-agent-advanced/SKILL.md` — Sessions primitive, custom transports/wire protocol, sub-agents, HITL, steering, actions, background injection, fast starts, resilience (compaction/recovery/OOM/large payloads), `chat.local`, testing, upgrades.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/ai-chat/` (including `patterns/` for HITL, sub-agents, sessions); the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for an API, e.g. `grep -rl "mockChatAgent" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **CRITICAL: sending a follow-up by re-POSTing `POST /api/v1/sessions`.**
|
||||
```ts
|
||||
// Wrong - a cached re-POST silently drops basePayload.message; basePayload is trigger config, not a channel
|
||||
await fetch("/api/v1/sessions", { method: "POST", body: JSON.stringify({ ...createBody }) });
|
||||
// Correct - append to the session's input channel
|
||||
await fetch(`/realtime/v1/sessions/${id}/in/append`, { method: "POST", body: JSON.stringify({ kind: "message", payload }) });
|
||||
```
|
||||
|
||||
- **Using the wrong token for `.in` / `.out`.** Use `publicAccessToken` from the create response
|
||||
body (session-scoped). The `x-trigger-jwt` response header is run-scoped and cannot subscribe.
|
||||
|
||||
- **Initializing `chat.local` in `onChatStart`.** It is skipped on continuation runs, so `run()`
|
||||
crashes with `chat.local can only be modified after initialization`. Init in `onBoot`.
|
||||
|
||||
- **`chat.defer` for the message-history write.** A mid-stream refresh would read `[]`. `await` that
|
||||
write inline before the model streams; reserve `chat.defer` for analytics, audit, cache warming.
|
||||
|
||||
- **Giving the HITL tool an `execute`.** `streamText` calls it immediately. Leave it execute-less;
|
||||
the frontend supplies the answer via `addToolOutput` + `sendAutomaticallyWhen`.
|
||||
|
||||
- **Declaring sub-agent / heavy tools only on `streamText`.** Also declare them on
|
||||
`chat.agent({ tools })` (or pass to `convertToModelMessages(uiMessages, { tools })` in a custom
|
||||
agent) so `toModelOutput` re-applies on every turn.
|
||||
|
||||
- **Importing heavy-execute tools into the Head Start route module.** This is a build-time import
|
||||
chain problem; runtime strip helpers do not fix it. Keep schemas in an `ai` + `zod`-only module.
|
||||
|
||||
- **Returning a megabyte tool output on the stream.** One `tool-output-available` record over ~1 MiB
|
||||
throws `ChatChunkTooLargeError`. Persist to your store, write the row first, then emit only an id.
|
||||
|
||||
- **Setting `X-Peek-Settled: 1` on the active-send path.** It races the new turn's first chunk and
|
||||
closes the stream early. Use it only on reconnect-on-reload paths.
|
||||
|
||||
> Note on docs vocabulary: agent-side examples in some docs still use the legacy
|
||||
> `trigger:turn-complete` chunk type. That is the agent-emit vocabulary. A custom **reader** must
|
||||
> filter on the `trigger-control` header, not on `chunk.type`.
|
||||
>
|
||||
> MCP-driven agent chats (`list_agents`, `start_agent_chat`, `send_agent_message`,
|
||||
> `close_agent_chat`) are MCP server tools used from Claude Code / Cursor, not importable SDK
|
||||
> functions. See `/mcp-tools#agent-chat-tools`.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-chat-agent** (the everyday `chat.agent({...})` happy path), **trigger-authoring-tasks** and **trigger-realtime-and-frontend** (task + frontend foundations).
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
name: trigger-cost-savings
|
||||
description: >
|
||||
Analyze Trigger.dev tasks, schedules, and runs for cost optimization opportunities. Use when
|
||||
asked to reduce spend, optimize costs, audit usage, right-size machines, or review task
|
||||
efficiency. Combines static source analysis with live run analysis via the Trigger.dev MCP
|
||||
tools (list_runs, get_run_details, get_current_worker).
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Trigger.dev Cost Savings Analysis
|
||||
|
||||
The full, version-pinned cost-audit workflow ships **inside your installed `@trigger.dev/sdk`**. Read it before giving recommendations so they match the SDK version in this project:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-cost-savings/SKILL.md` — the static-analysis checklist, the MCP run-analysis steps (`list_runs`, `get_run_details`, `get_current_worker`), the report format, and the machine-preset cost table.
|
||||
- **Docs:** the canonical guidance is bundled at `node_modules/@trigger.dev/sdk/docs/how-to-reduce-your-spend.mdx`, with supporting pages under `node_modules/@trigger.dev/sdk/docs/` (`machines.mdx`, `runs/max-duration.mdx`, `queue-concurrency.mdx`, `idempotency.mdx`, `triggering.mdx`, `errors-retrying.mdx`).
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
Live run analysis needs the Trigger.dev MCP server (`npx trigger.dev@latest install-mcp`). Without it, do the static source analysis only — never fabricate run data.
|
||||
|
||||
## Key principles
|
||||
|
||||
- **Waits > 5 seconds are free** — checkpointed, no compute charge.
|
||||
- **Start small, scale up** — the default `small-1x` is right for most tasks; right-size down tasks stuck on `large-*` with short durations.
|
||||
- **I/O-bound tasks don't need big machines** — API calls and DB queries wait on the network.
|
||||
- **Add `maxDuration`** — cap runaway compute.
|
||||
- **Debounce high-frequency triggers** — consolidate bursts into single runs.
|
||||
- **Idempotency keys prevent duplicate billed work.**
|
||||
- **`AbortTaskRunError` stops wasteful retries** — don't pay to retry permanent failures.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-tasks** (the task options these levers tune: `machine`, `maxDuration`, `retry`, `queue`, idempotency), **trigger-realtime-and-frontend**, **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (AI agents).
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
name: trigger-getting-started
|
||||
description: >
|
||||
Bootstrap Trigger.dev into an existing project from scratch: authenticate the
|
||||
CLI, install @trigger.dev/sdk and @trigger.dev/build, write trigger.config.ts
|
||||
with the project ref and task dirs, scaffold a /trigger directory with a first
|
||||
task, wire tsconfig and .gitignore, set TRIGGER_SECRET_KEY, and run the dev
|
||||
server. Load this when a project has no trigger.config.ts yet and the user
|
||||
asks to "add Trigger.dev", "set up Trigger.dev", "initialize Trigger.dev", or
|
||||
get a first task running, including in a monorepo. Once the project is set up
|
||||
and you are writing task code, switch to the trigger-authoring-tasks skill.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
library_version: "{{TRIGGER_SDK_VERSION}}"
|
||||
sources:
|
||||
- docs/quick-start.mdx
|
||||
- docs/manual-setup.mdx
|
||||
- docs/config/config-file.mdx
|
||||
- docs/triggering.mdx
|
||||
---
|
||||
|
||||
# Getting started with Trigger.dev
|
||||
|
||||
Set up Trigger.dev in an existing project. The end state is: the SDK installed, a
|
||||
`trigger.config.ts` pointing at a project ref, a `/trigger` directory with at least
|
||||
one exported task, and `trigger dev` running so the task shows up in the dashboard.
|
||||
|
||||
The fastest path is the CLI's own wizard, which performs every mechanical step below
|
||||
and also offers to install the MCP server and these agent skills:
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest init
|
||||
```
|
||||
|
||||
Prefer `init` when you can. Do the manual steps further down when `init` does not fit
|
||||
(monorepos, an existing config to extend, or a non-interactive environment).
|
||||
|
||||
## Two steps need the human
|
||||
|
||||
Most of setup is automatable, but two steps require a person and cannot be done
|
||||
headlessly. When you reach them, stop and ask the user to do them, then continue:
|
||||
|
||||
1. **Authenticating the CLI.** `npx trigger.dev@latest login` opens a browser for the
|
||||
user to sign in. If they have no account, point them to https://cloud.trigger.dev
|
||||
(or a self-hosted instance) first. You cannot complete this for them.
|
||||
2. **The secret key and project ref.** `TRIGGER_SECRET_KEY` and the project ref
|
||||
(`proj_...`) come from the dashboard. Ask the user to copy the **DEV** secret key
|
||||
from the project's API Keys page, and to pick or create the project so you have its
|
||||
ref. `trigger init` can select the project interactively once the user is logged in.
|
||||
|
||||
Treat these as handoffs: state exactly what you need, wait for the user, then resume.
|
||||
|
||||
## Manual setup
|
||||
|
||||
### 1. Authenticate (human step)
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest login
|
||||
# self-hosted:
|
||||
npx trigger.dev@latest login --api-url https://your-trigger-instance.com
|
||||
```
|
||||
|
||||
### 2. Install the packages
|
||||
|
||||
`@trigger.dev/sdk` is a runtime dependency; `@trigger.dev/build` is a dev dependency.
|
||||
Pin both to the same version as the `trigger.dev` CLI you run; the CLI warns on a
|
||||
mismatch during `dev`/`deploy`.
|
||||
|
||||
```bash
|
||||
npm add @trigger.dev/sdk@latest
|
||||
npm add --save-dev @trigger.dev/build@latest
|
||||
```
|
||||
|
||||
### 3. Write `trigger.config.ts`
|
||||
|
||||
Create it in the project root (or `trigger.config.mjs` for JavaScript). The `project`
|
||||
ref and `dirs` are the only required fields.
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
export default defineConfig({
|
||||
project: "<project ref>", // e.g. "proj_abc123", from the dashboard
|
||||
dirs: ["./src/trigger"], // where your tasks live
|
||||
maxDuration: 3600,
|
||||
retries: {
|
||||
enabledInDev: false,
|
||||
default: { maxAttempts: 3, factor: 2, minTimeoutInMs: 1000, maxTimeoutInMs: 10000, randomize: true },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use the Bun runtime by adding `runtime: "bun"`. Build extensions (`prismaExtension`,
|
||||
`puppeteer`, `additionalFiles`, etc.) come from `@trigger.dev/build` and go in
|
||||
`build.extensions`.
|
||||
|
||||
### 4. Add a first task
|
||||
|
||||
Create the directory that matches `dirs` and export a task from it. Every task must be
|
||||
a named export with a project-unique `id`.
|
||||
|
||||
```ts
|
||||
// src/trigger/example.ts
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const helloWorld = task({
|
||||
id: "hello-world",
|
||||
run: async (payload: { name: string }) => {
|
||||
return { message: `Hello ${payload.name}!` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Wire tsconfig and gitignore
|
||||
|
||||
Add `trigger.config.ts` to the `include` array in `tsconfig.json`, and add `.trigger`
|
||||
to `.gitignore` (the CLI writes local dev state there).
|
||||
|
||||
```jsonc
|
||||
// tsconfig.json
|
||||
{ "include": ["trigger.config.ts" /* ...existing */] }
|
||||
```
|
||||
|
||||
```bash
|
||||
# .gitignore
|
||||
.trigger
|
||||
```
|
||||
|
||||
### 6. Set the secret key (human step)
|
||||
|
||||
For triggering from your own code, set `TRIGGER_SECRET_KEY` to the DEV key from the
|
||||
dashboard's API Keys page. Self-hosted users also set `TRIGGER_API_URL`.
|
||||
|
||||
```bash
|
||||
# .env (or .env.local for Next.js)
|
||||
TRIGGER_SECRET_KEY=tr_dev_xxxxxxxx
|
||||
```
|
||||
|
||||
### 7. Run the dev server
|
||||
|
||||
```bash
|
||||
npx trigger.dev@latest dev
|
||||
```
|
||||
|
||||
Leave it running. Tasks register with the dashboard, where the user can fire a test run
|
||||
from the task's test page. On first run the CLI offers to install the MCP server and
|
||||
agent skills; recommend both.
|
||||
|
||||
## Triggering from your app
|
||||
|
||||
Once a task exists, trigger it from backend code with a **type-only** import so the
|
||||
task code is never bundled into your app. Trigger by id, not by calling the task object.
|
||||
|
||||
```ts
|
||||
import { tasks } from "@trigger.dev/sdk";
|
||||
import type { helloWorld } from "@/trigger/example"; // type-only
|
||||
|
||||
const handle = await tasks.trigger<typeof helloWorld>("hello-world", { name: "Ada" });
|
||||
```
|
||||
|
||||
`TRIGGER_SECRET_KEY` must be set wherever this runs. Framework specifics live in the
|
||||
Next.js / Remix / Node.js guides.
|
||||
|
||||
## Monorepos
|
||||
|
||||
Two layouts, both supported: put tasks in a shared package (`@repo/tasks` with its own
|
||||
`trigger.config.ts`, consumed via `workspace:*`), or install Trigger.dev directly in the
|
||||
app that needs it. Run `trigger dev` from the directory that holds `trigger.config.ts`.
|
||||
See the manual setup docs for full Turborepo examples before scaffolding either.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **Trying to do the human-only steps headlessly.** You cannot complete `trigger login`
|
||||
or read the dashboard secret key for the user.
|
||||
- Wrong: spawning `trigger login` and waiting on it to finish in an agent session.
|
||||
- Correct: ask the user to log in and to paste the DEV key, then continue.
|
||||
|
||||
2. **Mismatched CLI and SDK versions.** A `trigger.dev` CLI on a different major than
|
||||
`@trigger.dev/sdk` breaks dev/deploy.
|
||||
- Wrong: `npx trigger.dev@latest dev` against an old pinned SDK.
|
||||
- Correct: keep `trigger.dev`, `@trigger.dev/sdk`, and `@trigger.dev/build` on the same version.
|
||||
|
||||
3. **Importing from `@trigger.dev/sdk/v3` or using `client.defineJob()`.** Both are old.
|
||||
- Correct: always import from `@trigger.dev/sdk`; define work with `task()`.
|
||||
|
||||
4. **Tasks not exported, or outside `dirs`.** A task that is not a named export inside a
|
||||
configured directory will not be picked up.
|
||||
- Correct: `export const ... = task({ ... })` in a file under a `dirs` path.
|
||||
|
||||
5. **Importing the task instance into backend code.** This bundles the task.
|
||||
- Wrong: `import { helloWorld } from "@/trigger/example"` in a route handler.
|
||||
- Correct: `import type { helloWorld }` plus `tasks.trigger<typeof helloWorld>("hello-world", payload)`.
|
||||
|
||||
6. **Forgetting `TRIGGER_SECRET_KEY`.** Triggering from your app fails without it; the
|
||||
`dev` server itself works once the CLI is logged in.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills:
|
||||
|
||||
- **trigger-authoring-tasks** for writing the tasks themselves once setup is done: retries, waits,
|
||||
queues, scheduled tasks, triggering, and the full `trigger.config.ts`.
|
||||
- **trigger-realtime-and-frontend** for showing live run status in a frontend.
|
||||
- **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** for building AI chat agents.
|
||||
|
||||
Docs:
|
||||
|
||||
- [Quick start](https://trigger.dev/docs/quick-start)
|
||||
- [Manual setup](https://trigger.dev/docs/manual-setup)
|
||||
- [Configuration file](https://trigger.dev/docs/config/config-file)
|
||||
|
||||
## Version
|
||||
|
||||
Generated for @trigger.dev/sdk {{TRIGGER_SDK_VERSION}}. Re-run the trigger.dev skills installer after upgrading.
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: trigger-realtime-and-frontend
|
||||
description: >
|
||||
Trigger.dev client/frontend surface: subscribe to runs in realtime
|
||||
(runs.subscribeToRun and the @trigger.dev/react-hooks hook useRealtimeRun),
|
||||
consume metadata and AI/text streams in React (useRealtimeStream), trigger
|
||||
tasks from the browser (useTaskTrigger, useRealtimeTaskTrigger), and mint
|
||||
scoped frontend credentials with auth.createPublicToken /
|
||||
auth.createTriggerPublicToken.
|
||||
Load when wiring a frontend (React/Next.js/Remix) or backend-for-frontend to
|
||||
show live run progress, status badges, token streams, trigger buttons, or
|
||||
wait-token approval UIs. NOT for writing the backend task itself (streams.define
|
||||
/ metadata.set is trigger-authoring-tasks territory); this is the consumer side.
|
||||
type: core
|
||||
library: trigger.dev
|
||||
---
|
||||
|
||||
# Realtime and Frontend
|
||||
|
||||
The full, version-pinned reference ships **inside your installed `@trigger.dev/sdk`**. Read it before writing code — it always matches the SDK version in this project, so it never drifts:
|
||||
|
||||
- **Skill:** `node_modules/@trigger.dev/sdk/skills/trigger-realtime-and-frontend/SKILL.md` — run subscriptions, `@trigger.dev/react-hooks`, streams, frontend triggering, and scoped tokens.
|
||||
- **Docs:** the full, version-pinned docs ship bundled at `node_modules/@trigger.dev/sdk/docs/realtime/`; the skill above lists the exact pages it draws from in its `sources:` frontmatter. Grep for a hook, e.g. `grep -rl "useRealtimeRun" node_modules/@trigger.dev/sdk/docs/`.
|
||||
|
||||
If those paths don't exist, `@trigger.dev/sdk` isn't installed yet — install it first. In a non-hoisted layout, resolve the package with `node -p "require.resolve('@trigger.dev/sdk/package.json')"` and read `skills/` + `docs/` beside it.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
1. **CRITICAL: Triggering from the browser with a Public Access Token.** The
|
||||
read token from `createPublicToken` cannot trigger tasks.
|
||||
- Wrong: `useTaskTrigger("my-task", { accessToken: publicAccessTokenFromCreatePublicToken })`
|
||||
- Correct: mint a single-use Trigger Token with `auth.createTriggerPublicToken("my-task")` and pass that.
|
||||
|
||||
2. **Token with no scopes.** A scopeless token authorizes nothing, so every subscribe 403s.
|
||||
- Wrong: `await auth.createPublicToken()`
|
||||
- Correct: `await auth.createPublicToken({ scopes: { read: { runs: ["run_1234"] } } })`
|
||||
|
||||
3. **Polling with `useRun`/SWR for live updates.** `useRun` is the SWR-based
|
||||
management-API hook (not recommended for live state); set `refreshInterval: 0`
|
||||
to stop polling if you do use it.
|
||||
- Wrong: `useRun(runId, { refreshInterval: 1000 })` to track progress
|
||||
- Correct: `useRealtimeRun(runId, { accessToken })` (no polling, no WebSocket setup)
|
||||
|
||||
4. **Forgetting `"use client"`.** Realtime/trigger hooks cannot run in a server component.
|
||||
- Wrong: a Next.js App Router server component using `useRealtimeRun`
|
||||
- Correct: put `"use client";` at the top of any component using these hooks.
|
||||
|
||||
5. **Shipping `payload`/`output` you do not render.**
|
||||
- Wrong: `useRealtimeRun(runId, { accessToken })` for a status badge (large payloads over the wire)
|
||||
- Correct: `useRealtimeRun(runId, { accessToken, skipColumns: ["payload", "output"] })`
|
||||
|
||||
6. **Subscribing before the handle exists.**
|
||||
- Wrong: `useRealtimeRun(handle, { accessToken: handle?.publicAccessToken })` with no guard
|
||||
- Correct: add `enabled: !!handle` so it subscribes only once the trigger returns a handle.
|
||||
|
||||
## References
|
||||
|
||||
Sibling skills: **trigger-authoring-tasks** (the task side: `streams.define()`, `metadata.set()`, `wait.createToken`), **trigger-authoring-chat-agent** and **trigger-chat-agent-advanced** (chat agents build on these realtime streams).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user