[adapter-vercel] EEXIST in create_function_bundle when a workspace lib and the app resolve the same dependency through different pnpm symlink chains
Describe the bug
In a pnpm workspace, adapter-vercel's create_function_bundle can crash with EEXIST while copying traced dependencies into a serverless function's output directory. This happens when @vercel/nft's fileList contains two different source paths that both resolve (via fs.realpathSync) to the same real file, but land on the same dest after path.relative(ancestor, ...) — the copy loop assumes traced-file → destination is 1:1 and calls fs.symlinkSync unconditionally, so the second occurrence collides with the first.
This is easy to hit in a monorepo: pnpm gives every workspace package that depends on some library X its own node_modules/X symlink pointing at the same real install of X in the content-addressable store. If a workspace library (call it packages/ui) both re-exports/uses X in code that ends up in the app's server bundle, and the app itself directly imports X, nft traces X through two distinct symlink chains (apps/app/node_modules/X/... and packages/ui/node_modules/X/...) that both realpath to the identical file in the pnpm store. Since only one serverless function is generated (no per-route ISR/edge splitting), both chains get traced into the same function's dependency copy, and the second fs.symlinkSync throws.
Reproduction
Minimal shape (not a full repro repo, but should be straightforward to reproduce):
- A pnpm workspace with:
apps/app — a SvelteKit app using @sveltejs/adapter-vercel, with @sveltejs/kit as a direct dependency.
packages/ui — a workspace library that apps/app depends on, which itself has @sveltejs/kit as a dependency (direct or peer — doesn't matter, pnpm links a resolvable packages/ui/node_modules/@sveltejs/kit symlink either way) and whose compiled output does import { redirect } from '@sveltejs/kit' (or similar) in a module that's reachable from a route that's actually included in the production server bundle (e.g. a real, non-dev-gated route — not something dead-code-eliminated away).
apps/app's own route code also imports directly from @sveltejs/kit (e.g. redirect/error in a +page.server.ts), which is normal/expected for basically any real SvelteKit app.
- Build with a route configuration that resolves to a single (
singular) serverless function — i.e. no ISR/edge config splitting routes into multiple function groups.
pnpm build (vite build via adapter-vercel).
Actual behavior
Error: EEXIST: file already exists, symlink
at Object.symlinkSync (node:fs:...)
at create_function_bundle (.../node_modules/@sveltejs/adapter-vercel/index.js:792:8)
...
errno: -17,
code: 'PLUGIN_ERROR',
syscall: 'symlink',
path: '<pnpm-store>/@sveltejs+kit@.../node_modules/@sveltejs/kit',
dest: '.vercel/output/functions/![-]/catchall.func/<repo-relative-path>/node_modules/@sveltejs/kit',
pluginCode: 'EEXIST',
plugin: 'vite-plugin-sveltekit-compile',
hook: 'closeBundle'
}
The specific missing file / failure point is nondeterministic run-to-run in the sense that different traced dependencies can hit the same collision depending on fileList iteration order and what's cached from a prior partial build, but once .svelte-kit and .vercel output are both cleared, the same EEXIST on @sveltejs/kit reproduces deterministically for us on every clean build.
Root cause
create_function_bundle in packages/adapter-vercel/index.js:
for (const file of traced.fileList) {
const source = base + file;
const dest = path.join(dir, path.relative(ancestor, source));
const stats = fs.statSync(source);
const is_dir = stats.isDirectory();
const realpath = fs.realpathSync(source);
try {
fs.mkdirSync(path.dirname(dest), { recursive: true });
} catch {
// do nothing
}
if (source !== realpath) {
const realdest = path.join(dir, path.relative(ancestor, realpath));
fs.symlinkSync(path.relative(path.dirname(dest), realdest), dest, is_dir ? 'dir' : 'file');
} else if (!is_dir) {
fs.copyFileSync(source, dest);
}
}
There's no check for whether dest was already created by a previous iteration. Confirmed this is still present, unchanged, on the current default branch (version-3) — not something already fixed and unreleased.
Suggested fix
Make the copy idempotent — skip (or verify-and-skip) a destination that's already been linked, instead of letting fs.symlinkSync throw:
if (source !== realpath) {
const realdest = path.join(dir, path.relative(ancestor, realpath));
- fs.symlinkSync(path.relative(path.dirname(dest), realdest), dest, is_dir ? 'dir' : 'file');
+ try {
+ fs.symlinkSync(path.relative(path.dirname(dest), realdest), dest, is_dir ? 'dir' : 'file');
+ } catch (error) {
+ if (error.code !== 'EEXIST') throw error;
+ }
} else if (!is_dir) {
fs.copyFileSync(source, dest);
}
We've applied this locally via pnpm patch and confirmed it resolves the build failure with no other side effects.
Environment
@sveltejs/adapter-vercel: 6.3.3 (confirmed still present in 6.3.4 and on the version-3 default branch)
@sveltejs/kit: 2.55.0
vite: 8.0.3
pnpm: 10.20.0
node: v22.22.0
- OS: macOS (Darwin)
[adapter-vercel]
EEXISTincreate_function_bundlewhen a workspace lib and the app resolve the same dependency through different pnpm symlink chainsDescribe the bug
In a pnpm workspace,
adapter-vercel'screate_function_bundlecan crash withEEXISTwhile copying traced dependencies into a serverless function's output directory. This happens when@vercel/nft'sfileListcontains two differentsourcepaths that both resolve (viafs.realpathSync) to the same real file, but land on the samedestafterpath.relative(ancestor, ...)— the copy loop assumes traced-file → destination is 1:1 and callsfs.symlinkSyncunconditionally, so the second occurrence collides with the first.This is easy to hit in a monorepo: pnpm gives every workspace package that depends on some library
Xits ownnode_modules/Xsymlink pointing at the same real install ofXin the content-addressable store. If a workspace library (call itpackages/ui) both re-exports/usesXin code that ends up in the app's server bundle, and the app itself directly importsX,nfttracesXthrough two distinct symlink chains (apps/app/node_modules/X/...andpackages/ui/node_modules/X/...) that both realpath to the identical file in the pnpm store. Since only one serverless function is generated (no per-route ISR/edge splitting), both chains get traced into the same function's dependency copy, and the secondfs.symlinkSyncthrows.Reproduction
Minimal shape (not a full repro repo, but should be straightforward to reproduce):
apps/app— a SvelteKit app using@sveltejs/adapter-vercel, with@sveltejs/kitas a direct dependency.packages/ui— a workspace library thatapps/appdepends on, which itself has@sveltejs/kitas a dependency (direct or peer — doesn't matter, pnpm links a resolvablepackages/ui/node_modules/@sveltejs/kitsymlink either way) and whose compiled output doesimport { redirect } from '@sveltejs/kit'(or similar) in a module that's reachable from a route that's actually included in the production server bundle (e.g. a real, non-dev-gated route — not something dead-code-eliminated away).apps/app's own route code also imports directly from@sveltejs/kit(e.g.redirect/errorin a+page.server.ts), which is normal/expected for basically any real SvelteKit app.singular) serverless function — i.e. no ISR/edge config splitting routes into multiple function groups.pnpm build(vite buildviaadapter-vercel).Actual behavior
The specific missing file / failure point is nondeterministic run-to-run in the sense that different traced dependencies can hit the same collision depending on
fileListiteration order and what's cached from a prior partial build, but once.svelte-kitand.verceloutput are both cleared, the sameEEXISTon@sveltejs/kitreproduces deterministically for us on every clean build.Root cause
create_function_bundleinpackages/adapter-vercel/index.js:There's no check for whether
destwas already created by a previous iteration. Confirmed this is still present, unchanged, on the current default branch (version-3) — not something already fixed and unreleased.Suggested fix
Make the copy idempotent — skip (or verify-and-skip) a destination that's already been linked, instead of letting
fs.symlinkSyncthrow:if (source !== realpath) { const realdest = path.join(dir, path.relative(ancestor, realpath)); - fs.symlinkSync(path.relative(path.dirname(dest), realdest), dest, is_dir ? 'dir' : 'file'); + try { + fs.symlinkSync(path.relative(path.dirname(dest), realdest), dest, is_dir ? 'dir' : 'file'); + } catch (error) { + if (error.code !== 'EEXIST') throw error; + } } else if (!is_dir) { fs.copyFileSync(source, dest); }We've applied this locally via
pnpm patchand confirmed it resolves the build failure with no other side effects.Environment
@sveltejs/adapter-vercel: 6.3.3 (confirmed still present in 6.3.4 and on theversion-3default branch)@sveltejs/kit: 2.55.0vite: 8.0.3pnpm: 10.20.0node: v22.22.0