@@ -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+
14192export 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