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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user