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