vercel / vercel/nft

1.3.2 package-boundary guard silently drops cwd-anchored config files referenced by node_modules packages

Open
#606 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
1.7k
Forks
185
PR merge metrics
No merged PRs in 30d

Description

[!NOTE]
Used AI to replicate the issue, and generate this ticket.

Summary

Since 1.3.2 (#568), a package inside node_modules can no longer emit an asset that lives outside node_modules, even when the path is inside job.base and was resolved from process.cwd().

This silently drops user-project config files that packages look up by convention, e.g. path.resolve('./some-tool.config.js'). It took our production app down for several hours with no build-time signal.

I want to check whether the in-base case is intended before proposing anything — the tests added in #568 suggest the package boundary is deliberate, but the PR title and description both describe the goal as "outside job base", which is a wider allowance than the code implements.

Reproduction
#!/usr/bin/env bash
set -e
DIR=$(mktemp -d); cd "$DIR"
echo '{"name":"host","version":"1.0.0"}' > package.json
echo 'module.exports = { hello: true }'  > target.js

cat > check.js <<'EOF2'
const { nodeFileTrace } = require('@vercel/nft');
nodeFileTrace([process.cwd() + '/node_modules/fakepkg/index.js'], { base: process.cwd() })
  .then((r) => {
    const traced = [...r.fileList].some((f) => f.includes('target.js'));
    console.log(`  target.js: ${traced ? 'TRACED' : 'NOT TRACED'}`);
  });
EOF2

for v in 1.3.1 1.3.2; do
  npm i --silent --no-audit --no-fund "@vercel/nft@$v" > /dev/null 2>&1
  # recreate after install, since npm prunes extraneous packages
  mkdir -p node_modules/fakepkg
  echo '{"name":"fakepkg","version":"1.0.0","main":"index.js"}' > node_modules/fakepkg/package.json
  cat > node_modules/fakepkg/index.js <<'EOF2'
const path = require('path');
const p = path.resolve('./target.js');
// Babel's CJS output for `await import(p)`
module.exports = (function (specifier) {
  return new Promise(function (r) { return r("".concat(specifier)); })
    .then(function (s) { return require(s); });
})(p);
EOF2
  echo "@vercel/nft@$v"
  node check.js
done
@vercel/nft@1.3.1
  target.js: TRACED
@vercel/nft@1.3.2
  target.js: NOT TRACED

target.js is inside the job base, and cwd equals the base.

Mechanism

The guard added in src/analyze.ts:

if (pkgBase) {
  const nodeModulesBase =
    id.substring(0, id.indexOf(path.sep + 'node_modules')) + path.sep + 'node_modules' + path.sep;
  if (!assetPath.startsWith(nodeModulesBase)) {
    // skipped
    return;
  }
}

When the analysed file is inside node_modules, any asset outside <project>/node_modules/ is skipped — including files inside job.base.

This only affects paths that reach emitAssetPath. It's why the shape matters: require(p) is emitted as a dependency and is unaffected, while Babel's transpiled await import(p) is not recognised as a require, so the computed path is treated as an asset and hits the guard. Measured on the same file with the same base:

Shape 1.3.1 1.3.2
import(p) — native TRACED TRACED
Babel new Promise(r => r("".concat(p))).then(s => require(s)) TRACED NOT TRACED
new Promise(r => r(require(p))) TRACED TRACED
require("".concat(p)) TRACED TRACED
require(p) TRACED TRACED
Real-world impact

next-i18next's CommonJS build loads its user config this way:

// next-i18next/dist/commonjs/serverSideTranslations.js
const configPath = path.resolve('./next-i18next.config.js');
if (!userConfig && fs.existsSync(configPath)) {
  userConfig = await import(configPath);   // → Babel Promise/require shape
}
if (userConfig === null) {
  throw new Error(`next-i18next was unable to find a user config at ${configPath}`);
}

Next.js 16.3.0 bundles a post-1.3.2 nft (16.2.10 bundled an earlier one). After that upgrade next-i18next.config.js stopped being traced, so on Vercel fs.existsSync('/var/task/next-i18next.config.js') was false and the throw fired before any page rendered — every server-rendered response 500'd, including the on-demand 404, while prerendered pages were unaffected.

Measured against the real next-i18next entry point (base = project root, cwd = project root):

nft files traced next-i18next.config.js
1.3.1 308 traced
1.3.2 297 not traced

The 11 dropped files were exactly next-i18next.config.js, the root package.json, and the nine locale JSONs reachable through that import.

Looking up a config file from process.cwd() is a widespread convention (i18n, CSS, lint and test tooling all do it), so this likely reaches beyond next-i18next.

Questions
  1. Is skipping in-base assets intended, or is the guard broader than #568 needed? The SvelteKit case that motivated it (stringified env vars pointing at Node/Yarn system paths) is outside job.base, which the second half of that change already handles via job.ignoreFn(path.relative(job.base, assetPath)).
  2. Regardless of (1) — could the skip be surfaced in warnings rather than only logged under job.log? Right now the file is dropped silently, so the first symptom is a 500 in production. A warning would have made this a build-time signal. Happy to open a PR for that.
Workaround

Declare the files explicitly. For Next.js:

outputFileTracingIncludes: {
  '/**': ['./next-i18next.config.js', './public/locales/**'],
},
Environment
  • @vercel/nft 1.3.1 vs 1.3.2 (standalone), and as bundled in next 16.2.10 vs 16.3.0
  • next-i18next 15.4.3
  • Node.js v22.21.1, macOS

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the guard in src/analyze.ts and the tests added for #568, then run the supplied reproduction against nft 1.3.1 and 1.3.2. Determine the intended treatment of in-base assets and whether skipped assets should appear in warnings; done means the decision is covered by regression tests for the reported import shape.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, node.js
Domain
devtools
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.