From 49b2f683f4431058866763803892fa7143bba12c Mon Sep 17 00:00:00 2001 From: Oskar Date: Tue, 2 Dec 2025 19:52:45 +0100 Subject: [PATCH 1/6] feat(build): Add NeonDB branch resolution for Vercel preview environments Vercel's NeonDB integration renders database connection environment variables at runtime, which means Trigger.dev cannot directly sync these values during the build process. This change adds support for fetching branch-specific NeonDB connection strings via the Neon API. Changes: - Discover NEON_PROJECT_ID from incoming Vercel environment variables - Call NeonDB API to search for branches matching the git branch name - Filter branches to find exact matches with Vercel environment prefix (e.g., "preview/branch-name") to avoid false positives from partial string matches - Retrieve branch endpoints and select the write endpoint (or first available) - Build connection strings (DATABASE_URL, POSTGRES_URL, etc.) using the branch endpoint host while preserving user/password credentials Safety measures for non-production environments: - Filter out all Neon-related env vars (DATABASE_URL, PGHOST, etc.) before calling the Neon API to prevent accidental use of production database credentials - Only add branch-specific database env vars if a matching Neon branch is found and the API call succeeds - If neonDbAccessToken is not provided or the API fails, non-production environments will not receive any database connection env vars Usage: Users must provide a NEON_ACCESS_TOKEN (via options or env var) to enable automatic branch resolution for preview deployments. Production environments continue to use Vercel's standard env var sync without modification. --- .../src/extensions/core/vercelSyncEnvVars.ts | 237 +++++++++++++++++- 1 file changed, 236 insertions(+), 1 deletion(-) diff --git a/packages/build/src/extensions/core/vercelSyncEnvVars.ts b/packages/build/src/extensions/core/vercelSyncEnvVars.ts index 7f58e8a120..481d0ae7b5 100644 --- a/packages/build/src/extensions/core/vercelSyncEnvVars.ts +++ b/packages/build/src/extensions/core/vercelSyncEnvVars.ts @@ -11,11 +11,192 @@ type VercelEnvVar = { gitBranch?: string; }; +// List of Neon DB related environment variables to sync, +// provided by Vercel's NeonDB integration +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", +]; +const VERCEL_NEON_ENV_VAR_PREFIX = ""; +const NEON_PROJECT_ID_ENV_VAR = "NEON_PROJECT_ID"; + +type NeonBranch = { + id: string; + name: string; +}; + +type NeonEndpoint = { + id: string; + host: string; + type: string; +}; + +async function fetchNeonBranchEnvVars(options: { + neonProjectId: string; + neonDbAccessToken: string; + branch: string; + vercelEnvironment: string; + filteredEnvs: EnvVar[]; + vercelNeonEnvVarPrefix: string; +}): Promise { + const { + neonProjectId, + neonDbAccessToken, + branch, + vercelEnvironment, + filteredEnvs, + vercelNeonEnvVarPrefix, + } = options; + + // Step 1: Search for the branch in Neon + const branchSearchParams = new URLSearchParams({ search: branch }); + const branchesUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches?${branchSearchParams}`; + + const branchesResponse = await fetch(branchesUrl, { + headers: { + Authorization: `Bearer ${neonDbAccessToken}`, + }, + }); + + 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 null to keep original env vars + return null; + } + + // Neon branch names are prefixed with Vercel environment (e.g., "preview/branch-name") + // Filter branches to find the one with the exact matching name + const expectedBranchName = `${vercelEnvironment}/${branch}`; + const matchingBranch = branches.find((b) => b.name === expectedBranchName || b.name === branch); + + if (!matchingBranch) { + // No exact match found, return null to keep original env vars + return null; + } + + const neonBranchId = matchingBranch.id; + + // Step 2: Get endpoints for the branch + const endpointsUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches/${neonBranchId}/endpoints`; + + const endpointsResponse = await fetch(endpointsUrl, { + headers: { + Authorization: `Bearer ${neonDbAccessToken}`, + }, + }); + + 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) { + // No endpoints found, return null + return null; + } + + // 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 null; + } + + // Step 3: Build new environment variables based on the endpoint host + // We need to find DATABASE_URL from filteredEnvs to extract user, password, and database name + const prefixedDatabaseUrlKey = `${vercelNeonEnvVarPrefix}DATABASE_URL`; + const databaseUrlEnv = filteredEnvs.find( + (env) => env.name === prefixedDatabaseUrlKey || env.name === "DATABASE_URL" + ); + + if (!databaseUrlEnv) { + // No DATABASE_URL found, cannot construct new env vars + return null; + } + + // Parse DATABASE_URL to extract components + // Format: postgresql://user:password@host/database?sslmode=require + let parsedUrl: URL; + try { + parsedUrl = new URL(databaseUrlEnv.value); + } catch { + // Invalid URL, return null + return null; + } + + const user = parsedUrl.username; + const password = parsedUrl.password; + const database = parsedUrl.pathname.slice(1); // Remove leading slash + const newHost = endpoint.host; + const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler."); + + // Build new env vars + const newEnvVars: EnvVar[] = []; + + const envVarMappings: Record = { + PGUSER: user, + PGPASSWORD: password, + PGDATABASE: database, + PGHOST: poolerHost, + PGHOST_UNPOOLED: newHost, + POSTGRES_USER: user, + POSTGRES_PASSWORD: password, + POSTGRES_DATABASE: database, + POSTGRES_HOST: poolerHost, + DATABASE_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`, + DATABASE_URL_UNPOOLED: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`, + POSTGRES_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`, + POSTGRES_URL_NO_SSL: `postgresql://${user}:${password}@${poolerHost}/${database}`, + POSTGRES_URL_NON_POOLING: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`, + POSTGRES_PRISMA_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`, + }; + + for (const neonEnvVar of NEON_ENV_VARS) { + const prefixedKey = `${vercelNeonEnvVarPrefix}${neonEnvVar}`; + // Only override if the env var exists in filteredEnvs + const envInFiltered = filteredEnvs.find((env) => env.name === prefixedKey); + if (envInFiltered && envVarMappings[neonEnvVar]) { + newEnvVars.push({ + name: prefixedKey, + value: envVarMappings[neonEnvVar], + isParentEnv: envInFiltered.isParentEnv, + }); + } + } + + return newEnvVars; +} + export function syncVercelEnvVars(options?: { projectId?: string; vercelAccessToken?: string; vercelTeamId?: string; branch?: string; + neonDbAccessToken?: string; + neonProjectId?: string; + vercelNeonEnvVarPrefix?: string; }): BuildExtension { const sync = syncEnvVars(async (ctx) => { const projectId = @@ -25,6 +206,8 @@ export function syncVercelEnvVars(options?: { process.env.VERCEL_ACCESS_TOKEN ?? ctx.env.VERCEL_ACCESS_TOKEN ?? process.env.VERCEL_TOKEN; + const neonDbAccessToken = + options?.neonDbAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN; const vercelTeamId = options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID; const branch = @@ -32,6 +215,9 @@ export function syncVercelEnvVars(options?: { process.env.VERCEL_PREVIEW_BRANCH ?? ctx.env.VERCEL_PREVIEW_BRANCH ?? ctx.branch; + let neonProjectId: string | undefined = + options?.neonProjectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID; + const vercelNeonEnvVarPrefix = options?.vercelNeonEnvVarPrefix ?? VERCEL_NEON_ENV_VAR_PREFIX; if (!projectId) { throw new Error( @@ -79,7 +265,7 @@ export function syncVercelEnvVars(options?: { const isBranchable = ctx.environment === "preview"; - const filteredEnvs: EnvVar[] = data.envs + let filteredEnvs: EnvVar[] = data.envs .filter((env: VercelEnvVar) => { if (!env.value) return false; if (!env.target.includes(vercelEnvironment)) return false; @@ -94,6 +280,55 @@ export function syncVercelEnvVars(options?: { }; }); + // Discover NEON_PROJECT_ID from incoming Vercel env variables + const neonProjectIdEnv = filteredEnvs.find((env) => env.name === NEON_PROJECT_ID_ENV_VAR); + if (neonProjectIdEnv) { + neonProjectId = neonProjectIdEnv.value; + } + + // Keep a copy of the original env vars for the Neon API call (to extract credentials) + const originalFilteredEnvs = [...filteredEnvs]; + + // For non-production environments, filter out Neon env vars to avoid using production database + // These will be replaced with branch-specific values from Neon API if available + if (neonProjectId) { + const neonEnvVarNames = new Set( + NEON_ENV_VARS.map((name) => `${vercelNeonEnvVarPrefix}${name}`) + ); + + if (vercelEnvironment !== "production") { + filteredEnvs = filteredEnvs.filter((env) => !neonEnvVarNames.has(env.name)); + } + } + + // If we have neonProjectId, neonDbAccessToken, and branch, fetch Neon branch info and add env vars + if (neonProjectId && neonDbAccessToken && branch && vercelEnvironment !== "production") { + try { + const neonBranchEnvVars = await fetchNeonBranchEnvVars({ + neonProjectId, + neonDbAccessToken, + branch, + vercelEnvironment, + filteredEnvs: originalFilteredEnvs, + vercelNeonEnvVarPrefix, + }); + if (neonBranchEnvVars) { + // Override NEON_ENV_VARS in filteredEnvs with the new values + for (const neonEnvVar of neonBranchEnvVars) { + const existingIndex = filteredEnvs.findIndex((env) => env.name === neonEnvVar.name); + if (existingIndex !== -1) { + filteredEnvs[existingIndex] = neonEnvVar; + } else { + filteredEnvs.push(neonEnvVar); + } + } + } + } catch (neonError) { + console.error("Error fetching Neon branch environment variables:", neonError); + // Continue with original filteredEnvs if Neon API fails + } + } + return filteredEnvs; } catch (error) { console.error("Error fetching or processing Vercel environment variables:", error); From 53f21e1330482a5813debdf47989a75040f4c72e Mon Sep 17 00:00:00 2001 From: Oskar Date: Wed, 3 Dec 2025 15:55:53 +0100 Subject: [PATCH 2/6] feat(build): Add syncNeonEnvVars extension and improve Vercel env var syncing Add a new `syncNeonEnvVars` build extension for syncing environment variables from Neon database projects to Trigger.dev. The extension automatically detects branches and builds appropriate PostgreSQL connection strings for non-production environments (staging, dev, preview). Features of `syncNeonEnvVars`: - Fetches branch-specific database credentials from Neon API - Generates all standard Postgres connection strings (DATABASE_URL, POSTGRES_URL, POSTGRES_PRISMA_URL, etc.) with both pooled and unpooled variants - Supports custom database name, role name, and env var prefix options - Skips automatically in Vercel environments (Neon's Vercel integration handles this) - Skips for production environments (designed for preview/staging/dev branches) Improvements to `syncVercelEnvVars`: - When running in a Vercel build environment (detected via VERCEL env var), values are now read from process.env instead of the Vercel API response - This ensures the build uses the actual runtime values Vercel provides - Removed embedded Neon-specific logic (now handled by separate extension) - Simplified and cleaned up the extension code Documentation updates for both extensions with usage examples and configuration options. --- docs/config/extensions/syncEnvVars.mdx | 76 +++++ docs/guides/examples/vercel-sync-env-vars.mdx | 8 + packages/build/src/extensions/core.ts | 1 + .../src/extensions/core/neonSyncEnvVars.ts | 289 ++++++++++++++++++ .../src/extensions/core/vercelSyncEnvVars.ts | 246 +-------------- 5 files changed, 382 insertions(+), 238 deletions(-) create mode 100644 packages/build/src/extensions/core/neonSyncEnvVars.ts diff --git a/docs/config/extensions/syncEnvVars.mdx b/docs/config/extensions/syncEnvVars.mdx index 6241e1f520..d4e7def0e3 100644 --- a/docs/config/extensions/syncEnvVars.mdx +++ b/docs/config/extensions/syncEnvVars.mdx @@ -80,6 +80,14 @@ The `syncVercelEnvVars` build extension syncs environment variables from your Ve the project with the environment variables you want to sync. + + When running the build from a Vercel build environment (e.g., during a Vercel deployment), the + environment variable values will be read from `process.env` instead of fetching them from the + Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The + API is still used to determine which environment variables are configured for your project, but + the actual values come from the local environment. + + ```ts import { defineConfig } from "@trigger.dev/sdk"; import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core"; @@ -114,3 +122,71 @@ export default defineConfig({ }, }); ``` + +### syncNeonEnvVars + +The `syncNeonEnvVars` build extension syncs environment variables from your Neon database project to Trigger.dev. It automatically detects branches and builds the appropriate database connection strings for your environment. + + + You need to set the `NEON_ACCESS_TOKEN` and `NEON_PROJECT_ID` environment variables, or pass them + as arguments to the `syncNeonEnvVars` build extension. You can generate a `NEON_ACCESS_TOKEN` in + your Neon [dashboard](https://console.neon.tech/app/settings/api-keys). + + + + When running the build from a Vercel environment (determined by checking if the `VERCEL` + environment variable is present), this extension is skipped entirely. This is because Neon's + Vercel integration already handles environment variable synchronization in Vercel environments. + + + + This extension is skipped for `prod` environments. It is designed to sync branch-specific + database connections for preview/staging/dev environments. + + +```ts +import { defineConfig } from "@trigger.dev/sdk"; +import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core"; + +export default defineConfig({ + project: "", + // Your other config settings... + build: { + // This will automatically use the NEON_ACCESS_TOKEN and NEON_PROJECT_ID environment variables + extensions: [syncNeonEnvVars()], + }, +}); +``` + +Or you can pass in the token and project ID as arguments: + +```ts +import { defineConfig } from "@trigger.dev/sdk"; +import { syncNeonEnvVars } from "@trigger.dev/build/extensions/core"; + +export default defineConfig({ + project: "", + // Your other config settings... + build: { + extensions: [ + syncNeonEnvVars({ + projectId: "your-neon-project-id", + neonAccessToken: "your-neon-access-token", + branch: "your-branch-name", // optional, defaults to ctx.branch + databaseName: "your-database-name", // optional, defaults to the first database + roleName: "your-role-name", // optional, defaults to the database owner + envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars + }), + ], + }, +}); +``` + +The extension syncs the following environment variables (with optional prefix): + +- `DATABASE_URL` - Pooled connection string +- `DATABASE_URL_UNPOOLED` - Direct connection string +- `POSTGRES_URL`, `POSTGRES_URL_NO_SSL`, `POSTGRES_URL_NON_POOLING` +- `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma +- `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE` +- `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` diff --git a/docs/guides/examples/vercel-sync-env-vars.mdx b/docs/guides/examples/vercel-sync-env-vars.mdx index 1397f6ccd3..bcdb7d30cb 100644 --- a/docs/guides/examples/vercel-sync-env-vars.mdx +++ b/docs/guides/examples/vercel-sync-env-vars.mdx @@ -19,6 +19,14 @@ To sync environment variables, you just need to add our build extension to your the project with the environment variables you want to sync. + + When running the build from a Vercel build environment (e.g., during a Vercel deployment), the + environment variable values will be read from `process.env` instead of fetching them from the + Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The + API is still used to determine which environment variables are configured for your project, but + the actual values come from the local environment. + + ```ts trigger.config.ts import { defineConfig } from "@trigger.dev/sdk"; import { syncVercelEnvVars } from "@trigger.dev/build/extensions/core"; diff --git a/packages/build/src/extensions/core.ts b/packages/build/src/extensions/core.ts index d9ed52b5a0..62671dfd57 100644 --- a/packages/build/src/extensions/core.ts +++ b/packages/build/src/extensions/core.ts @@ -3,4 +3,5 @@ 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"; diff --git a/packages/build/src/extensions/core/neonSyncEnvVars.ts b/packages/build/src/extensions/core/neonSyncEnvVars.ts new file mode 100644 index 0000000000..d73a3ed434 --- /dev/null +++ b/packages/build/src/extensions/core/neonSyncEnvVars.ts @@ -0,0 +1,289 @@ +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 { + 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; + 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'.` + ); + } + + 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); + }, + }; +} diff --git a/packages/build/src/extensions/core/vercelSyncEnvVars.ts b/packages/build/src/extensions/core/vercelSyncEnvVars.ts index 481d0ae7b5..34933063cc 100644 --- a/packages/build/src/extensions/core/vercelSyncEnvVars.ts +++ b/packages/build/src/extensions/core/vercelSyncEnvVars.ts @@ -11,192 +11,11 @@ type VercelEnvVar = { gitBranch?: string; }; -// List of Neon DB related environment variables to sync, -// provided by Vercel's NeonDB integration -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", -]; -const VERCEL_NEON_ENV_VAR_PREFIX = ""; -const NEON_PROJECT_ID_ENV_VAR = "NEON_PROJECT_ID"; - -type NeonBranch = { - id: string; - name: string; -}; - -type NeonEndpoint = { - id: string; - host: string; - type: string; -}; - -async function fetchNeonBranchEnvVars(options: { - neonProjectId: string; - neonDbAccessToken: string; - branch: string; - vercelEnvironment: string; - filteredEnvs: EnvVar[]; - vercelNeonEnvVarPrefix: string; -}): Promise { - const { - neonProjectId, - neonDbAccessToken, - branch, - vercelEnvironment, - filteredEnvs, - vercelNeonEnvVarPrefix, - } = options; - - // Step 1: Search for the branch in Neon - const branchSearchParams = new URLSearchParams({ search: branch }); - const branchesUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches?${branchSearchParams}`; - - const branchesResponse = await fetch(branchesUrl, { - headers: { - Authorization: `Bearer ${neonDbAccessToken}`, - }, - }); - - 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 null to keep original env vars - return null; - } - - // Neon branch names are prefixed with Vercel environment (e.g., "preview/branch-name") - // Filter branches to find the one with the exact matching name - const expectedBranchName = `${vercelEnvironment}/${branch}`; - const matchingBranch = branches.find((b) => b.name === expectedBranchName || b.name === branch); - - if (!matchingBranch) { - // No exact match found, return null to keep original env vars - return null; - } - - const neonBranchId = matchingBranch.id; - - // Step 2: Get endpoints for the branch - const endpointsUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches/${neonBranchId}/endpoints`; - - const endpointsResponse = await fetch(endpointsUrl, { - headers: { - Authorization: `Bearer ${neonDbAccessToken}`, - }, - }); - - 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) { - // No endpoints found, return null - return null; - } - - // 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 null; - } - - // Step 3: Build new environment variables based on the endpoint host - // We need to find DATABASE_URL from filteredEnvs to extract user, password, and database name - const prefixedDatabaseUrlKey = `${vercelNeonEnvVarPrefix}DATABASE_URL`; - const databaseUrlEnv = filteredEnvs.find( - (env) => env.name === prefixedDatabaseUrlKey || env.name === "DATABASE_URL" - ); - - if (!databaseUrlEnv) { - // No DATABASE_URL found, cannot construct new env vars - return null; - } - - // Parse DATABASE_URL to extract components - // Format: postgresql://user:password@host/database?sslmode=require - let parsedUrl: URL; - try { - parsedUrl = new URL(databaseUrlEnv.value); - } catch { - // Invalid URL, return null - return null; - } - - const user = parsedUrl.username; - const password = parsedUrl.password; - const database = parsedUrl.pathname.slice(1); // Remove leading slash - const newHost = endpoint.host; - const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler."); - - // Build new env vars - const newEnvVars: EnvVar[] = []; - - const envVarMappings: Record = { - PGUSER: user, - PGPASSWORD: password, - PGDATABASE: database, - PGHOST: poolerHost, - PGHOST_UNPOOLED: newHost, - POSTGRES_USER: user, - POSTGRES_PASSWORD: password, - POSTGRES_DATABASE: database, - POSTGRES_HOST: poolerHost, - DATABASE_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`, - DATABASE_URL_UNPOOLED: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`, - POSTGRES_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`, - POSTGRES_URL_NO_SSL: `postgresql://${user}:${password}@${poolerHost}/${database}`, - POSTGRES_URL_NON_POOLING: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`, - POSTGRES_PRISMA_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`, - }; - - for (const neonEnvVar of NEON_ENV_VARS) { - const prefixedKey = `${vercelNeonEnvVarPrefix}${neonEnvVar}`; - // Only override if the env var exists in filteredEnvs - const envInFiltered = filteredEnvs.find((env) => env.name === prefixedKey); - if (envInFiltered && envVarMappings[neonEnvVar]) { - newEnvVars.push({ - name: prefixedKey, - value: envVarMappings[neonEnvVar], - isParentEnv: envInFiltered.isParentEnv, - }); - } - } - - return newEnvVars; -} - export function syncVercelEnvVars(options?: { projectId?: string; vercelAccessToken?: string; vercelTeamId?: string; branch?: string; - neonDbAccessToken?: string; - neonProjectId?: string; - vercelNeonEnvVarPrefix?: string; }): BuildExtension { const sync = syncEnvVars(async (ctx) => { const projectId = @@ -206,8 +25,6 @@ export function syncVercelEnvVars(options?: { process.env.VERCEL_ACCESS_TOKEN ?? ctx.env.VERCEL_ACCESS_TOKEN ?? process.env.VERCEL_TOKEN; - const neonDbAccessToken = - options?.neonDbAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN; const vercelTeamId = options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID; const branch = @@ -215,9 +32,7 @@ export function syncVercelEnvVars(options?: { process.env.VERCEL_PREVIEW_BRANCH ?? ctx.env.VERCEL_PREVIEW_BRANCH ?? ctx.branch; - let neonProjectId: string | undefined = - options?.neonProjectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID; - const vercelNeonEnvVarPrefix = options?.vercelNeonEnvVarPrefix ?? VERCEL_NEON_ENV_VAR_PREFIX; + const isVercelEnv = !!(ctx.env.VERCEL); if (!projectId) { throw new Error( @@ -265,70 +80,25 @@ export function syncVercelEnvVars(options?: { const isBranchable = ctx.environment === "preview"; - let filteredEnvs: EnvVar[] = data.envs + const filteredEnvs: EnvVar[] = data.envs .filter((env: VercelEnvVar) => { - if (!env.value) return false; 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: env.value, + value, isParentEnv: isBranchable && !env.gitBranch, }; }); - // Discover NEON_PROJECT_ID from incoming Vercel env variables - const neonProjectIdEnv = filteredEnvs.find((env) => env.name === NEON_PROJECT_ID_ENV_VAR); - if (neonProjectIdEnv) { - neonProjectId = neonProjectIdEnv.value; - } - - // Keep a copy of the original env vars for the Neon API call (to extract credentials) - const originalFilteredEnvs = [...filteredEnvs]; - - // For non-production environments, filter out Neon env vars to avoid using production database - // These will be replaced with branch-specific values from Neon API if available - if (neonProjectId) { - const neonEnvVarNames = new Set( - NEON_ENV_VARS.map((name) => `${vercelNeonEnvVarPrefix}${name}`) - ); - - if (vercelEnvironment !== "production") { - filteredEnvs = filteredEnvs.filter((env) => !neonEnvVarNames.has(env.name)); - } - } - - // If we have neonProjectId, neonDbAccessToken, and branch, fetch Neon branch info and add env vars - if (neonProjectId && neonDbAccessToken && branch && vercelEnvironment !== "production") { - try { - const neonBranchEnvVars = await fetchNeonBranchEnvVars({ - neonProjectId, - neonDbAccessToken, - branch, - vercelEnvironment, - filteredEnvs: originalFilteredEnvs, - vercelNeonEnvVarPrefix, - }); - if (neonBranchEnvVars) { - // Override NEON_ENV_VARS in filteredEnvs with the new values - for (const neonEnvVar of neonBranchEnvVars) { - const existingIndex = filteredEnvs.findIndex((env) => env.name === neonEnvVar.name); - if (existingIndex !== -1) { - filteredEnvs[existingIndex] = neonEnvVar; - } else { - filteredEnvs.push(neonEnvVar); - } - } - } - } catch (neonError) { - console.error("Error fetching Neon branch environment variables:", neonError); - // Continue with original filteredEnvs if Neon API fails - } - } - return filteredEnvs; } catch (error) { console.error("Error fetching or processing Vercel environment variables:", error); From 7cbf82a4ae3f6f7ca7ca7b4642399a941773fdd0 Mon Sep 17 00:00:00 2001 From: Oskar Date: Thu, 4 Dec 2025 14:07:31 +0100 Subject: [PATCH 3/6] fix: PR feedback, doc improvements for neon/vercel syncEnvVars --- docs/config/extensions/syncEnvVars.mdx | 23 +++++++++++++++++-- .../src/extensions/core/neonSyncEnvVars.ts | 5 ++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/config/extensions/syncEnvVars.mdx b/docs/config/extensions/syncEnvVars.mdx index d4e7def0e3..b76191196c 100644 --- a/docs/config/extensions/syncEnvVars.mdx +++ b/docs/config/extensions/syncEnvVars.mdx @@ -85,7 +85,18 @@ The `syncVercelEnvVars` build extension syncs environment variables from your Ve environment variable values will be read from `process.env` instead of fetching them from the Vercel API. This is determined by checking if the `VERCEL` environment variable is present. The API is still used to determine which environment variables are configured for your project, but - the actual values come from the local environment. + the actual values come from the local environment. Reading values from `process.env` allows the + extension to use values that Vercel integrations (such as the Neon integration) set per preview + deployment in the "Provisioning Integrations" phase that happens just before the Vercel build + starts. + + + + If you have the Neon database Vercel integration installed and are running builds outside of the + Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your + database environment variables. This ensures that the correct database connection strings are used for your + selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect + branch-specific database credentials when run locally. ```ts @@ -139,9 +150,17 @@ The `syncNeonEnvVars` build extension syncs environment variables from your Neon Vercel integration already handles environment variable synchronization in Vercel environments. + + If you have the Neon database Vercel integration installed and are running builds outside of the + Vercel environment, we recommend using `syncNeonEnvVars` in addition to `syncVercelEnvVars` for your + database environment variables. This ensures that the correct database connection strings are used for your + selected environment and current branch, as `syncVercelEnvVars` may not accurately reflect + branch-specific database credentials when run locally. + + This extension is skipped for `prod` environments. It is designed to sync branch-specific - database connections for preview/staging/dev environments. + database connections for preview/staging environments. ```ts diff --git a/packages/build/src/extensions/core/neonSyncEnvVars.ts b/packages/build/src/extensions/core/neonSyncEnvVars.ts index d73a3ed434..eeb75320e2 100644 --- a/packages/build/src/extensions/core/neonSyncEnvVars.ts +++ b/packages/build/src/extensions/core/neonSyncEnvVars.ts @@ -132,6 +132,11 @@ export function syncNeonEnvVars(options?: { ); } + if (environment === "development") { + // Skip syncing for development environment + return []; + } + try { // Step 1: Search for the branch in Neon const branchSearchParams = new URLSearchParams({ search: branch }); From 357aa99309ec0721396e6537bb8315921756c159 Mon Sep 17 00:00:00 2001 From: Oskar Date: Thu, 4 Dec 2025 14:12:42 +0100 Subject: [PATCH 4/6] Add changeset --- .changeset/funny-planes-remember.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/funny-planes-remember.md diff --git a/.changeset/funny-planes-remember.md b/.changeset/funny-planes-remember.md new file mode 100644 index 0000000000..5e550b8e75 --- /dev/null +++ b/.changeset/funny-planes-remember.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/build": patch +--- + +syncVercelEnvVars to skip API and read env vars directly from env.process for Vercel build environments.New syncNeonEnvVars build extension for syncing environment variablesfrom Neon database projects to Trigger.dev. The extension automatically detectsbranches and builds appropriate PostgreSQL connection strings for non-productionenvironments (staging, dev, preview). From 8fcd93001d4e31be4d55f7f9d06ed9db7befb3bc Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:42:32 +0000 Subject: [PATCH 5/6] chore: insert missing spaces in changeset --- .changeset/funny-planes-remember.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/funny-planes-remember.md b/.changeset/funny-planes-remember.md index 5e550b8e75..342d592f9f 100644 --- a/.changeset/funny-planes-remember.md +++ b/.changeset/funny-planes-remember.md @@ -2,4 +2,4 @@ "@trigger.dev/build": patch --- -syncVercelEnvVars to skip API and read env vars directly from env.process for Vercel build environments.New syncNeonEnvVars build extension for syncing environment variablesfrom Neon database projects to Trigger.dev. The extension automatically detectsbranches and builds appropriate PostgreSQL connection strings for non-productionenvironments (staging, dev, preview). +syncVercelEnvVars to skip API and read env vars directly from env.process for Vercel build environments. New syncNeonEnvVars build extension for syncing environment variablesfrom Neon database projects to Trigger.dev. The extension automatically detects branches and builds appropriate PostgreSQL connection strings for non-production environments (staging, dev, preview). From 702f3b4bcadb15446eb0253f2aa7ca8d460b4d66 Mon Sep 17 00:00:00 2001 From: Oskar Date: Thu, 4 Dec 2025 16:26:24 +0100 Subject: [PATCH 6/6] chore: Improve changeset message --- .changeset/funny-planes-remember.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/funny-planes-remember.md b/.changeset/funny-planes-remember.md index 342d592f9f..6c73ef4b71 100644 --- a/.changeset/funny-planes-remember.md +++ b/.changeset/funny-planes-remember.md @@ -2,4 +2,4 @@ "@trigger.dev/build": patch --- -syncVercelEnvVars to skip API and read env vars directly from env.process for Vercel build environments. New syncNeonEnvVars build extension for syncing environment variablesfrom Neon database projects to Trigger.dev. The extension automatically detects branches and builds appropriate PostgreSQL connection strings for non-production environments (staging, dev, preview). +syncVercelEnvVars to skip API and read env vars directly from env.process for Vercel build environments. New syncNeonEnvVars build extension for syncing environment variablesfrom Neon database projects to Trigger.dev. The extension automatically detects branches and builds appropriate PostgreSQL connection strings for non-production, non-dev environments (staging, preview).