55 * Prepares node_modules for Vercel deployment.
66 *
77 * pnpm uses symlinks in node_modules which Vercel rejects as
8- * "invalid deployment package … symlinked directories". This script
9- * replaces ALL top-level symlinks with real copies of the target
10- * directories so that Vercel can bundle the serverless function.
8+ * "invalid deployment package … symlinked directories". This script:
9+ *
10+ * 1. Resolves transitive dependencies — walks each package's pnpm
11+ * virtual store context (`.pnpm/<name>@<ver>/node_modules/`) and
12+ * copies any missing dependency into the top-level `node_modules/`.
13+ * This is repeated until the full transitive closure is present.
14+ *
15+ * 2. Dereferences all remaining symlinks — replaces every top-level
16+ * symlink in `node_modules/` with a real copy so Vercel can bundle
17+ * the serverless function.
1118 *
1219 * This script is invoked as a postinstall hook and is a no-op outside
1320 * the Vercel build environment (process.env.VERCEL is not set locally).
@@ -25,6 +32,159 @@ const path = require('path');
2532
2633const ROOT = path . resolve ( __dirname , '..' ) ;
2734
35+ // ---------------------------------------------------------------------------
36+ // Helpers
37+ // ---------------------------------------------------------------------------
38+
39+ /**
40+ * List all top-level package names in a node_modules directory.
41+ * Handles scoped packages (@scope/name).
42+ */
43+ function listTopLevelPackages ( nmAbs ) {
44+ const packages = [ ] ;
45+ if ( ! fs . existsSync ( nmAbs ) ) return packages ;
46+
47+ for ( const entry of fs . readdirSync ( nmAbs ) ) {
48+ if ( entry === '.pnpm' || entry . startsWith ( '.' ) ) continue ;
49+
50+ const entryPath = path . join ( nmAbs , entry ) ;
51+
52+ if ( entry . startsWith ( '@' ) ) {
53+ try {
54+ if ( ! fs . statSync ( entryPath ) . isDirectory ( ) ) continue ;
55+ } catch { continue ; }
56+ for ( const sub of fs . readdirSync ( entryPath ) ) {
57+ packages . push ( `${ entry } /${ sub } ` ) ;
58+ }
59+ } else {
60+ packages . push ( entry ) ;
61+ }
62+ }
63+ return packages ;
64+ }
65+
66+ /**
67+ * Given a resolved real path of a package *inside* the pnpm virtual store,
68+ * return the virtual `node_modules/` directory that contains the package's
69+ * dependencies as siblings.
70+ *
71+ * Example:
72+ * realPath = …/.pnpm/@objectstack+runtime@3.2.8/node_modules/@objectstack/runtime
73+ * pkgName = @objectstack/runtime (2 segments)
74+ * → …/.pnpm/@objectstack+runtime@3.2.8/node_modules
75+ */
76+ function pnpmContextDir ( realPath , pkgName ) {
77+ const depth = pkgName . split ( '/' ) . length ; // 1 for unscoped, 2 for scoped
78+ let dir = realPath ;
79+ for ( let i = 0 ; i < depth ; i ++ ) dir = path . dirname ( dir ) ;
80+ return dir ;
81+ }
82+
83+ // ---------------------------------------------------------------------------
84+ // Phase 1 — Resolve transitive dependencies
85+ // ---------------------------------------------------------------------------
86+
87+ /**
88+ * Walk the pnpm virtual store context of every package already present in
89+ * `node_modules/`, copying any sibling dependency that is not yet present
90+ * at the top level. Repeat until no new packages are added (transitive
91+ * closure).
92+ *
93+ * MUST run before symlinks are dereferenced — we rely on `fs.realpathSync`
94+ * following pnpm symlinks to discover the `.pnpm/` context directories.
95+ */
96+ function resolveTransitiveDeps ( nmDir ) {
97+ const nmAbs = path . resolve ( ROOT , nmDir ) ;
98+ if ( ! fs . existsSync ( nmAbs ) ) return 0 ;
99+
100+ const processedContexts = new Set ( ) ;
101+ const contextQueue = [ ] ;
102+
103+ // Seed the queue with every symlinked package's pnpm context.
104+ for ( const pkgName of listTopLevelPackages ( nmAbs ) ) {
105+ const pkgPath = path . join ( nmAbs , pkgName ) ;
106+ try {
107+ if ( ! fs . lstatSync ( pkgPath ) . isSymbolicLink ( ) ) continue ;
108+ const realPath = fs . realpathSync ( pkgPath ) ;
109+ const ctxDir = pnpmContextDir ( realPath , pkgName ) ;
110+ if ( ctxDir . includes ( '.pnpm' ) && ! processedContexts . has ( ctxDir ) ) {
111+ processedContexts . add ( ctxDir ) ;
112+ contextQueue . push ( ctxDir ) ;
113+ }
114+ } catch { /* skip unresolvable entries */ }
115+ }
116+
117+ let totalAdded = 0 ;
118+
119+ // Safety limit — prevent runaway iteration in pathological dependency graphs.
120+ const MAX_CONTEXTS = 5000 ;
121+
122+ while ( contextQueue . length > 0 ) {
123+ if ( processedContexts . size > MAX_CONTEXTS ) {
124+ console . warn ( ` ⚠ Reached ${ MAX_CONTEXTS } context directories — stopping transitive resolution.` ) ;
125+ break ;
126+ }
127+
128+ const ctxDir = contextQueue . shift ( ) ;
129+
130+ // Iterate siblings in this .pnpm context's node_modules.
131+ let entries ;
132+ try { entries = fs . readdirSync ( ctxDir ) ; } catch { continue ; }
133+ for ( const entry of entries ) {
134+ if ( entry === '.pnpm' || entry . startsWith ( '.' ) ) continue ;
135+
136+ const processEntry = ( depName , entryPath ) => {
137+ const targetPath = path . join ( nmAbs , depName ) ;
138+ if ( fs . existsSync ( targetPath ) ) return ; // already present
139+
140+ // Resolve the real path of this pnpm-store entry.
141+ let realDepPath ;
142+ try {
143+ const stat = fs . lstatSync ( entryPath ) ;
144+ realDepPath = stat . isSymbolicLink ( )
145+ ? fs . realpathSync ( entryPath )
146+ : entryPath ;
147+ } catch { return ; }
148+
149+ // Ensure scope directory exists for scoped packages.
150+ if ( depName . includes ( '/' ) ) {
151+ fs . mkdirSync ( path . join ( nmAbs , depName . split ( '/' ) [ 0 ] ) , { recursive : true } ) ;
152+ }
153+
154+ console . log ( ` + ${ depName } ` ) ;
155+ fs . cpSync ( realDepPath , targetPath , { recursive : true , dereference : true } ) ;
156+ totalAdded ++ ;
157+
158+ // Enqueue this dep's own pnpm context so its transitive deps
159+ // are also resolved on a subsequent iteration.
160+ const depCtxDir = pnpmContextDir ( realDepPath , depName ) ;
161+ if ( depCtxDir . includes ( '.pnpm' ) && ! processedContexts . has ( depCtxDir ) ) {
162+ processedContexts . add ( depCtxDir ) ;
163+ contextQueue . push ( depCtxDir ) ;
164+ }
165+ } ;
166+
167+ if ( entry . startsWith ( '@' ) ) {
168+ const scopeDir = path . join ( ctxDir , entry ) ;
169+ try {
170+ if ( ! fs . statSync ( scopeDir ) . isDirectory ( ) ) continue ;
171+ } catch { continue ; }
172+ for ( const sub of fs . readdirSync ( scopeDir ) ) {
173+ processEntry ( `${ entry } /${ sub } ` , path . join ( scopeDir , sub ) ) ;
174+ }
175+ } else {
176+ processEntry ( entry , path . join ( ctxDir , entry ) ) ;
177+ }
178+ }
179+ }
180+
181+ return totalAdded ;
182+ }
183+
184+ // ---------------------------------------------------------------------------
185+ // Phase 2 — Dereference symlinks
186+ // ---------------------------------------------------------------------------
187+
28188/**
29189 * Replace a pnpm symlink with a real copy of the target directory.
30190 */
@@ -84,7 +244,16 @@ function derefAllSymlinks(nmDir) {
84244 return count ;
85245}
86246
247+ // ---------------------------------------------------------------------------
248+ // Main
249+ // ---------------------------------------------------------------------------
250+
87251console . log ( '\n🔧 Patching pnpm symlinks for Vercel deployment…\n' ) ;
88252
253+ console . log ( 'Phase 1: Resolving transitive dependencies…' ) ;
254+ const transCount = resolveTransitiveDeps ( 'node_modules' ) ;
255+ console . log ( ` Added ${ transCount } transitive dependencies.\n` ) ;
256+
257+ console . log ( 'Phase 2: Dereferencing symlinks…' ) ;
89258const count = derefAllSymlinks ( 'node_modules' ) ;
90- console . log ( `\n✅ Patch complete — processed ${ count } packages\n` ) ;
259+ console . log ( `\n✅ Patch complete — dereferenced ${ count } packages, added ${ transCount } transitive deps \n` ) ;
0 commit comments