Skip to content

copyTracedFiles recreates pnpm symlinks as broken links on Windows (esbuild "Cannot read directory: Access is denied") #1184

Description

@joshua92y

Environment

  • Windows 11 (Developer Mode enabled)
  • pnpm 11.x (nodeLinker: isolated, the default) — also reproduced with nodeLinker: hoisted
  • @opennextjs/aws 4.0.2 (via @opennextjs/cloudflare 1.19.11)
  • Next.js 16.2.9, built with next build --webpack

Symptom

opennextjs-cloudflare build fails while bundling the server function:

✘ [ERROR] Cannot read directory ".open-next/server-functions/default/node_modules/.pnpm/next@16.2.9_.../node_modules/react-dom": Access is denied.
✘ [ERROR] Could not resolve "styled-jsx"
    .open-next/.../next/dist/compiled/next-server/pages.runtime.prod.js: ... require("styled-jsx") ...

Every directory that was a pnpm symlink in the traced files (react, react-dom, styled-jsx, …) is recreated in .open-next as a broken link, so esbuild cannot traverse into it.

Root cause

In dist/build/copyTracedFiles.js, symlinks are recreated with the raw readlinkSync value:

let symlink = null;
try {
    symlink = readlinkSync(from);
} catch (e) { /* ignore */ }
if (symlink) {
    try {
        symlinkSync(symlink, to);   // ← breaks on Windows
    } catch (e) {
        if (e.code !== "EEXIST") throw e;
    }
}

This has two Windows-specific problems:

  1. Link type falls back to 'file' for not-yet-existing targets. Per the Node docs for fs.symlinkSync(target, path[, type]), when type is omitted on Windows, Node autodetects the target type — and "if the target does not exist, 'file' will be used". Relative symlink targets are resolved against the link's parent directory, and copyTracedFiles copies entries in traversal order, so at the moment a pnpm directory link is recreated, its relative target inside the .open-next mirror frequently does not exist yet. The result is a file-type symlink pointing at a directory, which Windows cannot traverse — readdir fails with EPERM/"Access is denied" even after the target directory is later populated. (Linux has no file/dir link distinction, which is why this never surfaces there.)

  2. Raw relative targets are environment-sensitive. In our reproduction environment, node_modules installed by pnpm 10.x used absolute junctions (raw recreation happens to survive, since the target is absolute and already exists), while pnpm 11.5.2 produced relative symlinks (cf. Use symlink instead of junction even in Windows if possible (=in developer mode) pnpm/symlink-dir#55), which triggers (1). The failure family is not new to pnpm 11, though — opennextjs-cloudflare#414 reports the same copyTracedFiles symlink breakage on pnpm 10.5.0 — so this appears to be a latent issue that surfaces depending on the link style of the installed node_modules.

Note this also interacts with Next's own standalone output: .next/standalone already contains links recreated the same way by Next itself, so from can itself be a broken link — any fix that calls realpathSync(from) unconditionally will hit EPERM there (we verified this).

Proposed fix (verified)

Recreate the link on Windows as a junction whose target is the raw symlink value resolved against the destination's parent directory — semantically identical to what the relative symlink means on Linux (it points into the .open-next mirror structure). Junctions fit this exact case: NTFS junctions can only point to directories (and every pnpm link recreated here is a directory link), they require no admin rights/Developer Mode, they resolve lazily (the link stays valid once the target directory is populated later in the copy), and they never touch the original store. Junction targets must be absolute — Node normalizes 'junction' targets to absolute automatically, but we resolve explicitly for clarity:

if (symlink) {
    try {
        if (process.platform === "win32") {
            const rawTarget = symlink.startsWith("\\\\?\\")
                ? symlink.slice(4)
                : symlink;
            const target = path.isAbsolute(rawTarget)
                ? rawTarget
                : path.resolve(path.dirname(to), rawTarget);
            symlinkSync(target, to, "junction");
        } else {
            symlinkSync(symlink, to);
        }
    } catch (e) {
        if (e.code !== "EEXIST") throw e;
    }
}

Validation

Applied as a patch to @opennextjs/aws 4.0.2 across three real Next.js 16 apps on Windows:

  • 2 apps with pnpm nodeLinker: isolated, 1 with nodeLinker: hoisted
  • All three: clean opennextjs-cloudflare build reaches "Worker saved", no esbuild resolution errors
  • All three deployed to Cloudflare Workers and serving HTTP 200 in production

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions