Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/nuxt/src/vite/sourceMapDeletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@ import type { Nitro } from 'nitropack';
import * as fs from 'node:fs';
import * as path from 'node:path';

/** Nitro's output directories, which depend on the Nitro preset, e.g. `dist` for the public files on Netlify. */
type NitroOutputDirs = { publicDir: string; serverDir: string };

/** The sides whose source maps only exist because Sentry enabled them, and where Nitro writes them once it is set up. */
export type DeletionFallback = { client: boolean; server: boolean; outputDirs?: NitroOutputDirs };

/**
* Globs for the source maps in Nitro's output directories of the sides whose source maps only exist because Sentry
* enabled them. Empty until Nitro is set up.
*/
export function getFallbackFilesToDelete(fallback: DeletionFallback | undefined): string[] {
if (!fallback?.outputDirs) {
return [];
}

const { client, server, outputDirs } = fallback;
const { publicDir, serverDir } = outputDirs;
return [
...(client ? getPublicSourceMapGlobs(publicDir, server ? undefined : serverDir) : []),
...(server ? [`${toGlobPath(serverDir)}/**/*.map`] : []),
];
}

/** Some presets write the server into the public dir, e.g. Cloudflare Pages into `dist/_worker.js`. */
function getPublicSourceMapGlobs(publicDir: string, keptServerDir: string | undefined): string[] {
const publicGlob = toGlobPath(publicDir);

if (keptServerDir === undefined || !isInDirectory(keptServerDir, publicDir)) {
return [`${publicGlob}/**/*.map`];
}

const [serverDirName] = path.relative(publicDir, keptServerDir).split(path.sep);
return [`${publicGlob}/*.map`, `${publicGlob}/!(${serverDirName})/**/*.map`];
}

/** `filesToDeleteAfterUpload` is matched from the working directory and needs forward slashes, also on Windows. */
function toGlobPath(directory: string): string {
return path.relative(process.cwd(), directory).split(path.sep).join('/') || '.';
}

export function withoutSourceMapDeletion(options: Options): Options {
return {
...options,
Expand Down
23 changes: 11 additions & 12 deletions packages/nuxt/src/vite/sourceMaps.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { Nuxt } from '@nuxt/schema';
import { sentryRollupPlugin, type SentryRollupPluginOptions } from '@sentry/rollup-plugin';
import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plugin';
import type { NitroConfig } from 'nitropack';
import type { Nitro, NitroConfig } from 'nitropack';
import type { Plugin } from 'vite';
import type { SentryNuxtModuleOptions } from '../common/types';
import {
addPublicSourceMapDeletionPlugin,
type DeletionFallback,
getFallbackFilesToDelete,
deleteSourceMapsAfterBuild,
withoutSourceMapDeletion,
} from './sourceMapDeletion';
Expand Down Expand Up @@ -43,7 +45,7 @@ export function setupSourceMaps(
(sourceMapsUploadOptions.enabled ?? true);

// In case we overwrite the source map settings, we default to deleting the files
const shouldDeleteFilesFallback = { client: true, server: true };
const shouldDeleteFilesFallback: DeletionFallback = { client: true, server: true };

nuxt.hook('modules:done', () => {
if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) {
Expand Down Expand Up @@ -112,6 +114,7 @@ export function setupSourceMaps(
}

validateNitroSourceMapSettings(nuxt, nitroConfig, moduleOptions);
shouldDeleteFilesFallback.server &&= nitroConfig.rollupConfig.output?.sourcemap === undefined;

if (isDebug) {
// eslint-disable-next-line no-console
Expand All @@ -126,8 +129,9 @@ export function setupSourceMaps(
}
});

nuxt.hook('nitro:init', nitro => {
nuxt.hook('nitro:init', (nitro: Nitro) => {
if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) {
shouldDeleteFilesFallback.outputDirs = nitro.options.output;
addPublicSourceMapDeletionPlugin(nitro, () => getPluginOptions(moduleOptions, shouldDeleteFilesFallback));
}
});
Expand Down Expand Up @@ -155,18 +159,13 @@ function normalizePath(path: string): string {
// eslint-disable-next-line complexity
export function getPluginOptions(
moduleOptions: SentryNuxtModuleOptions,
shouldDeleteFilesFallback?: { client: boolean; server: boolean },
shouldDeleteFilesFallback?: DeletionFallback,
): SentryVitePluginOptions | SentryRollupPluginOptions {
// eslint-disable-next-line typescript/no-deprecated
const sourceMapsUploadOptions = moduleOptions.sourceMapsUploadOptions || {};

const shouldDeleteFilesAfterUpload = shouldDeleteFilesFallback?.client || shouldDeleteFilesFallback?.server;
const fallbackFilesToDelete = [
...(shouldDeleteFilesFallback?.client ? ['.*/**/public/**/*.map'] : []),
...(shouldDeleteFilesFallback?.server
? ['.*/**/server/**/*.map', '.*/**/output/**/*.map', '.*/**/function/**/*.map']
: []),
];
const fallbackFilesToDelete = getFallbackFilesToDelete(shouldDeleteFilesFallback);
const shouldDeleteFilesAfterUpload = fallbackFilesToDelete.length > 0;

// Check for filesToDeleteAfterUpload in new location first, then deprecated location
const sourcemapsOptions = moduleOptions.sourcemaps || {};
Expand Down Expand Up @@ -232,7 +231,7 @@ export function getPluginOptions(
ignore: sourcemapsOptions.ignore ?? deprecatedSourcemapsOptions.ignore ?? undefined,
filesToDeleteAfterUpload: filesToDeleteAfterUpload
? filesToDeleteAfterUpload
: shouldDeleteFilesFallback?.server || shouldDeleteFilesFallback?.client
: shouldDeleteFilesAfterUpload
? fallbackFilesToDelete
: undefined,
rewriteSources: sourcemapsOptions.rewriteSources ?? normalizePath,
Expand Down
119 changes: 117 additions & 2 deletions packages/nuxt/test/vite/sourceMapDeletion.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { deletePublicSourceMaps } from '../../src/vite/sourceMapDeletion';
import { globFiles } from '@sentry/bundler-plugins/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { deletePublicSourceMaps, getFallbackFilesToDelete } from '../../src/vite/sourceMapDeletion';

const BUILD_FILES = [
'.output/public/_nuxt/entry.DxB3kLmA.js',
Expand Down Expand Up @@ -93,3 +94,117 @@ describe('deletePublicSourceMaps', () => {
expect(remainingFiles()).toEqual(BUILD_FILES);
});
});

/** Output directories of Nitro presets and the source maps a build writes there, relative to the Nuxt root. */
const PRESETS = {
'node-server': {
outputDirs: { publicDir: '.output/public', serverDir: '.output/server' },
clientMaps: ['.output/public/_nuxt/entry.DxB3kLmA.js.map'],
serverMaps: ['.output/server/chunks/nitro/nitro.mjs.map'],
},
netlify: {
outputDirs: { publicDir: 'dist', serverDir: '.netlify/functions-internal/server' },
clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map'],
serverMaps: ['.netlify/functions-internal/server/chunks/nitro/nitro.mjs.map'],
},
vercel: {
outputDirs: { publicDir: '.vercel/output/static', serverDir: '.vercel/output/functions/__fallback.func' },
clientMaps: ['.vercel/output/static/_nuxt/entry.DxB3kLmA.js.map'],
serverMaps: ['.vercel/output/functions/__fallback.func/chunks/nitro/nitro.mjs.map'],
},
'cloudflare-pages': {
outputDirs: { publicDir: 'dist', serverDir: 'dist/_worker.js' },
clientMaps: ['dist/_nuxt/entry.DxB3kLmA.js.map', 'dist/sw.js.map'],
serverMaps: ['dist/_worker.js/chunks/nitro/nitro.mjs.map'],
},
};

type Preset = keyof typeof PRESETS;

/** Never deployed, so Sentry keeps them. */
const UNDEPLOYED_MAPS = [
'.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map',
'node_modules/vue/dist/vue.runtime.esm-bundler.js.map',
];

describe('getFallbackFilesToDelete', () => {
let projectDir: string;

beforeEach(() => {
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-fallback-deletion-'));
vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
});

afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(projectDir, { recursive: true, force: true });
});

function writeFiles(files: string[], nuxtRoot = projectDir): void {
for (const file of files) {
fs.mkdirSync(path.dirname(path.join(nuxtRoot, file)), { recursive: true });
fs.writeFileSync(path.join(nuxtRoot, file), '');
}
}

/** Writes the build output of a preset and returns the files that the fallback globs match, relative to the root. */
async function matchFallbackFiles(
preset: Preset,
sides: { client: boolean; server: boolean },
nuxtRoot = projectDir,
): Promise<string[]> {
const { outputDirs, clientMaps, serverMaps } = PRESETS[preset];
writeFiles([...clientMaps, ...serverMaps, ...UNDEPLOYED_MAPS], nuxtRoot);

const globs = getFallbackFilesToDelete({
...sides,
outputDirs: {
publicDir: path.join(nuxtRoot, outputDirs.publicDir),
serverDir: path.join(nuxtRoot, outputDirs.serverDir),
},
});
const files = globs.length > 0 ? await globFiles(globs) : [];
return files.map(file => path.relative(nuxtRoot, file).split(path.sep).join('/')).sort();
}

it.each(Object.keys(PRESETS) as Preset[])(
'matches the client and server source maps in the output of the %s preset',
async preset => {
const { clientMaps, serverMaps } = PRESETS[preset];

await expect(matchFallbackFiles(preset, { client: true, server: true })).resolves.toEqual(
[...clientMaps, ...serverMaps].sort(),
);
},
);

it.each(Object.keys(PRESETS) as Preset[])(
'matches only the client source maps of the %s preset when Sentry enabled only those',
async preset => {
await expect(matchFallbackFiles(preset, { client: true, server: false })).resolves.toEqual(
[...PRESETS[preset].clientMaps].sort(),
);
},
);

it.each(Object.keys(PRESETS) as Preset[])(
'matches only the server source maps of the %s preset when Sentry enabled only those',
async preset => {
await expect(matchFallbackFiles(preset, { client: false, server: true })).resolves.toEqual(
PRESETS[preset].serverMaps,
);
},
);

it('matches the output of a Nuxt app in a subdirectory of the working directory', async () => {
const nuxtRoot = path.join(projectDir, 'apps', 'shop');

await expect(matchFallbackFiles('node-server', { client: true, server: false }, nuxtRoot)).resolves.toEqual(
PRESETS['node-server'].clientMaps,
);
});

it('returns no globs before Nitro reported its output directories', () => {
expect(getFallbackFilesToDelete({ client: true, server: true })).toEqual([]);
});
});
67 changes: 61 additions & 6 deletions packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from 'node:path';
import type { Nuxt } from '@nuxt/schema';
import type { Plugin, UserConfig } from 'vite';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
Expand All @@ -19,6 +20,23 @@ function createMockAddVitePlugin() {

type HookCallback = (...args: unknown[]) => void | Promise<void>;

/** Nitro's output directories of the `node-server` preset, relative to the working directory. */
const NODE_SERVER_OUTPUT = { publicDir: '.output/public', serverDir: '.output/server' };

function createMockNitro(options: { dev?: boolean; output?: { publicDir: string; serverDir: string } } = {}) {
const output = options.output ?? NODE_SERVER_OUTPUT;
return {
options: {
dev: options.dev ?? false,
output: {
publicDir: path.join(process.cwd(), output.publicDir),
serverDir: path.join(process.cwd(), output.serverDir),
},
rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown },
},
};
}

function createMockNuxt(options: {
_prepare?: boolean;
dev?: boolean;
Expand Down Expand Up @@ -47,12 +65,7 @@ function createMockNuxt(options: {
}

describe('setupSourceMaps hooks', () => {
const defaultFilesToDeleteAfterUpload = [
'.*/**/public/**/*.map',
'.*/**/server/**/*.map',
'.*/**/output/**/*.map',
'.*/**/function/**/*.map',
];
const defaultFilesToDeleteAfterUpload = ['.output/public/**/*.map', '.output/server/**/*.map'];

const mockSentryVitePlugin = vi.fn(() => [{ name: 'sentry-vite-plugin' }]);
const mockSentryRollupPlugin = vi.fn(() => ({ name: 'sentry-rollup-plugin' }));
Expand Down Expand Up @@ -220,6 +233,7 @@ describe('setupSourceMaps hooks', () => {

setupSourceMaps({ debug: false }, mockNuxt as unknown as Nuxt, mockAddVitePlugin);
await mockNuxt.triggerHook('modules:done');
await mockNuxt.triggerHook('nitro:init', createMockNitro());
await mockNuxt.triggerHook('close');

expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith(
Expand All @@ -231,6 +245,47 @@ describe('setupSourceMaps hooks', () => {
expect(mockDeleteArtifacts).toHaveBeenCalledTimes(1);
});

it('keeps the server source maps that `nitro.rollupConfig.output.sourcemap` enables', async () => {
const { setupSourceMaps } = await import('../../src/vite/sourceMaps');
const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } });
const nitroConfig = { rollupConfig: { plugins: [] as unknown[], output: { sourcemap: true } }, dev: false };

setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn());
await mockNuxt.triggerHook('modules:done');
await mockNuxt.triggerHook('nitro:config', nitroConfig);
await mockNuxt.triggerHook('nitro:init', createMockNitro());
await mockNuxt.triggerHook('close');

expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith(
expect.objectContaining({
sourcemaps: expect.objectContaining({ filesToDeleteAfterUpload: ['.output/public/**/*.map'] }),
}),
{ buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' },
);
});

it.each([
{ publicDir: 'dist', serverDir: '.netlify/functions-internal/server' },
{ publicDir: 'dist', serverDir: 'dist/_worker.js' },
])('uses Nitro output directories $publicDir and $serverDir for fallback deletion', async output => {
const { setupSourceMaps } = await import('../../src/vite/sourceMaps');
const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } });

setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn());
await mockNuxt.triggerHook('modules:done');
await mockNuxt.triggerHook('nitro:init', createMockNitro({ output }));
await mockNuxt.triggerHook('close');

expect(mockCreateSentryBuildPluginManager).toHaveBeenCalledWith(
expect.objectContaining({
sourcemaps: expect.objectContaining({
filesToDeleteAfterUpload: [`${output.publicDir}/**/*.map`, `${output.serverDir}/**/*.map`],
}),
}),
{ buildTool: 'nuxt', loggerPrefix: '[Sentry Nuxt]' },
);
});

it('uses user-provided deletion patterns after the build', async () => {
const { setupSourceMaps } = await import('../../src/vite/sourceMaps');
const mockNuxt = createMockNuxt({
Expand Down
Loading
Loading