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.
This commit is contained in:
Oskar
2025-12-02 19:52:45 +01:00
parent 6ae1317b69
commit 49b2f683f4
@@ -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<EnvVar[] | null> {
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<string, string> = {
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);