Skip to content

Commit 2ce0ea1

Browse files
s1gr1dhalillusion
andauthored
fix(v10/nuxt): Windows file:// for import-in-the-middle hook and isAbsolute for C:\ (#24026)
Backport of #23653 --------- Co-authored-by: halillusion <halillusion@gmail.com>
1 parent 3e0eccc commit 2ce0ea1

8 files changed

Lines changed: 373 additions & 30 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66

7+
Work in this release was contributed by @halillusion. Thank you for your contribution!
8+
79
## 10.73.0
810

911
### Important Changes

‎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: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { existsSync } from 'node:fs';
2+
import { basename } from 'node:path';
3+
import { pathToFileURL } from 'node:url';
24
import { createResolver } from '@nuxt/kit';
35
import { debug } from '@sentry/core';
46
import * as fs from 'fs';
@@ -14,10 +16,21 @@ import {
1416
SENTRY_REEXPORTED_FUNCTIONS,
1517
SENTRY_WRAPPED_ENTRY,
1618
SENTRY_WRAPPED_FUNCTIONS,
19+
toResolvablePath,
1720
} from './utils';
1821

1922
const SERVER_CONFIG_FILENAME = 'sentry.server.config';
2023

24+
const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts'];
25+
26+
function isServerConfigFile(sourcePath: string, resolvedPath: string): boolean {
27+
if (sourcePath === resolvedPath) {
28+
return true;
29+
}
30+
const name = basename(sourcePath);
31+
return name === SERVER_CONFIG_FILENAME || CONFIG_EXTENSIONS.some(ext => name === `${SERVER_CONFIG_FILENAME}${ext}`);
32+
}
33+
2134
/**
2235
* 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.
2336
*
@@ -115,7 +128,7 @@ export function addDynamicImportEntryFileWrapper(
115128

116129
nitro.options.rollupConfig.plugins.push(
117130
wrapEntryWithDynamicImport({
118-
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`),
131+
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(serverConfigFile),
119132
experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions,
120133
}),
121134
);
@@ -131,7 +144,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
131144
name: 'rollup-plugin-inject-sentry-server-config',
132145

133146
buildStart() {
134-
const configPath = createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`);
147+
const configPath = createResolver(nitro.options.rootDir).resolve(serverConfigFile);
135148

136149
if (!existsSync(configPath)) {
137150
if (isDebug) {
@@ -151,7 +164,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
151164
resolveId(source) {
152165
if (source.startsWith(filePrefix)) {
153166
const originalFilePath = source.replace(filePrefix, '');
154-
const configPath = createResolver(nitro.options.rootDir).resolve(`/${originalFilePath}`);
167+
const configPath = createResolver(nitro.options.rootDir).resolve(originalFilePath);
155168

156169
return { id: configPath };
157170
}
@@ -164,8 +177,10 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
164177
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
165178
* by using a regular `import` and load the server after that.
166179
* This also works with serverless `handler` functions, as it re-exports the `handler`.
180+
*
181+
* Only exported for testing.
167182
*/
168-
function wrapEntryWithDynamicImport({
183+
export function wrapEntryWithDynamicImport({
169184
resolvedSentryConfigPath,
170185
experimental_entrypointWrappedFunctions,
171186
debug,
@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
183198
return {
184199
name: 'sentry-wrap-entry-with-dynamic-import',
185200
async resolveId(source, importer, options) {
186-
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
187-
return { id: source, moduleSideEffects: true };
201+
// `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows paths,
202+
// but Rollup's resolver only understands filesystem paths.
203+
const resolvable = toResolvablePath(source);
204+
if (!resolvable) {
205+
return null;
206+
}
207+
const { path: normalizedSource, wasFileUrl } = resolvable;
208+
209+
if (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
210+
return { id: normalizedSource, moduleSideEffects: true };
188211
}
189212

190213
if (source === 'import-in-the-middle/hook.mjs') {
@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
195218
return { id: source, moduleSideEffects: true, external: true };
196219
}
197220

198-
if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
199-
const resolution = await this.resolve(source, importer, options);
221+
if (
222+
options.isEntry &&
223+
normalizedSource.includes('.mjs') &&
224+
!normalizedSource.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
225+
) {
226+
const resolution = await this.resolve(normalizedSource, importer, options);
200227

201228
// If it cannot be resolved or is external, just return it so that Rollup can display an error
202229
if (!resolution || resolution?.external) return resolution;
@@ -220,24 +247,36 @@ function wrapEntryWithDynamicImport({
220247
)
221248
.concat(QUERY_END_INDICATOR)}`;
222249
}
250+
251+
// Pass isEntry:false to avoid re-entering the isEntry branch and double-wrapping
252+
// (normalizedSource strips the SENTRY_WRAPPED_ENTRY query suffix).
253+
if (wasFileUrl) {
254+
const resolved = await this.resolve(normalizedSource, importer, { ...options, isEntry: false });
255+
if (resolved) return resolved;
256+
return { id: normalizedSource };
257+
}
258+
223259
return null;
224260
},
225261
load(id: string) {
226262
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
227263
const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length);
264+
const entryIdUrl = pathToFileURL(entryId).href;
265+
const configUrl = pathToFileURL(resolvedSentryConfigPath).href;
228266

267+
// Use entryIdUrl so Node's runtime ESM loader receives file:// on Windows; Rollup normalizes it in resolveId.
229268
// Mostly useful for serverless `handler` functions
230269
const reExportedFunctions =
231270
id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS)
232-
? constructFunctionReExport(id, entryId)
271+
? constructFunctionReExport(id, entryIdUrl)
233272
: '';
234273

235274
return (
236275
// Regular `import` of the Sentry config
237-
`import ${JSON.stringify(resolvedSentryConfigPath)};\n` +
276+
`import ${JSON.stringify(configUrl)};\n` +
238277
// Dynamic `import()` for the previous, actual entry point.
239278
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
240-
`import(${JSON.stringify(entryId)});\n` +
279+
`import(${JSON.stringify(entryIdUrl)});\n` +
241280
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
242281
"import 'import-in-the-middle/hook.mjs';\n" +
243282
`${reExportedFunctions}\n`

‎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

@@ -204,6 +205,31 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string
204205
);
205206
}
206207

208+
/**
209+
* `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows
210+
* paths (`ERR_UNSUPPORTED_ESM_URL_SCHEME`), but Rollup's resolver only understands
211+
* filesystem paths. Returns `undefined` for a malformed `file://` URL.
212+
*
213+
* Only exported for testing.
214+
*/
215+
export function toResolvablePath(source: string): { path: string; wasFileUrl: boolean } | undefined {
216+
if (!source.startsWith('file://')) {
217+
return { path: source, wasFileUrl: false };
218+
}
219+
if (source === 'file://' || source === 'file:///') {
220+
return undefined;
221+
}
222+
try {
223+
const filePath = fileURLToPath(source);
224+
if (!filePath || filePath === '/' || filePath === '\\') {
225+
return undefined;
226+
}
227+
return { path: filePath, wasFileUrl: true };
228+
} catch {
229+
return undefined;
230+
}
231+
}
232+
207233
/**
208234
* Sets up alias to work around OpenTelemetry's incomplete ESM imports.
209235
* https://github.com/getsentry/sentry-javascript/issues/15204
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { fileURLToPath, pathToFileURL } from 'node:url';
2+
import { describe, expect, it, vi } from 'vitest';
3+
import { wrapEntryWithDynamicImport } from '../../src/vite/addServerConfig';
4+
import {
5+
QUERY_END_INDICATOR,
6+
SENTRY_REEXPORTED_FUNCTIONS,
7+
SENTRY_WRAPPED_ENTRY,
8+
toResolvablePath,
9+
} from '../../src/vite/utils';
10+
11+
const configPath = '/project/sentry.server.config.ts';
12+
const entryPath = '/project/.nuxt/entry.mjs';
13+
14+
describe('toResolvablePath', () => {
15+
it('passes through non-file specifiers', () => {
16+
expect(toResolvablePath('./module')).toEqual({ path: './module', wasFileUrl: false });
17+
expect(toResolvablePath(configPath)).toEqual({ path: configPath, wasFileUrl: false });
18+
});
19+
20+
it('converts file:// URLs to filesystem paths', () => {
21+
const url = pathToFileURL(entryPath).href;
22+
expect(toResolvablePath(url)).toEqual({ path: fileURLToPath(url), wasFileUrl: true });
23+
});
24+
25+
it('returns undefined for malformed file:// URLs', () => {
26+
expect(toResolvablePath('file://')).toBeUndefined();
27+
expect(toResolvablePath('file:///')).toBeUndefined();
28+
expect(toResolvablePath('file://%E0%A4%A')).toBeUndefined();
29+
});
30+
});
31+
32+
describe('wrapEntryWithDynamicImport', () => {
33+
const plugin = wrapEntryWithDynamicImport({
34+
resolvedSentryConfigPath: configPath,
35+
experimental_entrypointWrappedFunctions: ['handler'],
36+
}) as unknown as {
37+
resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise<unknown>;
38+
load: (id: string) => string | null;
39+
};
40+
const { resolveId, load } = plugin;
41+
42+
it('emits file:// URLs from load() so Node resolves them on Windows', () => {
43+
const code = load.call({}, `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`);
44+
45+
expect(code).toContain(`import ${JSON.stringify(pathToFileURL(configPath).href)}`);
46+
expect(code).toContain(`import(${JSON.stringify(pathToFileURL(entryPath).href)})`);
47+
expect(code).not.toContain(`import ${JSON.stringify(configPath)}`);
48+
});
49+
50+
it('uses file:// URLs for re-exported functions', () => {
51+
const id = `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${SENTRY_REEXPORTED_FUNCTIONS}handler${QUERY_END_INDICATOR}`;
52+
const code = load.call({}, id);
53+
54+
expect(code).toContain(`export { handler } from ${JSON.stringify(pathToFileURL(entryPath).href)}`);
55+
});
56+
57+
it('resolves a file:// config URL to a filesystem path with moduleSideEffects', async () => {
58+
const source = pathToFileURL(configPath).href;
59+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, source, undefined, { isEntry: false });
60+
61+
expect(result).toEqual({ id: fileURLToPath(source), moduleSideEffects: true });
62+
});
63+
64+
it('resolves a plain config path without converting it', async () => {
65+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, configPath, undefined, { isEntry: false });
66+
67+
expect(result).toEqual({ id: configPath, moduleSideEffects: true });
68+
});
69+
70+
it('does not mark backup or test config files as the Sentry server config', async () => {
71+
const backupPath = '/project/sentry.server.config.backup.ts';
72+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, backupPath, undefined, { isEntry: false });
73+
74+
expect(result).toBeNull();
75+
});
76+
77+
it('resolves file:// entry specifiers without re-entering the entry branch', async () => {
78+
const source = pathToFileURL(entryPath).href;
79+
const fakeResolve = vi.fn(async () => ({ id: 'resolved-id', external: false }));
80+
const result = await resolveId.call({ resolve: fakeResolve, load: vi.fn() }, source, undefined, { isEntry: false });
81+
82+
expect(fakeResolve).toHaveBeenCalledWith(
83+
fileURLToPath(source),
84+
undefined,
85+
expect.objectContaining({ isEntry: false }),
86+
);
87+
expect(result).toEqual({ id: 'resolved-id', external: false });
88+
});
89+
90+
it('returns null for malformed file:// URLs', async () => {
91+
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, 'file://', undefined, { isEntry: false });
92+
93+
expect(result).toBeNull();
94+
});
95+
96+
it('wraps the entry with the dynamic-import query suffix', async () => {
97+
const fakeResolve = vi.fn(async () => ({ id: entryPath, external: false }));
98+
const fakeLoad = vi.fn(async () => ({ exportedBindings: { '.': ['handler'] }, moduleSideEffects: false }));
99+
const result = await resolveId.call({ resolve: fakeResolve, load: fakeLoad }, entryPath, undefined, {
100+
isEntry: true,
101+
});
102+
103+
expect(result).toContain(SENTRY_WRAPPED_ENTRY);
104+
expect(result).toContain('?sentry-query-wrapped-functions=handler');
105+
expect(result?.startsWith('\0raw')).toBe(true);
106+
});
107+
});

‎packages/nuxt/test/vite/utils.test.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Nuxt } from '@nuxt/schema';
22
import * as fs from 'fs';
3+
import * as path from 'path';
34
import { afterEach, describe, expect, it, vi } from 'vitest';
45
import {
56
addOTelCommonJSImportAlias,
@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
3637
});
3738

3839
const result = await findDefaultSdkInitFile('server');
39-
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
40+
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
4041
},
4142
);
4243

@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
4849
});
4950

5051
const result = await findDefaultSdkInitFile('client');
51-
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
52+
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
5253
},
5354
);
5455

@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
6768
configDir: '~/config',
6869
});
6970

70-
expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
71+
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
7172
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
7273
},
7374
);
@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
8788
configDir: '~/config',
8889
});
8990

90-
expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
91+
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
9192
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
9293
},
9394
);
@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
138139
} as unknown as Nuxt;
139140

140141
const result = await findDefaultSdkInitFile('client', nuxtMock);
141-
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
142+
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
142143
});
143144

144145
it('should return the latest layer config file path if server config exists', async () => {
@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
164165
} as unknown as Nuxt;
165166

166167
const result = await findDefaultSdkInitFile('server', nuxtMock);
167-
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
168+
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
168169
});
169170

170171
it('should return the latest layer config file path if client config exists in former layer', async () => {
171172
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
172-
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
173+
return (
174+
!(filePath instanceof URL) &&
175+
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
176+
);
173177
});
174178

175179
const nuxtMock = {
@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
186190
} as unknown as Nuxt;
187191

188192
const result = await findDefaultSdkInitFile('client', nuxtMock);
189-
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
193+
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
190194
});
191195
});
192196

0 commit comments

Comments
 (0)