Skip to content

Commit 768ab88

Browse files
committed
fix(nuxt): Chain Nuxt 5 server source maps to the original sources
Nitro 3 bundles the output of Nuxt 5's ssr build (.nuxt/vite/services/ssr) again, but loads it without the .map files next to it. So server stack frames stop at that output instead of the .vue and .ts files. Nuxt's own SourcemapPreserverPlugin does not chain the maps when Nitro builds as a Vite environment. Until Nuxt fixes this, return each service chunk together with its map from a load hook in the Nitro build. Fixes #25323
1 parent b8fddd7 commit 768ab88

8 files changed

Lines changed: 146 additions & 19 deletions

File tree

‎dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts‎

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getAssembleRequests,
1313
getChunkUploadPosts,
1414
getDebugIdPairs,
15+
getSourcemapSources,
1516
getSourcemaps,
1617
loadMockServerResults,
1718
startNodeServer,
@@ -234,14 +235,18 @@ for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER
234235
const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps);
235236
assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map');
236237

237-
// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output
238-
// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach
239-
// `.vue` files. Assert this when the server maps reach the original sources:
240-
// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
241-
// const serverSources = sourcemaps
242-
// .filter(map => map.debugId && serverDebugIds.has(map.debugId))
243-
// .flatMap(map => getSourcemapSources(map.sourcemap));
244-
// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue')));
238+
if (!isStaticBuild) {
239+
// Nitro bundles the output of the SSR build (`.nuxt/vite/services/ssr`) again, so server frames reach `.vue`
240+
// files only through the maps of that build.
241+
const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT }));
242+
const serverSources = sourcemaps
243+
.filter(map => map.debugId && serverDebugIds.has(map.debugId))
244+
.flatMap(map => getSourcemapSources(map.sourcemap));
245+
assert.ok(
246+
serverSources.some(source => source.endsWith('app/pages/index.vue')),
247+
`Expected the server source maps to resolve to app/pages/index.vue, got: ${serverSources.join(', ')}`,
248+
);
249+
}
245250
console.log('');
246251

247252
// --- What the build leaves behind in the client output ---
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
3+
import type { Plugin } from 'rollup';
4+
import { isInDirectory } from './utils';
5+
6+
/**
7+
* Nitro 3 bundles the output of its Vite services, like Nuxt 5's `ssr` build, again. It loads that output without the
8+
* `.map` files next to it, so the server source maps stop at the output instead of the original source files.
9+
* Returning the map from `load` lets the bundler chain both maps.
10+
*
11+
* Nuxt's own `SourcemapPreserverPlugin` does this only when Nitro does not build as a Vite environment.
12+
*/
13+
export function serviceSourceMapsPlugin(buildDir: string): Plugin {
14+
const servicesDir = path.join(buildDir, 'vite', 'services');
15+
16+
return {
17+
name: 'sentry-nuxt-service-source-maps',
18+
async load(id) {
19+
const mapFile = `${id}.map`;
20+
// Rolldown emits no map for a chunk without mappable source.
21+
if (!isInDirectory(id, servicesDir) || !fs.existsSync(mapFile)) {
22+
return null;
23+
}
24+
25+
const [code, map] = await Promise.all([fs.promises.readFile(id, 'utf8'), fs.promises.readFile(mapFile, 'utf8')]);
26+
return { code, map };
27+
},
28+
};
29+
}

‎packages/nuxt/src/vite/sourceMapDeletion.ts‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core';
22
import * as fs from 'node:fs';
3-
import * as path from 'node:path';
3+
import { isInDirectory } from './utils';
44

55
export function withoutSourceMapDeletion(options: Options): Options {
66
return {
@@ -64,13 +64,3 @@ function logDebug(options: Options, ...messages: unknown[]): void {
6464
console.log('[Sentry]', ...messages);
6565
}
6666
}
67-
68-
function isInDirectory(file: string, directory: string): boolean {
69-
const relativePath = path.relative(directory, file);
70-
return (
71-
relativePath !== '' &&
72-
relativePath !== '..' &&
73-
!relativePath.startsWith(`..${path.sep}`) &&
74-
!path.isAbsolute(relativePath)
75-
);
76-
}

‎packages/nuxt/src/vite/sourceMaps.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Plugin } from 'vite';
77
import type { SentryNuxtModuleOptions } from '../common/types';
88
import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion';
99
import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin';
10+
import { serviceSourceMapsPlugin } from './serviceSourceMaps';
1011

1112
/**
1213
* Whether the user enabled (true, 'hidden', 'inline') or disabled (false) source maps
@@ -107,6 +108,7 @@ export function setupSourceMaps(
107108
// Add Sentry plugin
108109
// Runs only on server-side (Nitro)
109110
nitroConfig.rollupConfig.plugins.push(
111+
serviceSourceMapsPlugin(nuxt.options.buildDir),
110112
sentryRollupPlugin(withoutSourceMapDeletion(getPluginOptions(moduleOptions, shouldDeleteFilesFallback))),
111113
);
112114
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@ export function toImportSpecifier(fromDir: string, filePath: string): string {
6666
return `./${path.relative(fromDir, filePath).split(/[\\/]/).join('/')}`;
6767
}
6868

69+
/** Whether `file` is inside `directory`. `directory` itself does not count. */
70+
export function isInDirectory(file: string, directory: string): boolean {
71+
const relativePath = path.relative(directory, file);
72+
return (
73+
relativePath !== '' &&
74+
relativePath !== '..' &&
75+
!relativePath.startsWith(`..${path.sep}`) &&
76+
!path.isAbsolute(relativePath)
77+
);
78+
}
79+
6980
/**
7081
* Extracts the filename from a node command with a path.
7182
*/
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as fs from 'fs';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
import type { PluginContext } from 'rollup';
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
6+
import { serviceSourceMapsPlugin } from '../../src/vite/serviceSourceMaps';
7+
8+
const CODE = 'export default 1;\n';
9+
const MAP = '{"version":3,"sources":["../../../../app/pages/index.vue"],"mappings":"AAAA"}';
10+
11+
let buildDir: string;
12+
13+
function writeFile(file: string, content: string): string {
14+
fs.mkdirSync(path.dirname(file), { recursive: true });
15+
fs.writeFileSync(file, content);
16+
return file;
17+
}
18+
19+
function load(id: string): unknown {
20+
const { load } = serviceSourceMapsPlugin(buildDir);
21+
if (typeof load !== 'function') {
22+
throw new Error('Expected `load` to be a function');
23+
}
24+
return load.call({} as PluginContext, id);
25+
}
26+
27+
describe('serviceSourceMapsPlugin', () => {
28+
beforeEach(() => {
29+
buildDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-service-source-maps-'));
30+
});
31+
32+
afterEach(() => {
33+
fs.rmSync(buildDir, { recursive: true, force: true });
34+
});
35+
36+
it('loads a service chunk together with its source map', async () => {
37+
const chunk = writeFile(path.join(buildDir, 'vite', 'services', 'ssr', '_nuxt', 'pages.js'), CODE);
38+
writeFile(`${chunk}.map`, MAP);
39+
40+
expect(await load(chunk)).toEqual({ code: CODE, map: MAP });
41+
});
42+
43+
it('leaves a service chunk without a source map to the default loader', async () => {
44+
const chunk = writeFile(path.join(buildDir, 'vite', 'services', 'ssr', '_nuxt', 'export-helper.js'), CODE);
45+
46+
expect(await load(chunk)).toBeNull();
47+
});
48+
49+
it('leaves files outside the service output to the default loader', async () => {
50+
const file = writeFile(path.join(buildDir, 'sentry-runtime-flags.mjs'), CODE);
51+
writeFile(`${file}.map`, MAP);
52+
53+
expect(await load(file)).toBeNull();
54+
});
55+
});

‎packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ function createMockNuxt(options: {
5454
_prepare: options._prepare ?? false,
5555
dev: options.dev ?? false,
5656
sourcemap: options.sourcemap ?? { server: undefined, client: undefined },
57+
buildDir: path.join(os.tmpdir(), '.nuxt'),
5758
},
5859
hook: (name: string, callback: HookCallback) => {
5960
hooks[name] = hooks[name] || [];
@@ -355,6 +356,22 @@ describe('setupSourceMaps hooks', () => {
355356
expect(nitroConfig.rollupConfig.plugins.length).toBeGreaterThan(0);
356357
});
357358

359+
it('loads the source maps of the Vite service output in the Nitro build', async () => {
360+
const mockNuxt = createMockNuxt({ _prepare: false, dev: false });
361+
const { mockAddVitePlugin } = createMockAddVitePlugin();
362+
363+
setupSourceMaps({}, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
364+
await mockNuxt.triggerHook('modules:done');
365+
366+
const nitroConfig = { rollupConfig: { plugins: [] as { name: string }[], output: {} }, dev: false };
367+
await mockNuxt.triggerHook('nitro:config', nitroConfig);
368+
369+
expect(nitroConfig.rollupConfig.plugins.map(plugin => plugin.name)).toEqual([
370+
'sentry-nuxt-service-source-maps',
371+
'sentry-rollup-plugin',
372+
]);
373+
});
374+
358375
it.each([
359376
{
360377
label: 'prepare mode',

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
extractFunctionReexportQueryParameters,
1010
findDefaultSdkInitFile,
1111
getFilenameFromNodeStartCommand,
12+
isInDirectory,
1213
QUERY_END_INDICATOR,
1314
removeSentryQueryFromPath,
1415
SENTRY_REEXPORTED_FUNCTIONS,
@@ -203,6 +204,23 @@ describe('toImportSpecifier', () => {
203204
});
204205
});
205206

207+
describe('isInDirectory', () => {
208+
const directory = path.join('/my', 'app', '.nuxt', 'vite', 'services');
209+
210+
it.each([
211+
{ label: 'a file in a subdirectory', file: path.join(directory, 'ssr', 'index.mjs'), expected: true },
212+
{ label: 'the directory itself', file: directory, expected: false },
213+
{
214+
label: 'a sibling directory with the same prefix',
215+
file: `${directory}-old${path.sep}index.mjs`,
216+
expected: false,
217+
},
218+
{ label: 'the parent directory', file: path.dirname(directory), expected: false },
219+
])('returns $expected for $label', ({ file, expected }) => {
220+
expect(isInDirectory(file, directory)).toBe(expected);
221+
});
222+
});
223+
206224
describe('getFilenameFromPath', () => {
207225
it('should return the filename from a simple path', () => {
208226
const path = 'node ./server/index.mjs';

0 commit comments

Comments
 (0)