Skip to content

Commit 49b2f68

Browse files
committed
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.
1 parent 6ae1317 commit 49b2f68

File tree

1 file changed

+236
-1
lines changed

1 file changed

+236
-1
lines changed

packages/build/src/extensions/core/vercelSyncEnvVars.ts

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,192 @@ type VercelEnvVar = {
1111
gitBranch?: string;
1212
};
1313

14+
// List of Neon DB related environment variables to sync,
15+
// provided by Vercel's NeonDB integration
16+
const NEON_ENV_VARS = [
17+
"PGUSER",
18+
"POSTGRES_URL_NO_SSL",
19+
"POSTGRES_HOST",
20+
"POSTGRES_URL",
21+
"POSTGRES_PRISMA_URL",
22+
"DATABASE_URL_UNPOOLED",
23+
"POSTGRES_URL_NON_POOLING",
24+
"PGHOST",
25+
"POSTGRES_USER",
26+
"DATABASE_URL",
27+
"POSTGRES_PASSWORD",
28+
"POSTGRES_DATABASE",
29+
"PGPASSWORD",
30+
"PGDATABASE",
31+
"PGHOST_UNPOOLED",
32+
];
33+
const VERCEL_NEON_ENV_VAR_PREFIX = "";
34+
const NEON_PROJECT_ID_ENV_VAR = "NEON_PROJECT_ID";
35+
36+
type NeonBranch = {
37+
id: string;
38+
name: string;
39+
};
40+
41+
type NeonEndpoint = {
42+
id: string;
43+
host: string;
44+
type: string;
45+
};
46+
47+
async function fetchNeonBranchEnvVars(options: {
48+
neonProjectId: string;
49+
neonDbAccessToken: string;
50+
branch: string;
51+
vercelEnvironment: string;
52+
filteredEnvs: EnvVar[];
53+
vercelNeonEnvVarPrefix: string;
54+
}): Promise<EnvVar[] | null> {
55+
const {
56+
neonProjectId,
57+
neonDbAccessToken,
58+
branch,
59+
vercelEnvironment,
60+
filteredEnvs,
61+
vercelNeonEnvVarPrefix,
62+
} = options;
63+
64+
// Step 1: Search for the branch in Neon
65+
const branchSearchParams = new URLSearchParams({ search: branch });
66+
const branchesUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches?${branchSearchParams}`;
67+
68+
const branchesResponse = await fetch(branchesUrl, {
69+
headers: {
70+
Authorization: `Bearer ${neonDbAccessToken}`,
71+
},
72+
});
73+
74+
if (!branchesResponse.ok) {
75+
throw new Error(`Failed to fetch Neon branches: ${branchesResponse.status}`);
76+
}
77+
78+
const branchesData = await branchesResponse.json();
79+
const branches: NeonBranch[] = branchesData.branches || [];
80+
81+
if (branches.length === 0) {
82+
// No matching branch found, return null to keep original env vars
83+
return null;
84+
}
85+
86+
// Neon branch names are prefixed with Vercel environment (e.g., "preview/branch-name")
87+
// Filter branches to find the one with the exact matching name
88+
const expectedBranchName = `${vercelEnvironment}/${branch}`;
89+
const matchingBranch = branches.find((b) => b.name === expectedBranchName || b.name === branch);
90+
91+
if (!matchingBranch) {
92+
// No exact match found, return null to keep original env vars
93+
return null;
94+
}
95+
96+
const neonBranchId = matchingBranch.id;
97+
98+
// Step 2: Get endpoints for the branch
99+
const endpointsUrl = `https://console.neon.tech/api/v2/projects/${neonProjectId}/branches/${neonBranchId}/endpoints`;
100+
101+
const endpointsResponse = await fetch(endpointsUrl, {
102+
headers: {
103+
Authorization: `Bearer ${neonDbAccessToken}`,
104+
},
105+
});
106+
107+
if (!endpointsResponse.ok) {
108+
throw new Error(`Failed to fetch Neon branch endpoints: ${endpointsResponse.status}`);
109+
}
110+
111+
const endpointsData = await endpointsResponse.json();
112+
const endpoints: NeonEndpoint[] = endpointsData.endpoints || [];
113+
114+
if (endpoints.length === 0) {
115+
// No endpoints found, return null
116+
return null;
117+
}
118+
119+
// Find an endpoint with type containing 'write', or take the first one
120+
const writeEndpoint = endpoints.find((ep) => ep.type.includes("write"));
121+
const endpoint = writeEndpoint || endpoints[0];
122+
123+
if (!endpoint) {
124+
return null;
125+
}
126+
127+
// Step 3: Build new environment variables based on the endpoint host
128+
// We need to find DATABASE_URL from filteredEnvs to extract user, password, and database name
129+
const prefixedDatabaseUrlKey = `${vercelNeonEnvVarPrefix}DATABASE_URL`;
130+
const databaseUrlEnv = filteredEnvs.find(
131+
(env) => env.name === prefixedDatabaseUrlKey || env.name === "DATABASE_URL"
132+
);
133+
134+
if (!databaseUrlEnv) {
135+
// No DATABASE_URL found, cannot construct new env vars
136+
return null;
137+
}
138+
139+
// Parse DATABASE_URL to extract components
140+
// Format: postgresql://user:password@host/database?sslmode=require
141+
let parsedUrl: URL;
142+
try {
143+
parsedUrl = new URL(databaseUrlEnv.value);
144+
} catch {
145+
// Invalid URL, return null
146+
return null;
147+
}
148+
149+
const user = parsedUrl.username;
150+
const password = parsedUrl.password;
151+
const database = parsedUrl.pathname.slice(1); // Remove leading slash
152+
const newHost = endpoint.host;
153+
const poolerHost = newHost.replace(/^([^.]+)\./, "$1-pooler.");
154+
155+
// Build new env vars
156+
const newEnvVars: EnvVar[] = [];
157+
158+
const envVarMappings: Record<string, string> = {
159+
PGUSER: user,
160+
PGPASSWORD: password,
161+
PGDATABASE: database,
162+
PGHOST: poolerHost,
163+
PGHOST_UNPOOLED: newHost,
164+
POSTGRES_USER: user,
165+
POSTGRES_PASSWORD: password,
166+
POSTGRES_DATABASE: database,
167+
POSTGRES_HOST: poolerHost,
168+
DATABASE_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`,
169+
DATABASE_URL_UNPOOLED: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`,
170+
POSTGRES_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require`,
171+
POSTGRES_URL_NO_SSL: `postgresql://${user}:${password}@${poolerHost}/${database}`,
172+
POSTGRES_URL_NON_POOLING: `postgresql://${user}:${password}@${newHost}/${database}?sslmode=require`,
173+
POSTGRES_PRISMA_URL: `postgresql://${user}:${password}@${poolerHost}/${database}?sslmode=require&pgbouncer=true&connect_timeout=15`,
174+
};
175+
176+
for (const neonEnvVar of NEON_ENV_VARS) {
177+
const prefixedKey = `${vercelNeonEnvVarPrefix}${neonEnvVar}`;
178+
// Only override if the env var exists in filteredEnvs
179+
const envInFiltered = filteredEnvs.find((env) => env.name === prefixedKey);
180+
if (envInFiltered && envVarMappings[neonEnvVar]) {
181+
newEnvVars.push({
182+
name: prefixedKey,
183+
value: envVarMappings[neonEnvVar],
184+
isParentEnv: envInFiltered.isParentEnv,
185+
});
186+
}
187+
}
188+
189+
return newEnvVars;
190+
}
191+
14192
export function syncVercelEnvVars(options?: {
15193
projectId?: string;
16194
vercelAccessToken?: string;
17195
vercelTeamId?: string;
18196
branch?: string;
197+
neonDbAccessToken?: string;
198+
neonProjectId?: string;
199+
vercelNeonEnvVarPrefix?: string;
19200
}): BuildExtension {
20201
const sync = syncEnvVars(async (ctx) => {
21202
const projectId =
@@ -25,13 +206,18 @@ export function syncVercelEnvVars(options?: {
25206
process.env.VERCEL_ACCESS_TOKEN ??
26207
ctx.env.VERCEL_ACCESS_TOKEN ??
27208
process.env.VERCEL_TOKEN;
209+
const neonDbAccessToken =
210+
options?.neonDbAccessToken ?? process.env.NEON_ACCESS_TOKEN ?? ctx.env.NEON_ACCESS_TOKEN;
28211
const vercelTeamId =
29212
options?.vercelTeamId ?? process.env.VERCEL_TEAM_ID ?? ctx.env.VERCEL_TEAM_ID;
30213
const branch =
31214
options?.branch ??
32215
process.env.VERCEL_PREVIEW_BRANCH ??
33216
ctx.env.VERCEL_PREVIEW_BRANCH ??
34217
ctx.branch;
218+
let neonProjectId: string | undefined =
219+
options?.neonProjectId ?? process.env.NEON_PROJECT_ID ?? ctx.env.NEON_PROJECT_ID;
220+
const vercelNeonEnvVarPrefix = options?.vercelNeonEnvVarPrefix ?? VERCEL_NEON_ENV_VAR_PREFIX;
35221

36222
if (!projectId) {
37223
throw new Error(
@@ -79,7 +265,7 @@ export function syncVercelEnvVars(options?: {
79265

80266
const isBranchable = ctx.environment === "preview";
81267

82-
const filteredEnvs: EnvVar[] = data.envs
268+
let filteredEnvs: EnvVar[] = data.envs
83269
.filter((env: VercelEnvVar) => {
84270
if (!env.value) return false;
85271
if (!env.target.includes(vercelEnvironment)) return false;
@@ -94,6 +280,55 @@ export function syncVercelEnvVars(options?: {
94280
};
95281
});
96282

283+
// Discover NEON_PROJECT_ID from incoming Vercel env variables
284+
const neonProjectIdEnv = filteredEnvs.find((env) => env.name === NEON_PROJECT_ID_ENV_VAR);
285+
if (neonProjectIdEnv) {
286+
neonProjectId = neonProjectIdEnv.value;
287+
}
288+
289+
// Keep a copy of the original env vars for the Neon API call (to extract credentials)
290+
const originalFilteredEnvs = [...filteredEnvs];
291+
292+
// For non-production environments, filter out Neon env vars to avoid using production database
293+
// These will be replaced with branch-specific values from Neon API if available
294+
if (neonProjectId) {
295+
const neonEnvVarNames = new Set(
296+
NEON_ENV_VARS.map((name) => `${vercelNeonEnvVarPrefix}${name}`)
297+
);
298+
299+
if (vercelEnvironment !== "production") {
300+
filteredEnvs = filteredEnvs.filter((env) => !neonEnvVarNames.has(env.name));
301+
}
302+
}
303+
304+
// If we have neonProjectId, neonDbAccessToken, and branch, fetch Neon branch info and add env vars
305+
if (neonProjectId && neonDbAccessToken && branch && vercelEnvironment !== "production") {
306+
try {
307+
const neonBranchEnvVars = await fetchNeonBranchEnvVars({
308+
neonProjectId,
309+
neonDbAccessToken,
310+
branch,
311+
vercelEnvironment,
312+
filteredEnvs: originalFilteredEnvs,
313+
vercelNeonEnvVarPrefix,
314+
});
315+
if (neonBranchEnvVars) {
316+
// Override NEON_ENV_VARS in filteredEnvs with the new values
317+
for (const neonEnvVar of neonBranchEnvVars) {
318+
const existingIndex = filteredEnvs.findIndex((env) => env.name === neonEnvVar.name);
319+
if (existingIndex !== -1) {
320+
filteredEnvs[existingIndex] = neonEnvVar;
321+
} else {
322+
filteredEnvs.push(neonEnvVar);
323+
}
324+
}
325+
}
326+
} catch (neonError) {
327+
console.error("Error fetching Neon branch environment variables:", neonError);
328+
// Continue with original filteredEnvs if Neon API fails
329+
}
330+
}
331+
97332
return filteredEnvs;
98333
} catch (error) {
99334
console.error("Error fetching or processing Vercel environment variables:", error);

0 commit comments

Comments
 (0)