-
Notifications
You must be signed in to change notification settings - Fork 2
fix: enhance patch-symlinks.cjs to auto-resolve transitive dependencies for Vercel deployment #430
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3d25537
cc9e4a8
7fa1acd
997426e
fba93ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,9 +5,16 @@ | |
| * Prepares node_modules for Vercel deployment. | ||
| * | ||
| * pnpm uses symlinks in node_modules which Vercel rejects as | ||
| * "invalid deployment package … symlinked directories". This script | ||
| * replaces ALL top-level symlinks with real copies of the target | ||
| * directories so that Vercel can bundle the serverless function. | ||
| * "invalid deployment package … symlinked directories". This script: | ||
| * | ||
| * 1. Resolves transitive dependencies — walks each package's pnpm | ||
| * virtual store context (`.pnpm/<name>@<ver>/node_modules/`) and | ||
| * copies any missing dependency into the top-level `node_modules/`. | ||
| * This is repeated until the full transitive closure is present. | ||
| * | ||
| * 2. Dereferences all remaining symlinks — replaces every top-level | ||
| * symlink in `node_modules/` with a real copy so Vercel can bundle | ||
| * the serverless function. | ||
| * | ||
| * This script is invoked as a postinstall hook and is a no-op outside | ||
| * the Vercel build environment (process.env.VERCEL is not set locally). | ||
|
|
@@ -25,6 +32,159 @@ const path = require('path'); | |
|
|
||
| const ROOT = path.resolve(__dirname, '..'); | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Helpers | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * List all top-level package names in a node_modules directory. | ||
| * Handles scoped packages (@scope/name). | ||
| */ | ||
| function listTopLevelPackages(nmAbs) { | ||
| const packages = []; | ||
| if (!fs.existsSync(nmAbs)) return packages; | ||
|
|
||
| for (const entry of fs.readdirSync(nmAbs)) { | ||
| if (entry === '.pnpm' || entry.startsWith('.')) continue; | ||
|
|
||
| const entryPath = path.join(nmAbs, entry); | ||
|
|
||
| if (entry.startsWith('@')) { | ||
| try { | ||
| if (!fs.statSync(entryPath).isDirectory()) continue; | ||
| } catch { continue; } | ||
| for (const sub of fs.readdirSync(entryPath)) { | ||
| packages.push(`${entry}/${sub}`); | ||
| } | ||
| } else { | ||
| packages.push(entry); | ||
| } | ||
| } | ||
| return packages; | ||
| } | ||
|
|
||
| /** | ||
| * Given a resolved real path of a package *inside* the pnpm virtual store, | ||
| * return the virtual `node_modules/` directory that contains the package's | ||
| * dependencies as siblings. | ||
| * | ||
| * Example: | ||
| * realPath = …/.pnpm/@objectstack+runtime@3.2.8/node_modules/@objectstack/runtime | ||
| * pkgName = @objectstack/runtime (2 segments) | ||
| * → …/.pnpm/@objectstack+runtime@3.2.8/node_modules | ||
| */ | ||
| function pnpmContextDir(realPath, pkgName) { | ||
| const depth = pkgName.split('/').length; // 1 for unscoped, 2 for scoped | ||
| let dir = realPath; | ||
| for (let i = 0; i < depth; i++) dir = path.dirname(dir); | ||
| return dir; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Phase 1 — Resolve transitive dependencies | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Walk the pnpm virtual store context of every package already present in | ||
| * `node_modules/`, copying any sibling dependency that is not yet present | ||
| * at the top level. Repeat until no new packages are added (transitive | ||
| * closure). | ||
| * | ||
| * MUST run before symlinks are dereferenced — we rely on `fs.realpathSync` | ||
| * following pnpm symlinks to discover the `.pnpm/` context directories. | ||
| */ | ||
| function resolveTransitiveDeps(nmDir) { | ||
| const nmAbs = path.resolve(ROOT, nmDir); | ||
| if (!fs.existsSync(nmAbs)) return 0; | ||
|
|
||
| const processedContexts = new Set(); | ||
| const contextQueue = []; | ||
|
|
||
| // Seed the queue with every symlinked package's pnpm context. | ||
| for (const pkgName of listTopLevelPackages(nmAbs)) { | ||
| const pkgPath = path.join(nmAbs, pkgName); | ||
| try { | ||
| if (!fs.lstatSync(pkgPath).isSymbolicLink()) continue; | ||
| const realPath = fs.realpathSync(pkgPath); | ||
| const ctxDir = pnpmContextDir(realPath, pkgName); | ||
| if (ctxDir.includes('.pnpm') && !processedContexts.has(ctxDir)) { | ||
| processedContexts.add(ctxDir); | ||
| contextQueue.push(ctxDir); | ||
| } | ||
| } catch { /* skip unresolvable entries */ } | ||
| } | ||
|
|
||
| let totalAdded = 0; | ||
|
|
||
| // Safety limit — prevent runaway iteration in pathological dependency graphs. | ||
| const MAX_CONTEXTS = 5000; | ||
|
|
||
| while (contextQueue.length > 0) { | ||
| if (processedContexts.size > MAX_CONTEXTS) { | ||
| console.warn(` ⚠ Reached ${MAX_CONTEXTS} context directories — stopping transitive resolution.`); | ||
| break; | ||
| } | ||
|
|
||
| const ctxDir = contextQueue.shift(); | ||
|
|
||
| // Iterate siblings in this .pnpm context's node_modules. | ||
| let entries; | ||
| try { entries = fs.readdirSync(ctxDir); } catch { continue; } | ||
| for (const entry of entries) { | ||
| if (entry === '.pnpm' || entry.startsWith('.')) continue; | ||
|
|
||
| const processEntry = (depName, entryPath) => { | ||
| const targetPath = path.join(nmAbs, depName); | ||
| if (fs.existsSync(targetPath)) return; // already present | ||
|
|
||
| // Resolve the real path of this pnpm-store entry. | ||
| let realDepPath; | ||
| try { | ||
| const stat = fs.lstatSync(entryPath); | ||
| realDepPath = stat.isSymbolicLink() | ||
| ? fs.realpathSync(entryPath) | ||
| : entryPath; | ||
| } catch { return; } | ||
|
|
||
| // Ensure scope directory exists for scoped packages. | ||
| if (depName.includes('/')) { | ||
| fs.mkdirSync(path.join(nmAbs, depName.split('/')[0]), { recursive: true }); | ||
| } | ||
|
|
||
| console.log(` + ${depName}`); | ||
| fs.cpSync(realDepPath, targetPath, { recursive: true, dereference: true }); | ||
| totalAdded++; | ||
|
Comment on lines
+136
to
+156
|
||
|
|
||
| // Enqueue this dep's own pnpm context so its transitive deps | ||
| // are also resolved on a subsequent iteration. | ||
| const depCtxDir = pnpmContextDir(realDepPath, depName); | ||
| if (depCtxDir.includes('.pnpm') && !processedContexts.has(depCtxDir)) { | ||
| processedContexts.add(depCtxDir); | ||
| contextQueue.push(depCtxDir); | ||
| } | ||
| }; | ||
|
|
||
| if (entry.startsWith('@')) { | ||
| const scopeDir = path.join(ctxDir, entry); | ||
| try { | ||
| if (!fs.statSync(scopeDir).isDirectory()) continue; | ||
| } catch { continue; } | ||
| for (const sub of fs.readdirSync(scopeDir)) { | ||
| processEntry(`${entry}/${sub}`, path.join(scopeDir, sub)); | ||
| } | ||
| } else { | ||
| processEntry(entry, path.join(ctxDir, entry)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return totalAdded; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Phase 2 — Dereference symlinks | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Replace a pnpm symlink with a real copy of the target directory. | ||
| */ | ||
|
|
@@ -84,7 +244,16 @@ function derefAllSymlinks(nmDir) { | |
| return count; | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Main | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| console.log('\n🔧 Patching pnpm symlinks for Vercel deployment…\n'); | ||
|
|
||
| console.log('Phase 1: Resolving transitive dependencies…'); | ||
| const transCount = resolveTransitiveDeps('node_modules'); | ||
| console.log(` Added ${transCount} transitive dependencies.\n`); | ||
|
|
||
| console.log('Phase 2: Dereferencing symlinks…'); | ||
| const count = derefAllSymlinks('node_modules'); | ||
| console.log(`\n✅ Patch complete — processed ${count} packages\n`); | ||
| console.log(`\n✅ Patch complete — dereferenced ${count} packages, added ${transCount} transitive deps\n`); | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
resolveTransitiveDeps() uses contextQueue.shift() in a loop. Array#shift() is O(n) because it re-indexes the array each time, which can make this phase noticeably slower as the queue grows. Use an index pointer (e.g., for (let i=0; i<queue.length; i++)) or a small deque implementation to keep it O(n).