Skip to content
Merged
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
3 changes: 3 additions & 0 deletions dev-packages/test-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export {
export { getPlaywrightConfig } from './playwright-config';
export { createBasicSentryServer, createTestServer } from './server';

export { startNodeServer } from './node-server';
export type { NodeServerOptions, NodeServer } from './node-server';

export { startMockSentryServer } from './mock-sentry-server';
export type { MockSentryServerOptions, MockSentryServer } from './mock-sentry-server';
export * from './sourcemap-upload-utils';
Expand Down
55 changes: 55 additions & 0 deletions dev-packages/test-utils/src/node-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { spawn } from 'node:child_process';

export interface NodeServerOptions {
/** Path to the server entry, e.g. `.output/server/index.mjs`. */
entry: string;
port: number;
/** How long to wait for the first response. Defaults to 30 seconds. */
timeoutMs?: number;
}

export interface NodeServer {
baseUrl: string;
/** Stops the server and resolves once its process has exited. */
close: () => Promise<void>;
}

/**
* Starts a built Node server and resolves once it answers HTTP requests. The server gets the port via `PORT`.
*
* Rejects if the server exits or does not answer before the timeout. Its output is printed only then.
*/
export async function startNodeServer({ entry, port, timeoutMs = 30_000 }: NodeServerOptions): Promise<NodeServer> {
const baseUrl = `http://localhost:${port}`;
const child = spawn(process.execPath, [entry], { env: { ...process.env, PORT: String(port) } });

let output = '';
child.stdout.on('data', chunk => (output += chunk));
child.stderr.on('data', chunk => (output += chunk));

const close = async (): Promise<void> => {
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
const exited = new Promise(resolve => child.once('exit', resolve));
child.kill();
await exited;
};

const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Server ${entry} exited with code ${child.exitCode} before it answered:\n${output}`);
}

try {
await fetch(baseUrl);
return { baseUrl, close };
} catch {
await new Promise(resolve => setTimeout(resolve, 250));
}
}

await close();
throw new Error(`Server ${entry} did not answer on ${baseUrl} within ${timeoutMs}ms:\n${output}`);
}
64 changes: 63 additions & 1 deletion packages/nuxt/src/vite/sourceMapDeletion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createSentryBuildPluginManager, type Options } from '@sentry/bundler-plugins/core';
import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core';
import type { Nitro } from 'nitropack';
import * as fs from 'node:fs';
import * as path from 'node:path';

export function withoutSourceMapDeletion(options: Options): Options {
return {
Expand Down Expand Up @@ -32,3 +35,62 @@ export async function deleteSourceMapsAfterBuild(options: Options): Promise<void

await sentryBuildPluginManager.deleteArtifacts();
}

/**
* Adds a plugin to Nitro's build that deletes the matching files in Nitro's public dir with `deletePublicSourceMaps`.
*/
export function addPublicSourceMapDeletionPlugin(nitro: Nitro, getOptions: () => Options): void {
const publicDir = nitro.options.output.publicDir;
const rollupConfig = (nitro.options.rollupConfig ??= { output: {} });

rollupConfig.plugins = [
rollupConfig.plugins,
{
name: 'sentry-nuxt-public-source-map-deletion',
// By now the client build is in the public dir, and Nitro has not yet listed its files.
async buildStart() {
await deletePublicSourceMaps(getOptions(), publicDir);
},
},
];
}

/**
* Deletes the files in `publicDir` that match `filesToDeleteAfterUpload`. Nitro answers a request for a file it
* listed at build time but cannot read with a 500. Files outside `publicDir` are deleted in `close`.
*/
export async function deletePublicSourceMaps(options: Options, publicDir: string): Promise<void> {
const filesToDeleteAfterUpload = await options.sourcemaps?.filesToDeleteAfterUpload;

if (filesToDeleteAfterUpload === undefined) {
return;
}

const files = await globFiles(filesToDeleteAfterUpload);
const publicFiles = files.filter(file => isInDirectory(file, publicDir));

await Promise.all(
publicFiles.map(async file => {
logDebug(options, `Deleting ${file} before Nitro bundles the server.`);
// `deleteSourceMapsAfterBuild` tries again in `close`.
await fs.promises.rm(file, { force: true }).catch(error => logDebug(options, `Could not delete ${file}.`, error));
}),
);
}

function logDebug(options: Options, ...messages: unknown[]): void {
if (options.debug) {
// eslint-disable-next-line no-console
console.log('[Sentry]', ...messages);
}
}

function isInDirectory(file: string, directory: string): boolean {
const relativePath = path.relative(directory, file);
return (
relativePath !== '' &&
relativePath !== '..' &&
!relativePath.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relativePath)
);
}
12 changes: 11 additions & 1 deletion packages/nuxt/src/vite/sourceMaps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { sentryVitePlugin, type SentryVitePluginOptions } from '@sentry/vite-plu
import type { NitroConfig } from 'nitropack';
import type { Plugin } from 'vite';
import type { SentryNuxtModuleOptions } from '../common/types';
import { deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion';
import {
addPublicSourceMapDeletionPlugin,
deleteSourceMapsAfterBuild,
withoutSourceMapDeletion,
} from './sourceMapDeletion';
import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin';

/**
Expand Down Expand Up @@ -122,6 +126,12 @@ export function setupSourceMaps(
}
});

nuxt.hook('nitro:init', nitro => {
if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) {
addPublicSourceMapDeletionPlugin(nitro, () => getPluginOptions(moduleOptions, shouldDeleteFilesFallback));
}
});

nuxt.hook('close', async () => {
if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) {
await deleteSourceMapsAfterBuild(getPluginOptions(moduleOptions, shouldDeleteFilesFallback));
Expand Down
95 changes: 95 additions & 0 deletions packages/nuxt/test/vite/sourceMapDeletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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';

const BUILD_FILES = [
'.output/public/_nuxt/entry.DxB3kLmA.js',
'.output/public/_nuxt/entry.DxB3kLmA.js.map',
'.output/public/_nuxt/pages/products.Cq81zR4t.js.map',
'.output/public-assets/_nuxt/entry.DxB3kLmA.js.map',
'.output/server/chunks/nitro/nitro.mjs.map',
'.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map',
];

let rootDir: string;
let publicDir: string;

/** Glob patterns need forward slashes, also on Windows. */
function toGlob(relativePattern: string): string {
return path.join(rootDir, relativePattern).split(path.sep).join('/');
}

function remainingFiles(): string[] {
return BUILD_FILES.filter(file => fs.existsSync(path.join(rootDir, file)));
}

describe('deletePublicSourceMaps', () => {
beforeEach(() => {
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-source-map-deletion-'));
publicDir = path.join(rootDir, '.output', 'public');

for (const file of BUILD_FILES) {
fs.mkdirSync(path.dirname(path.join(rootDir, file)), { recursive: true });
fs.writeFileSync(path.join(rootDir, file), '');
}
});

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

it('deletes the matched files in the public dir and keeps the matched files outside of it', async () => {
await deletePublicSourceMaps(
{
sourcemaps: {
filesToDeleteAfterUpload: [toGlob('.*/**/public*/**/*.map'), toGlob('.*/**/server/**/*.map')],
},
},
publicDir,
);

expect(remainingFiles()).toEqual([
'.output/public/_nuxt/entry.DxB3kLmA.js',
'.output/public-assets/_nuxt/entry.DxB3kLmA.js.map',
'.output/server/chunks/nitro/nitro.mjs.map',
'.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map',
]);
});

it('keeps the files in the public dir that no pattern matches', async () => {
await deletePublicSourceMaps(
{ sourcemaps: { filesToDeleteAfterUpload: toGlob('.output/public/_nuxt/pages/*.map') } },
publicDir,
);

expect(remainingFiles()).toEqual([
'.output/public/_nuxt/entry.DxB3kLmA.js',
'.output/public/_nuxt/entry.DxB3kLmA.js.map',
'.output/public-assets/_nuxt/entry.DxB3kLmA.js.map',
'.output/server/chunks/nitro/nitro.mjs.map',
'.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map',
]);
});

it('resolves a promised list of patterns', async () => {
await deletePublicSourceMaps(
{ sourcemaps: { filesToDeleteAfterUpload: Promise.resolve([toGlob('.output/public/**/*.map')]) } },
publicDir,
);

expect(remainingFiles()).toEqual([
'.output/public/_nuxt/entry.DxB3kLmA.js',
'.output/public-assets/_nuxt/entry.DxB3kLmA.js.map',
'.output/server/chunks/nitro/nitro.mjs.map',
'.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map',
]);
});

it('deletes nothing when no deletion patterns are set', async () => {
await deletePublicSourceMaps({ sourcemaps: {} }, publicDir);

expect(remainingFiles()).toEqual(BUILD_FILES);
});
});
Loading