Skip to content

Commit d97a57b

Browse files
halillusions1gr1d
andauthored
fix(nuxt): Windows file:// for import-in-the-middle hook and isAbsolute for C:\ (#23653)
- [x] tests added if needed - [x] yarn lint passes - [ ] no related issue, auto-link is fine Windows fix for Nuxt dev on Node 24. On Windows `npm run dev` was failing with `ERR_UNSUPPORTED_ESM_URL_SCHEME` / `Received protocol 'c:'` because `.nuxt/dev/index.mjs` generated `import 'C:\...'` instead of `file:///C:/...`. Same `isExternal` check in `rollup.module.config.mjs` treated `C:\` as external and broke the build. Changed `addServerConfig.ts` (and the solidstart copy) to emit `pathToFileURL(...).href` and fixed `isExternal` to use `isAbsolute()` so Windows absolute paths work. Also handles POSIX fine. Verified with `yarn nx run @sentry/nuxt:build:dev` -> now passes, and `pathToFileURL` gives `file:///C:/...` on Windows. Closes #XXXX --------- Co-authored-by: Sigrid <32902192+s1gr1d@users.noreply.github.com>
1 parent 80a776c commit d97a57b

7 files changed

Lines changed: 374 additions & 34 deletions

File tree

packages/nuxt/rollup.module.config.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { readdirSync } from 'node:fs';
2-
import { join } from 'node:path';
2+
import { isAbsolute, join } from 'node:path';
33
import esbuild from 'rollup-plugin-esbuild';
44

55
// The Nuxt module ships two kinds of output that live side by side in `build/module`:
@@ -11,7 +11,7 @@ import esbuild from 'rollup-plugin-esbuild';
1111

1212
// Anything that isn't a relative path is provided by the consuming app or Node at runtime
1313
// (this covers `@sentry/*`, `nuxt/app`, `#imports`, node builtins), so it stays external.
14-
const isExternal = id => !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('\0');
14+
const isExternal = id => !id.startsWith('.') && !isAbsolute(id) && !id.startsWith('\0');
1515

1616
const transpile = esbuild({
1717
target: 'es2020',

packages/nuxt/src/vite/addServerConfig.ts

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync } from 'node:fs';
2+
import { basename } from 'node:path';
23
import { pathToFileURL } from 'node:url';
34
import { addTemplate, createResolver } from '@nuxt/kit';
45
import type { Nuxt } from '@nuxt/schema';
@@ -20,6 +21,7 @@ import {
2021
SENTRY_WRAPPED_FUNCTIONS,
2122
SERVER_CONFIG_FILENAME,
2223
toImportSpecifier,
24+
toResolvablePath,
2325
} from './utils';
2426

2527
/** Path of the generated dev-mode config file, relative to the Nuxt build directory. */
@@ -31,7 +33,7 @@ export const DEV_SERVER_CONFIG_PATH = `dev/${SERVER_CONFIG_FILENAME}.mjs`;
3133
* In dev-mode, Nitro v3 has no server bundle to emit into, so Node loads the server config file as it is written.
3234
*/
3335
export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): void {
34-
const configPath = createResolver(nuxt.options.rootDir).resolve(`/${serverConfigFile}`);
36+
const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile);
3537
const importSpecifier = toImportSpecifier(
3638
nuxt.options.rootDir,
3739
path.join(nuxt.options.buildDir, DEV_SERVER_CONFIG_PATH),
@@ -59,6 +61,15 @@ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): vo
5961
].join('\n'),
6062
});
6163
}
64+
const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts'];
65+
66+
function isServerConfigFile(sourcePath: string, resolvedPath: string): boolean {
67+
if (sourcePath === resolvedPath) {
68+
return true;
69+
}
70+
const name = basename(sourcePath);
71+
return name === SERVER_CONFIG_FILENAME || CONFIG_EXTENSIONS.some(ext => name === `${SERVER_CONFIG_FILENAME}${ext}`);
72+
}
6273

6374
/**
6475
* Adds the `sentry.server.config.ts` file as `sentry.server.config.mjs` to the `.output` directory to be able to reference this file in the node --import option.
@@ -157,7 +168,7 @@ export function addDynamicImportEntryFileWrapper(
157168

158169
nitro.options.rollupConfig.plugins.push(
159170
wrapEntryWithDynamicImport({
160-
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`),
171+
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(serverConfigFile),
161172
experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions,
162173
}),
163174
);
@@ -173,7 +184,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
173184
name: 'rollup-plugin-inject-sentry-server-config',
174185

175186
buildStart() {
176-
const configPath = createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`);
187+
const configPath = createResolver(nitro.options.rootDir).resolve(serverConfigFile);
177188

178189
if (!existsSync(configPath)) {
179190
if (isDebug) {
@@ -193,7 +204,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
193204
resolveId(source) {
194205
if (source.startsWith(filePrefix)) {
195206
const originalFilePath = source.replace(filePrefix, '');
196-
const configPath = createResolver(nitro.options.rootDir).resolve(`/${originalFilePath}`);
207+
const configPath = createResolver(nitro.options.rootDir).resolve(originalFilePath);
197208

198209
return { id: configPath };
199210
}
@@ -206,8 +217,10 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
206217
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
207218
* by using a regular `import` and load the server after that.
208219
* This also works with serverless `handler` functions, as it re-exports the `handler`.
220+
*
221+
* Only exported for testing.
209222
*/
210-
function wrapEntryWithDynamicImport({
223+
export function wrapEntryWithDynamicImport({
211224
resolvedSentryConfigPath,
212225
experimental_entrypointWrappedFunctions,
213226
debug,
@@ -225,12 +238,24 @@ function wrapEntryWithDynamicImport({
225238
return {
226239
name: 'sentry-wrap-entry-with-dynamic-import',
227240
async resolveId(source, importer, options) {
228-
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
229-
return { id: source, moduleSideEffects: true };
241+
// `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows paths,
242+
// but Rollup's resolver only understands filesystem paths.
243+
const resolvable = toResolvablePath(source);
244+
if (!resolvable) {
245+
return null;
230246
}
247+
const { path: normalizedSource, wasFileUrl } = resolvable;
231248

232-
if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
233-
const resolution = await this.resolve(source, importer, options);
249+
if (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
250+
return { id: normalizedSource, moduleSideEffects: true };
251+
}
252+
253+
if (
254+
options.isEntry &&
255+
normalizedSource.includes('.mjs') &&
256+
!normalizedSource.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
257+
) {
258+
const resolution = await this.resolve(normalizedSource, importer, options);
234259

235260
// If it cannot be resolved or is external, just return it so that Rollup can display an error
236261
if (!resolution || resolution?.external) return resolution;
@@ -254,24 +279,36 @@ function wrapEntryWithDynamicImport({
254279
)
255280
.concat(QUERY_END_INDICATOR)}`;
256281
}
282+
283+
// Pass isEntry:false to avoid re-entering the isEntry branch and double-wrapping
284+
// (normalizedSource strips the SENTRY_WRAPPED_ENTRY query suffix).
285+
if (wasFileUrl) {
286+
const resolved = await this.resolve(normalizedSource, importer, { ...options, isEntry: false });
287+
if (resolved) return resolved;
288+
return { id: normalizedSource };
289+
}
290+
257291
return null;
258292
},
259293
load(id: string) {
260294
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
261295
const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length);
296+
const entryIdUrl = pathToFileURL(entryId).href;
297+
const configUrl = pathToFileURL(resolvedSentryConfigPath).href;
262298

299+
// Use entryIdUrl so Node's runtime ESM loader receives file:// on Windows; Rollup normalizes it in resolveId.
263300
// Mostly useful for serverless `handler` functions
264301
const reExportedFunctions =
265302
id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS)
266-
? constructFunctionReExport(id, entryId)
303+
? constructFunctionReExport(id, entryIdUrl)
267304
: '';
268305

269306
return (
270307
// Regular `import` of the Sentry config
271-
`import ${JSON.stringify(resolvedSentryConfigPath)};\n` +
308+
`import ${JSON.stringify(configUrl)};\n` +
272309
// Dynamic `import()` for the previous, actual entry point.
273310
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
274-
`import(${JSON.stringify(entryId)});\n` +
311+
`import(${JSON.stringify(entryIdUrl)});\n` +
275312
`${reExportedFunctions}\n`
276313
);
277314
}

packages/nuxt/src/vite/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Nuxt } from '@nuxt/schema';
22
import { consoleSandbox } from '@sentry/core';
33
import * as fs from 'fs';
44
import * as path from 'path';
5+
import { fileURLToPath } from 'node:url';
56
import type { SentryNuxtModuleOptions } from '../common/types';
67
import { resolvePath } from '@nuxt/kit';
78

@@ -199,6 +200,31 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string
199200
);
200201
}
201202

203+
/**
204+
* `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows
205+
* paths (`ERR_UNSUPPORTED_ESM_URL_SCHEME`), but Rollup's resolver only understands
206+
* filesystem paths. Returns `undefined` for a malformed `file://` URL.
207+
*
208+
* Only exported for testing.
209+
*/
210+
export function toResolvablePath(source: string): { path: string; wasFileUrl: boolean } | undefined {
211+
if (!source.startsWith('file://')) {
212+
return { path: source, wasFileUrl: false };
213+
}
214+
if (source === 'file://' || source === 'file:///') {
215+
return undefined;
216+
}
217+
try {
218+
const filePath = fileURLToPath(source);
219+
if (!filePath || filePath === '/' || filePath === '\\') {
220+
return undefined;
221+
}
222+
return { path: filePath, wasFileUrl: true };
223+
} catch {
224+
return undefined;
225+
}
226+
}
227+
202228
/**
203229
* Sets up alias to work around OpenTelemetry's incomplete ESM imports.
204230
* https://github.com/getsentry/sentry-javascript/issues/15204

packages/nuxt/test/vite/addServerConfig.test.ts

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,116 @@
11
import type { Nuxt } from '@nuxt/schema';
2+
import { fileURLToPath, pathToFileURL } from 'node:url';
23
import * as path from 'path';
34
import { beforeEach, describe, expect, it, vi } from 'vitest';
4-
import { addDevServerConfigFile, DEV_SERVER_CONFIG_PATH } from '../../src/vite/addServerConfig';
5+
import {
6+
addDevServerConfigFile,
7+
DEV_SERVER_CONFIG_PATH,
8+
wrapEntryWithDynamicImport,
9+
} from '../../src/vite/addServerConfig';
10+
import {
11+
QUERY_END_INDICATOR,
12+
SENTRY_REEXPORTED_FUNCTIONS,
13+
SENTRY_WRAPPED_ENTRY,
14+
toResolvablePath,
15+
} from '../../src/vite/utils';
16+
17+
const configPath = '/project/sentry.server.config.ts';
18+
const entryPath = '/project/.nuxt/entry.mjs';
19+
20+
describe('toResolvablePath', () => {
21+
it('passes through non-file specifiers', () => {
22+
expect(toResolvablePath('./module')).toEqual({ path: './module', wasFileUrl: false });
23+
expect(toResolvablePath(configPath)).toEqual({ path: configPath, wasFileUrl: false });
24+
});
25+
26+
it('converts file:// URLs to filesystem paths', () => {
27+
const url = pathToFileURL(entryPath).href;
28+
expect(toResolvablePath(url)).toEqual({ path: fileURLToPath(url), wasFileUrl: true });
29+
});
30+
31+
it('returns undefined for malformed file:// URLs', () => {
32+
expect(toResolvablePath('file://')).toBeUndefined();
33+
expect(toResolvablePath('file:///')).toBeUndefined();
34+
expect(toResolvablePath('file://%E0%A4%A')).toBeUndefined();
35+
});
36+
});
37+
38+
describe('wrapEntryWithDynamicImport', () => {
39+
const plugin = wrapEntryWithDynamicImport({
40+
resolvedSentryConfigPath: configPath,
41+
experimental_entrypointWrappedFunctions: ['handler'],
42+
}) as unknown as {
43+
resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise<unknown>;
44+
load: (id: string) => string | null;
45+
};
46+
const { resolveId, load } = plugin;
47+
48+
it('emits file:// URLs from load() so Node resolves them on Windows', () => {
49+
const code = load.call({}, `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`);
50+
51+
expect(code).toContain(`import ${JSON.stringify(pathToFileURL(configPath).href)}`);
52+
expect(code).toContain(`import(${JSON.stringify(pathToFileURL(entryPath).href)})`);
53+
expect(code).not.toContain(`import ${JSON.stringify(configPath)}`);
54+
});
55+
56+
it('uses file:// URLs for re-exported functions', () => {
57+
const id = `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${SENTRY_REEXPORTED_FUNCTIONS}handler${QUERY_END_INDICATOR}`;
58+
const code = load.call({}, id);
59+
60+
expect(code).toContain(`export { handler } from ${JSON.stringify(pathToFileURL(entryPath).href)}`);
61+
});
62+
63+
it('resolves a file:// config URL to a filesystem path with moduleSideEffects', async () => {
64+
const source = pathToFileURL(configPath).href;
65+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, source, undefined, { isEntry: false });
66+
67+
expect(result).toEqual({ id: fileURLToPath(source), moduleSideEffects: true });
68+
});
69+
70+
it('resolves a plain config path without converting it', async () => {
71+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, configPath, undefined, { isEntry: false });
72+
73+
expect(result).toEqual({ id: configPath, moduleSideEffects: true });
74+
});
75+
76+
it('does not mark backup or test config files as the Sentry server config', async () => {
77+
const backupPath = '/project/sentry.server.config.backup.ts';
78+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, backupPath, undefined, { isEntry: false });
79+
80+
expect(result).toBeNull();
81+
});
82+
83+
it('resolves file:// entry specifiers without re-entering the entry branch', async () => {
84+
const source = pathToFileURL(entryPath).href;
85+
const fakeResolve = vi.fn(async () => ({ id: 'resolved-id', external: false }));
86+
const result = await resolveId.call({ resolve: fakeResolve, load: vi.fn() }, source, undefined, { isEntry: false });
87+
88+
expect(fakeResolve).toHaveBeenCalledWith(
89+
fileURLToPath(source),
90+
undefined,
91+
expect.objectContaining({ isEntry: false }),
92+
);
93+
expect(result).toEqual({ id: 'resolved-id', external: false });
94+
});
95+
96+
it('returns null for malformed file:// URLs', async () => {
97+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, 'file://', undefined, { isEntry: false });
98+
99+
expect(result).toBeNull();
100+
});
101+
102+
it('wraps the entry with the dynamic-import query suffix', async () => {
103+
const fakeResolve = vi.fn(async () => ({ id: entryPath, external: false }));
104+
const fakeLoad = vi.fn(async () => ({ exportedBindings: { '.': ['handler'] }, moduleSideEffects: false }));
105+
const result = await resolveId.call({ resolve: fakeResolve, load: fakeLoad }, entryPath, undefined, {
106+
isEntry: true,
107+
});
108+
109+
expect(result).toContain(SENTRY_WRAPPED_ENTRY);
110+
expect(result).toContain('?sentry-query-wrapped-functions=handler');
111+
expect(result?.startsWith('\0raw')).toBe(true);
112+
});
113+
});
5114

6115
const addTemplateMock = vi.hoisted(() => vi.fn());
7116

@@ -40,7 +149,7 @@ describe('addDevServerConfigFile', () => {
40149
});
41150

42151
it('imports the user config as a file URL so Node can load it directly', () => {
43-
expect(generate(APP_CONFIG)).toContain(`await import("file://${APP_CONFIG}")`);
152+
expect(generate(APP_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(APP_CONFIG).href)})`);
44153
});
45154

46155
it('sets the dev flag before importing the config', () => {
@@ -64,7 +173,7 @@ describe('addDevServerConfigFile', () => {
64173

65174
describe('when the config comes from a layer outside the project root', () => {
66175
it('imports the config from the layer it belongs to', () => {
67-
expect(generate(LAYER_CONFIG)).toContain(`await import("file://${LAYER_CONFIG}")`);
176+
expect(generate(LAYER_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(LAYER_CONFIG).href)})`);
68177
});
69178

70179
it('keeps the preload path relative to the project root', () => {

0 commit comments

Comments
 (0)