From 2a83a9ffe2f4d78a258175f99b9d8ffc685b1159 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 12 Jun 2026 13:22:54 +0200 Subject: [PATCH] ref(build): Relocate vendored deps out of `node_modules` in npm builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `preserveModules: true`, Rollup emits bundled dependencies that resolve from `node_modules` (e.g. a vendored devDependency like `@sentry/conventions`) into an output directory literally named `node_modules`. Node treats `node_modules` as a package-scope boundary, so the `{"type":"module"}` marker we emit at the ESM build root does NOT apply inside it — Node then loads our ESM `.js` files there as CommonJS (named imports fail with "is a CommonJS module"), and Vitest externalizes any `/node_modules/` path and `require()`s it, which additionally breaks on Node 18. This adds a rollup-utils plugin that relocates such modules to a plain `_external/` directory (Rollup rewrites the import specifiers automatically), so the scope marker applies and the heuristics no longer fire. Also maps the `@sentry/conventions/attributes` subpath in the shared TS config for `node` moduleResolution type builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev-packages/rollup-utils/npmHelpers.mjs | 10 +++- .../rollup-utils/plugins/npmPlugins.mjs | 58 +++++++++++++++++++ packages/typescript/tsconfig.json | 5 +- 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 4a184d1ea4e5..e0261d3dded9 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -17,6 +17,7 @@ import { makeEsbuildPlugin, makeNodeResolvePlugin, makeProductionReplacePlugin, + makeRelocateVendoredModulesPlugin, makeRrwebBuildPlugin, } from './plugins/index.mjs'; import { makePackageNodeEsm } from './plugins/make-esm-plugin.mjs'; @@ -38,6 +39,7 @@ export function makeBaseNPMConfig(options = {}) { } = options; const nodeResolvePlugin = makeNodeResolvePlugin(); + const relocateVendoredModulesPlugin = makeRelocateVendoredModulesPlugin(); const transpilePlugin = makeEsbuildPlugin(esbuild); const debugBuildStatementReplacePlugin = makeDebugBuildStatementReplacePlugin(); const rrwebBuildPlugin = makeRrwebBuildPlugin({ @@ -102,7 +104,13 @@ export function makeBaseNPMConfig(options = {}) { }, }, - plugins: [nodeResolvePlugin, transpilePlugin, debugBuildStatementReplacePlugin, rrwebBuildPlugin], + plugins: [ + relocateVendoredModulesPlugin, + nodeResolvePlugin, + transpilePlugin, + debugBuildStatementReplacePlugin, + rrwebBuildPlugin, + ], // don't include imported modules from outside the package in the final output // also treat subpath exports (e.g. `@sentry/core/browser`) as external diff --git a/dev-packages/rollup-utils/plugins/npmPlugins.mjs b/dev-packages/rollup-utils/plugins/npmPlugins.mjs index 22e24decdfaf..ead6ed86f36f 100644 --- a/dev-packages/rollup-utils/plugins/npmPlugins.mjs +++ b/dev-packages/rollup-utils/plugins/npmPlugins.mjs @@ -6,6 +6,7 @@ * Replace plugin docs: https://github.com/rollup/plugins/tree/master/packages/replace */ +import fs from 'node:fs'; import json from '@rollup/plugin-json'; import replace from '@rollup/plugin-replace'; import esbuild from 'rollup-plugin-esbuild'; @@ -52,6 +53,63 @@ export function makeJsonPlugin() { return json(); } +/** + * With `preserveModules: true`, Rollup emits bundled dependencies that resolve from `node_modules` + * (e.g. a vendored devDependency like `@sentry/conventions`) into an output directory literally + * named `node_modules`. That breaks at runtime: Node treats `node_modules` as a package-scope + * boundary, so the `{"type":"module"}` marker we emit at the ESM build root does NOT apply inside it. + * Node then loads our ESM `.js` files there as CommonJS — named imports fail with + * "is a CommonJS module" — and Vitest externalizes any `/node_modules/` path and `require()`s it, + * which additionally fails on Node 18 (no `require(esm)` support). + * + * This plugin relocates those modules to a plain `_external/` directory (still inside `src` so + * `preserveModules` roots them under the build root). Rollup rewrites every import specifier to the + * new location automatically, so the scope marker applies and the heuristics no longer fire. + */ +export function makeRelocateVendoredModulesPlugin() { + // Rollup module ids and `@rollup/plugin-node-resolve` results use OS-native separators, i.e. + // backslashes on Windows. Normalize every path we inspect to forward slashes before matching, + // otherwise `/node_modules/` never matches on Windows and the relocation silently no-ops. + const toPosix = p => p.split('\\').join('/'); + const cwd = toPosix(process.cwd()); + const NODE_MODULES = '/node_modules/'; + const VENDOR_DIR = `${cwd}/src/_external/`; + + return { + name: 'relocate-vendored-modules', + async resolveId(source, importer, options) { + // Ids we've already relocated resolve to themselves. + if (toPosix(source).startsWith(VENDOR_DIR)) return source; + + // When the importer is itself a relocated module, resolve its imports (which are often + // relative, e.g. `./chunk.js`) against the REAL on-disk location so sibling files inside the + // vendored package are found — then relocate those too, preserving the package's structure. + const realImporter = + importer && toPosix(importer).startsWith(VENDOR_DIR) + ? this.getModuleInfo(importer)?.meta?.vendoredFrom + : importer; + + const resolved = await this.resolve(source, realImporter, { ...options, skipSelf: true }); + // Leave external deps and unresolved ids untouched. + if (!resolved || resolved.external) return resolved; + + const resolvedId = toPosix(resolved.id); + const idx = resolvedId.lastIndexOf(NODE_MODULES); + if (idx === -1) return resolved; + + // Map `/node_modules//<...>` -> `/src/_external//<...>` and remember + // the real on-disk path so `load()` can read the original source. + const rest = resolvedId.slice(idx + NODE_MODULES.length); + return { id: `${VENDOR_DIR}${rest}`, meta: { vendoredFrom: resolved.id } }; + }, + load(id) { + if (!toPosix(id).startsWith(VENDOR_DIR)) return null; + const vendoredFrom = this.getModuleInfo(id)?.meta?.vendoredFrom; + return vendoredFrom ? fs.readFileSync(vendoredFrom, 'utf-8') : null; + }, + }; +} + /** * Create a plugin which can be used to pause the build process at the given hook. * diff --git a/packages/typescript/tsconfig.json b/packages/typescript/tsconfig.json index 2e70d1a0c493..b30d3f8887db 100644 --- a/packages/typescript/tsconfig.json +++ b/packages/typescript/tsconfig.json @@ -18,6 +18,9 @@ "strict": true, "strictBindCallApply": false, "target": "es2020", - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "paths": { + "@sentry/conventions/attributes": ["../../node_modules/@sentry/conventions/dist/attributes"] + } } }