Skip to content

[adapter-vercel] EEXIST in create_function_bundle when a workspace lib and the app resolve the same dependency through different pnpm symlink chains #16806

Description

@mstachowiak

[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):

  1. 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).
  2. 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.
  3. 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.
  4. 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)

Activity

  1. added a commit that references this issue on Aug 17, 2026
    b817dc0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions