From f44da22475d9821080d9c2f9af12f77f9537a3c9 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:14:27 +0200 Subject: [PATCH 1/2] fix(nuxt): Delete public source maps before Nitro lists its public assets --- .../nuxt-4-sourcemaps/assert-build.ts | 50 ++++++++ dev-packages/test-utils/src/index.ts | 3 + dev-packages/test-utils/src/node-server.ts | 55 +++++++++ packages/nuxt/src/vite/sourceMapDeletion.ts | 44 ++++++- packages/nuxt/src/vite/sourceMaps.ts | 20 +++- .../nuxt/test/vite/sourceMapDeletion.test.ts | 95 ++++++++++++++++ .../test/vite/sourceMaps-nuxtHooks.test.ts | 107 +++++++++++++++++- 7 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 dev-packages/test-utils/src/node-server.ts create mode 100644 packages/nuxt/test/vite/sourceMapDeletion.test.ts diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts index 49b9839d98ba..671ec4526a7a 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4-sourcemaps/assert-build.ts @@ -15,6 +15,7 @@ import { getSourcemapSources, getSourcemaps, loadMockServerResults, + startNodeServer, } from '@sentry-internal/test-utils'; /** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ @@ -170,6 +171,11 @@ const debugIdsWithoutSourceMap = debugIdsOfChunksWithoutSourceMap(CLIENT_BUILD_O for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { const injectedDebugIds = findInjectedDebugIds({ outputDir }); assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); const unuploaded = injectedDebugIds.filter( debugId => !uploadedDebugIds.has(debugId) && !debugIdsWithoutSourceMap.has(debugId), @@ -232,4 +238,48 @@ if (keepClientSourceMaps) { console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); } +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. +if (!isStaticBuild) { + const clientChunkUrls = fs + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) + .map( + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, + ); + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + + try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => { + const mapUrl = `${chunkUrl}.map`; + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); + return { mapUrl, status, expectedStatus }; + }), + ); + const unexpectedStatuses = responses + .filter(({ status, expectedStatus }) => status !== expectedStatus) + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); + assert.deepEqual( + unexpectedStatuses, + [], + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', + ); + + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); + } finally { + await server.close(); + } +} + console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/test-utils/src/index.ts b/dev-packages/test-utils/src/index.ts index 9b245bd74ee5..bb70d9382f9e 100644 --- a/dev-packages/test-utils/src/index.ts +++ b/dev-packages/test-utils/src/index.ts @@ -38,6 +38,9 @@ export { getRuntime } from './runtime'; export type { Runtime } from './runtime'; 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'; diff --git a/dev-packages/test-utils/src/node-server.ts b/dev-packages/test-utils/src/node-server.ts new file mode 100644 index 000000000000..c2bf6791af29 --- /dev/null +++ b/dev-packages/test-utils/src/node-server.ts @@ -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; +} + +/** + * 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 { + 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 => { + 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}`); +} diff --git a/packages/nuxt/src/vite/sourceMapDeletion.ts b/packages/nuxt/src/vite/sourceMapDeletion.ts index dc23fcf7c5f5..01b4d21daecf 100644 --- a/packages/nuxt/src/vite/sourceMapDeletion.ts +++ b/packages/nuxt/src/vite/sourceMapDeletion.ts @@ -1,4 +1,6 @@ -import { createSentryBuildPluginManager, type Options } from '@sentry/bundler-plugins/core'; +import { createSentryBuildPluginManager, globFiles, type Options } from '@sentry/bundler-plugins/core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; export function withoutSourceMapDeletion(options: Options): Options { return { @@ -32,3 +34,43 @@ export async function deleteSourceMapsAfterBuild(options: Options): Promise { + 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) + ); +} diff --git a/packages/nuxt/src/vite/sourceMaps.ts b/packages/nuxt/src/vite/sourceMaps.ts index d03c725e6c8c..5d0f634fac59 100644 --- a/packages/nuxt/src/vite/sourceMaps.ts +++ b/packages/nuxt/src/vite/sourceMaps.ts @@ -5,7 +5,7 @@ import { warnOnRemovedBuildOptions } from '@sentry/core'; import type { NitroConfig } from 'nitropack'; import type { Plugin } from 'vite'; import type { SentryNuxtModuleOptions } from '../common/types'; -import { deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; +import { deletePublicSourceMaps, deleteSourceMapsAfterBuild, withoutSourceMapDeletion } from './sourceMapDeletion'; import { validateSourceMapsOptionsPlugin } from './sentryVitePlugin'; /** @@ -112,6 +112,24 @@ export function setupSourceMaps( } }); + nuxt.hook('nitro:init', nitro => { + if (sourceMapsEnabled && !nitro.options.dev && !nuxt.options?._prepare) { + 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(getPluginOptions(moduleOptions, shouldDeleteFilesFallback), publicDir); + }, + }, + ]; + } + }); + nuxt.hook('close', async () => { if (sourceMapsEnabled && !nuxt.options.dev && !nuxt.options?._prepare) { await deleteSourceMapsAfterBuild(getPluginOptions(moduleOptions, shouldDeleteFilesFallback)); diff --git a/packages/nuxt/test/vite/sourceMapDeletion.test.ts b/packages/nuxt/test/vite/sourceMapDeletion.test.ts new file mode 100644 index 000000000000..1599aebcdf9f --- /dev/null +++ b/packages/nuxt/test/vite/sourceMapDeletion.test.ts @@ -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); + }); +}); diff --git a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts index 18f67df7ab40..2059462d1490 100644 --- a/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts +++ b/packages/nuxt/test/vite/sourceMaps-nuxtHooks.test.ts @@ -1,6 +1,10 @@ import type { Nuxt } from '@nuxt/schema'; +import type * as BundlerPluginsCore from '@sentry/bundler-plugins/core'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { ConfigEnv, Plugin, UserConfig } from 'vite'; -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeEach, describe, expect, it, type MockInstance, vi } from 'vitest'; import { setupSourceMaps, type SourceMapSetting } from '../../src/vite/sourceMaps'; const { mockSentryVitePlugin, mockSentryRollupPlugin, mockDeleteArtifacts, mockCreateSentryBuildPluginManager } = @@ -15,7 +19,8 @@ const { mockSentryVitePlugin, mockSentryRollupPlugin, mockDeleteArtifacts, mockC }; }); -vi.mock('@sentry/bundler-plugins/core', () => ({ +vi.mock('@sentry/bundler-plugins/core', async importOriginal => ({ + ...(await importOriginal()), createSentryBuildPluginManager: mockCreateSentryBuildPluginManager, })); vi.mock('@sentry/bundler-plugins/vite', () => ({ sentryVitePlugin: mockSentryVitePlugin })); @@ -369,6 +374,104 @@ describe('setupSourceMaps hooks', () => { }); }); + describe('nitro:init hook', () => { + const PUBLIC_MAP = '.output/public/_nuxt/entry.DxB3kLmA.js.map'; + const PUBLIC_CHUNK = '.output/public/_nuxt/entry.DxB3kLmA.js'; + const SSR_BUILD_MAP = '.nuxt/dist/server/_nuxt/index-B2kR7mXp.mjs.map'; + + let rootDir: string; + let cwdSpy: MockInstance; + + beforeEach(() => { + rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sentry-nuxt-root-')); + // The fallback deletion patterns are relative to the working directory. + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(rootDir); + for (const file of [PUBLIC_MAP, PUBLIC_CHUNK, SSR_BUILD_MAP]) { + fs.mkdirSync(path.dirname(path.join(rootDir, file)), { recursive: true }); + fs.writeFileSync(path.join(rootDir, file), ''); + } + }); + + afterEach(() => { + cwdSpy.mockRestore(); + fs.rmSync(rootDir, { recursive: true, force: true }); + }); + + function createMockNitro(options: { dev?: boolean } = {}) { + return { + options: { + dev: options.dev ?? false, + output: { publicDir: path.join(rootDir, '.output', 'public') }, + rollupConfig: { plugins: [{ name: 'sentry-rollup-plugin' }] as unknown }, + }, + }; + } + + function findDeletionPlugin(nitro: ReturnType) { + return [nitro.options.rollupConfig.plugins] + .flat(2) + .find( + (plugin): plugin is { name: string; buildStart: () => Promise } => + typeof plugin === 'object' && + plugin !== null && + 'name' in plugin && + plugin.name === 'sentry-nuxt-public-source-map-deletion' && + 'buildStart' in plugin && + typeof plugin.buildStart === 'function', + ); + } + + function remainingFiles(): string[] { + return [PUBLIC_MAP, PUBLIC_CHUNK, SSR_BUILD_MAP].filter(file => fs.existsSync(path.join(rootDir, file))); + } + + it('deletes the source maps in the Nitro public dir when Nitro starts to bundle the server', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: 'hidden', server: true } }); + const nitro = createMockNitro(); + const filesToDeleteAfterUpload = [`${rootDir.split(path.sep).join('/')}/.output/public/**/*.map`]; + + setupSourceMaps({ sourcemaps: { filesToDeleteAfterUpload } }, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + const deletionPlugin = findDeletionPlugin(nitro); + await deletionPlugin?.buildStart(); + + expect(deletionPlugin).toBeDefined(); + expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); + }); + + it('deletes only the public source maps with the fallback patterns and keeps the SSR build maps', async () => { + const mockNuxt = createMockNuxt({ sourcemap: { client: undefined, server: undefined } }); + const nitro = createMockNitro(); + + setupSourceMaps({}, mockNuxt as unknown as Nuxt, vi.fn()); + await mockNuxt.triggerHook('modules:done'); + await mockNuxt.triggerHook('nitro:init', nitro); + const deletionPlugin = findDeletionPlugin(nitro); + await deletionPlugin?.buildStart(); + + expect(deletionPlugin).toBeDefined(); + expect(remainingFiles()).toEqual([PUBLIC_CHUNK, SSR_BUILD_MAP]); + }); + + it.each([ + { label: 'prepare mode', nuxtOptions: { _prepare: true }, nitroOptions: {} }, + { label: 'dev mode', nuxtOptions: { dev: true }, nitroOptions: { dev: true } }, + ])('does not add the deletion plugin in $label', async ({ nuxtOptions, nitroOptions }) => { + const mockNuxt = createMockNuxt(nuxtOptions); + const nitro = createMockNitro(nitroOptions); + + setupSourceMaps( + { sourcemaps: { filesToDeleteAfterUpload: ['.output/**/*.map'] } }, + mockNuxt as unknown as Nuxt, + vi.fn(), + ); + await mockNuxt.triggerHook('nitro:init', nitro); + + expect(findDeletionPlugin(nitro)).toBeUndefined(); + }); + }); + describe('debug logging', () => { it('logs a [Sentry] message in production mode', async () => { const mockNuxt = createMockNuxt({ _prepare: false, dev: false }); From 9795dd1ba44ac55ee57b25514c4e0692f7b21e23 Mon Sep 17 00:00:00 2001 From: s1gr1d <32902192+s1gr1d@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:20:08 +0200 Subject: [PATCH 2/2] test(e2e): Add nuxt-5-sourcemaps test --- .../nuxt-5-sourcemaps/.gitignore | 23 ++ .../nuxt-5-sourcemaps/app/app.vue | 12 + .../nuxt-5-sourcemaps/app/pages/index.vue | 9 + .../nuxt-5-sourcemaps/assert-build.ts | 313 ++++++++++++++++++ .../nuxt-5-sourcemaps/nuxt.config.ts | 33 ++ .../nuxt-5-sourcemaps/package.json | 47 +++ .../nuxt-5-sourcemaps/pnpm-workspace.yaml | 3 + .../nuxt-5-sourcemaps/sentry.client.config.ts | 7 + .../nuxt-5-sourcemaps/sentry.server.config.ts | 6 + .../server/api/server-error.ts | 8 + .../start-mock-sentry-server.mjs | 3 + .../nuxt-5-sourcemaps/tsconfig.json | 15 + 12 files changed, 479 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs create mode 100644 dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore new file mode 100644 index 000000000000..1dca2550f7e3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/.gitignore @@ -0,0 +1,23 @@ +# Nuxt dev/build outputs +.output +.data +.nuxt +.nitro +.cache +dist + +# Node dependencies +node_modules + +# Mock Sentry server artifacts +.tmp_mock_uploads.json +.tmp_chunks +.tmp_build_stdout +.tmp_build_stderr + +# Logs +logs +*.log + +# Misc +.DS_Store diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue new file mode 100644 index 000000000000..eb66eddd8a33 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/app.vue @@ -0,0 +1,12 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue new file mode 100644 index 000000000000..bc19868e4488 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/app/pages/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts new file mode 100644 index 000000000000..5bf988c300ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/assert-build.ts @@ -0,0 +1,313 @@ +import * as assert from 'assert/strict'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + findDebugIdsWithConflictingSourcemaps, + findFilesWithForeignSourcemaps, + findFilesWithMultipleDebugIds, + findInjectedDebugIds, + findSourceMapFiles, + findSourceMappingUrlComments, + getArtifactBundles, + getAssembleRequests, + getChunkUploadPosts, + getDebugIdPairs, + getSourcemaps, + loadMockServerResults, + startNodeServer, +} from '@sentry-internal/test-utils'; + +/** This variant omits `sourcemaps.filesToDeleteAfterUpload`, so Sentry must upload but not delete. */ +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +/** `nuxt generate` emits no `.output/server`. Keyed on the command so a missing one under `nuxt build` still fails. */ +const isStaticBuild = process.env.NUXT_COMMAND === 'generate'; + +/** Nuxt 5 writes the client build here directly, so Sentry deletes the maps in Vite's only output directory. */ +const CLIENT_OUTPUT = path.join('.output', 'public'); +const SERVER_OUTPUT = path.join('.output', 'server'); + +/** Both markers sit in comments, so bundlers strip them from the code but keep them in `sourcesContent`. */ +const CLIENT_MARKER = 'SOURCEMAP_MARKER_CLIENT'; +const SERVER_MARKER = 'SOURCEMAP_MARKER_SERVER'; + +const UUID_REGEX = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; + +function filesContaining(dir: string, needle: string): string[] { + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => path.join(entry.parentPath, entry.name)) + .filter(file => fs.readFileSync(file, 'utf8').includes(needle)); +} + +/** + * Rolldown emits no source map for a chunk without mappable source, such as Vue's export helper. + * Such a chunk still gets a debug ID, but there is no map to upload for it. + */ +function debugIdsOfChunksWithoutSourceMap(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (file.endsWith('.map') || fs.existsSync(`${file}.map`)) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + +// TODO: The uploader passes the emitted file paths to `glob` as patterns, so it skips a file whose path has glob +// characters, like Nitro 3's `_libs/_sentry/node+[...].mjs`. Remove this when the uploader matches paths literally. +function debugIdsOfFilesWithGlobCharacters(dir: string): Set { + const debugIds = new Set(); + + for (const file of filesContaining(dir, 'sentry-dbid-')) { + if (!/[[\]{}()*?]/.test(path.relative(dir, file))) { + continue; + } + + for (const match of fs.readFileSync(file, 'utf8').matchAll(/sentry-dbid-([\da-f-]{36})/gi)) { + debugIds.add((match[1] as string).toLowerCase()); + } + } + + return debugIds; +} + +console.log( + `Variant: ${isStaticBuild ? 'nuxt generate' : 'nuxt build'}, ` + + `client source maps ${keepClientSourceMaps ? 'kept' : 'deleted'}\n`, +); + +const requests = loadMockServerResults(); + +console.log(`Captured ${requests.length} requests to mock Sentry server:\n`); +for (const request of requests) { + console.log(` ${request.method} ${request.url} (${request.bodySize} bytes)`); +} +console.log(''); + +// --- The upload reached Sentry --- + +assert.ok( + requests.some(r => r.authorization.includes('fake-auth-token')), + 'Expected requests with the configured auth token', +); + +// Sentry rejects a release that names no project with a 400, and the mock server accepts anything, +// so assert on the body rather than on the call alone. +const releaseCreates = requests.filter(r => r.url?.includes('/releases') && r.method === 'POST'); +assert.ok(releaseCreates.length > 0, 'Expected a POST to create the release'); +for (const request of releaseCreates) { + const body = request.jsonBody as { version?: string; projects?: string[] } | undefined; + assert.equal( + body?.version, + 'test-release', + `Expected the release create body to carry the version, got ${JSON.stringify(body)}`, + ); + assert.deepEqual( + body?.projects, + ['test-project'], + `Expected the release create body to name the project, got ${JSON.stringify(body)}`, + ); +} + +assert.ok( + requests.some(r => r.url?.includes('/releases/') && r.method === 'PUT'), + 'Expected a PUT to finalize the release', +); + +assert.ok( + requests.some(r => r.url?.includes('/chunk-upload/') && r.method === 'GET'), + 'Expected a GET for the chunk-upload options', +); + +const chunkPosts = getChunkUploadPosts(requests); +assert.ok( + chunkPosts.some(r => r.bodySize > 0), + 'Expected at least one chunk upload POST with a non-empty body', +); + +const assembleRequests = getAssembleRequests(requests); +assert.ok(assembleRequests.length > 0, 'Expected at least one assemble request'); +for (const request of assembleRequests) { + assert.ok(request.assembleBody?.projects?.includes('test-project'), 'Expected assemble request for test-project'); + assert.equal(request.assembleBody?.version, 'test-release', 'Expected assemble request to reference the release'); + assert.ok((request.assembleBody?.chunks?.length ?? 0) > 0, 'Expected assemble request to have chunk checksums'); + for (const chunk of request.assembleBody?.chunks ?? []) { + assert.match(chunk, /^[\da-f]{40}$/i, `Expected a SHA-1 chunk checksum, got: ${chunk}`); + } +} + +const bundles = getArtifactBundles(requests); +assert.ok(bundles.length > 0, 'Expected at least one artifact bundle with a manifest'); +console.log(`Found ${bundles.length} artifact bundle(s)\n`); + +// --- Both bundlers uploaded --- + +const sourcemaps = getSourcemaps(bundles); +assert.ok( + sourcemaps.some(map => map.sourcemap.mappings?.length), + 'Expected at least one uploaded sourcemap with non-empty mappings', +); + +const containsMarker = (marker: string): boolean => + sourcemaps.some(map => map.sourcemap.sourcesContent?.some(source => source?.includes(marker))); + +// Vite builds the client and Nitro builds the server. Counting bundles would still pass with either +// plugin dropped, so each side is pinned to a marker only that side's source supplies. +assert.ok(containsMarker(CLIENT_MARKER), 'Expected an uploaded sourcemap carrying the client source (Vite plugin)'); +// TODO: Nitro 3 builds the server with Rolldown, and the uploaded server maps carry no `sourcesContent`: the +// module's `sourcemapExcludeSources: false` does not reach that build. Assert the server marker when that is fixed: +// assert.ok(containsMarker(SERVER_MARKER), 'Expected an uploaded sourcemap carrying the server source (Rollup plugin)'); + +// `rewriteSources` normalizes `../../../foo` to `./foo` so paths stay resolvable in Sentry. +const unnormalizedSources = [...new Set(sourcemaps.flatMap(map => map.sourcemap.sources ?? []))].filter( + source => source.startsWith('../') || path.isAbsolute(source), +); +assert.deepEqual(unnormalizedSources, [], `Expected every uploaded source to be normalized to './…'`); + +// --- Debug IDs tie the shipped bundle to the uploaded map --- + +const uploadedDebugIds = new Set(getDebugIdPairs(bundles).map(pair => pair.debugId.toLowerCase())); +assert.ok(uploadedDebugIds.size > 0, 'Expected at least one JS/sourcemap pair with matching debug IDs'); + +const malformedDebugIds = [...uploadedDebugIds].filter(debugId => !UUID_REGEX.test(debugId)); +assert.deepEqual(malformedDebugIds, [], 'Expected every uploaded debug ID to be a UUID'); + +// An uploaded map is only reachable at runtime if the shipped bundle claims the same ID. Inspecting +// the upload alone cannot show this. +// Nuxt 5 writes the client build to `CLIENT_OUTPUT` directly. After Sentry deletes the client maps there, a chunk +// that never had a map looks the same as one whose map was deleted. So the client upload is checked only when the +// maps are kept. The upload is the same in all variants. +const outputDirsWithSourceMaps = [ + ...(keepClientSourceMaps ? [CLIENT_OUTPUT] : []), + ...(isStaticBuild ? [] : [SERVER_OUTPUT]), +]; +const debugIdsWithoutSourceMap = new Set( + outputDirsWithSourceMaps.flatMap(dir => [...debugIdsOfChunksWithoutSourceMap(dir)]), +); +const debugIdsSkippedByUploader = isStaticBuild ? new Set() : debugIdsOfFilesWithGlobCharacters(SERVER_OUTPUT); + +for (const outputDir of isStaticBuild ? [CLIENT_OUTPUT] : [CLIENT_OUTPUT, SERVER_OUTPUT]) { + const injectedDebugIds = findInjectedDebugIds({ outputDir }); + assert.ok(injectedDebugIds.length > 0, `Expected debug IDs to be injected into ${outputDir}`); + // The exemption for chunks without a source map must not let a build without any uploaded map pass. + assert.ok( + injectedDebugIds.some(debugId => uploadedDebugIds.has(debugId)), + `Expected at least one debug ID in ${outputDir} to have an uploaded sourcemap`, + ); + + if (outputDirsWithSourceMaps.includes(outputDir)) { + const unuploaded = injectedDebugIds.filter( + debugId => + !uploadedDebugIds.has(debugId) && + !debugIdsWithoutSourceMap.has(debugId) && + !debugIdsSkippedByUploader.has(debugId), + ); + assert.deepEqual(unuploaded, [], `Expected every debug ID in ${outputDir} to have an uploaded sourcemap`); + } + + console.log(` ${outputDir}: ${injectedDebugIds.length} injected debug ID(s)`); + + // Nitro bundles the SSR build again, so its debug IDs can end up next to Nitro's own. + // Reference issue: https://github.com/getsentry/sentry-javascript/issues/18519 + const filesWithMultipleDebugIds = findFilesWithMultipleDebugIds({ outputDir }); + assert.deepEqual(filesWithMultipleDebugIds, [], `Expected at most one debug ID per file in ${outputDir}`); + + // A server file can borrow the debug ID of its SSR build chunk. Then the upload check above passes on + // Vite's map even if Nitro uploaded nothing. + const filesWithForeignSourcemaps = findFilesWithForeignSourcemaps({ outputDir }, sourcemaps); + assert.deepEqual( + filesWithForeignSourcemaps, + [], + `Expected the source map uploaded for each file in ${outputDir} to be generated for that file`, + ); +} + +// If both builds upload a map under a borrowed debug ID, Sentry can resolve with the wrong one. +const conflictingDebugIds = findDebugIdsWithConflictingSourcemaps(sourcemaps); +assert.deepEqual(conflictingDebugIds, [], 'Expected every uploaded debug ID to resolve to exactly one source map'); + +// TODO: Nitro 3 bundles the SSR build's output again, but its maps stop at that output +// (`.nuxt/vite/services/ssr/...`) and do not chain to the SSR build's own maps. So server frames do not reach +// `.vue` files. Assert this when the server maps reach the original sources: +// const serverDebugIds = new Set(findInjectedDebugIds({ outputDir: SERVER_OUTPUT })); +// const serverSources = sourcemaps +// .filter(map => map.debugId && serverDebugIds.has(map.debugId)) +// .flatMap(map => getSourcemapSources(map.sourcemap)); +// assert.ok(serverSources.some(source => source.endsWith('app/pages/index.vue'))); +console.log(''); + +// --- What the build leaves behind in the client output --- + +const clientSourceMaps = findSourceMapFiles({ outputDir: CLIENT_OUTPUT }); + +if (keepClientSourceMaps) { + assert.ok(clientSourceMaps.length > 0, `Expected Sentry to leave the user-enabled maps in ${CLIENT_OUTPUT}`); + console.log(` ${clientSourceMaps.length} source map(s) kept in ${CLIENT_OUTPUT}, as configured\n`); +} else { + // This directory is served to the internet, so a surviving `.map` hands out the original source. + assert.deepEqual(clientSourceMaps, [], `Expected no source maps in ${CLIENT_OUTPUT} after upload`); + + // The maps are gone, so a surviving reference only 404s in devtools and leaks where they were. + const danglingReferences = findSourceMappingUrlComments({ outputDir: CLIENT_OUTPUT }); + assert.deepEqual(danglingReferences, [], `Expected no sourceMappingURL comments in ${CLIENT_OUTPUT}`); + + // Catches source maps inlined as `data:` URIs, which the reference check above skips by design. + const leakedSource = filesContaining(CLIENT_OUTPUT, CLIENT_MARKER); + assert.deepEqual(leakedSource, [], `Expected no original client source under ${CLIENT_OUTPUT}`); + + console.log(` ${CLIENT_OUTPUT} is free of source maps, sourceMappingURL comments and original source\n`); +} + +// --- What the built server answers for the client source maps --- + +// `nuxt generate` emits no server, and a static host serves only the files that exist. +if (!isStaticBuild) { + const clientChunkUrls = fs + .readdirSync(CLIENT_OUTPUT, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith('.js')) + .map( + entry => `/${path.relative(CLIENT_OUTPUT, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')}`, + ); + assert.ok(clientChunkUrls.length > 0, `Expected client chunks in ${CLIENT_OUTPUT}`); + + const server = await startNodeServer({ entry: path.join(SERVER_OUTPUT, 'index.mjs'), port: 3030 }); + + try { + // Without this, a server that serves no public files at all would pass the 404 checks. + const chunkStatus = (await fetch(`${server.baseUrl}${clientChunkUrls[0]}`)).status; + assert.equal(chunkStatus, 200, `Expected the server to serve the client chunk ${clientChunkUrls[0]}`); + + // Nitro serves public files from a list it writes into the server bundle. A map deleted after that + // stays on the list, and Nitro answers its request with a 500 instead of a 404. + const responses = await Promise.all( + clientChunkUrls.map(async chunkUrl => { + const mapUrl = `${chunkUrl}.map`; + const expectedStatus = fs.existsSync(path.join(CLIENT_OUTPUT, mapUrl)) ? 200 : 404; + const { status } = await fetch(`${server.baseUrl}${mapUrl}`); + return { mapUrl, status, expectedStatus }; + }), + ); + const unexpectedStatuses = responses + .filter(({ status, expectedStatus }) => status !== expectedStatus) + .map(({ mapUrl, status, expectedStatus }) => `${mapUrl} → ${status}, expected ${expectedStatus}`); + assert.deepEqual( + unexpectedStatuses, + [], + 'Expected the server to answer each client source map URL with 200 if the map is in the output, else 404', + ); + + console.log(` The server answers all ${clientChunkUrls.length} client source map URL(s) as expected\n`); + } finally { + await server.close(); + } +} + +console.log('All sourcemap assertions passed!'); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts new file mode 100644 index 000000000000..f2f14911dde3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/nuxt.config.ts @@ -0,0 +1,33 @@ +// Nuxt defaults `sourcemap.client` to `false`, which the SDK respects as a deliberate opt-out, so +// an app that never mentions `sourcemap`, uploads nothing client-side. `'hidden'` is what the SDK's +// own warning tells users to set, which makes it the setup worth regression-testing. +const keepClientSourceMaps = process.env.E2E_KEEP_CLIENT_SOURCEMAPS === 'true'; + +export default defineNuxtConfig({ + compatibilityDate: '2025-06-06', + imports: { autoImport: false }, + + sourcemap: { client: 'hidden' }, + + modules: ['@sentry/nuxt/module'], + + runtimeConfig: { + public: { + sentry: { + dsn: 'https://public@dsn.ingest.sentry.io/1337', + }, + }, + }, + + sentry: { + sentryUrl: 'http://localhost:3032', + authToken: 'fake-auth-token', + org: 'test-org', + project: 'test-project', + release: { name: 'test-release' }, + // Dropping `filesToDeleteAfterUpload` is the whole point of the "kept" variant: Sentry should + // upload the maps and leave the emitted files alone. + sourcemaps: keepClientSourceMaps ? {} : { filesToDeleteAfterUpload: ['.output/public/**/*.map'] }, + debug: true, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json new file mode 100644 index 000000000000..ccc0df377bcd --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/package.json @@ -0,0 +1,47 @@ +{ + "name": "nuxt-5-sourcemaps", + "description": "E2E test app asserting what the Nuxt SDK uploads to Sentry and what it leaves behind in `.output` on Nuxt 5 (Nitro 3).", + "private": true, + "type": "module", + "scripts": { + "build": "node start-mock-sentry-server.mjs & nuxt ${NUXT_COMMAND:-build} > .tmp_build_stdout 2> .tmp_build_stderr; BUILD_EXIT=$?; kill %1 2>/dev/null; if [ $BUILD_EXIT -ne 0 ]; then cat .tmp_build_stdout; cat .tmp_build_stderr >&2; fi; exit $BUILD_EXIT", + "clean": "npx nuxi cleanup", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm ts-node --script-mode assert-build.ts", + "test:build:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:build", + "test:assert:keep-client-sourcemaps": "E2E_KEEP_CLIENT_SOURCEMAPS=true pnpm test:assert", + "test:build:static": "NUXT_COMMAND=generate pnpm test:build", + "test:assert:static": "NUXT_COMMAND=generate pnpm test:assert" + }, + "dependencies": { + "@sentry/nuxt": "file:../../packed/sentry-nuxt-packed.tgz", + "nitro": "latest", + "nuxt": "npm:nuxt-nightly@5x" + }, + "devDependencies": { + "@playwright/test": "~1.63.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^22.20.0", + "ts-node": "10.9.1", + "typescript": "~5.0.0" + }, + "volta": { + "extends": "../../package.json", + "pnpm": "11.10.0" + }, + "sentryTest": { + "optional": true, + "optionalVariants": [ + { + "build-command": "pnpm test:build:keep-client-sourcemaps", + "assert-command": "pnpm test:assert:keep-client-sourcemaps", + "label": "nuxt-5-sourcemaps (client source maps kept)" + }, + { + "build-command": "pnpm test:build:static", + "assert-command": "pnpm test:assert:static", + "label": "nuxt-5-sourcemaps (static / nuxt generate)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml new file mode 100644 index 000000000000..4210a62a804d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@sentry/cli': true +minimumReleaseAge: 0 diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts new file mode 100644 index 000000000000..795fc10a560c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.client.config.ts @@ -0,0 +1,7 @@ +import * as Sentry from '@sentry/nuxt'; +import { useRuntimeConfig } from '#imports'; + +Sentry.init({ + dsn: useRuntimeConfig().public.sentry.dsn, + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts new file mode 100644 index 000000000000..38ed95a35801 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/sentry.server.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from '@sentry/nuxt'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1.0, +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts new file mode 100644 index 000000000000..dba169666ee1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/server/api/server-error.ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'nitro'; + +// SOURCEMAP_MARKER_SERVER — the server counterpart of the client marker. Nitro defaults to +// `sourcemapExcludeSources: true`, which would drop this from the uploaded map; the Sentry module +// flips it to `false`, so finding this marker in `sourcesContent` is what proves that still works. +export default defineHandler(() => { + throw new Error('Server error from the Nuxt source map E2E app'); +}); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs new file mode 100644 index 000000000000..1680a6360165 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/start-mock-sentry-server.mjs @@ -0,0 +1,3 @@ +import { startMockSentryServer } from '@sentry-internal/test-utils'; + +startMockSentryServer({ org: 'test-org' }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json new file mode 100644 index 000000000000..a2aac493d878 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/nuxt-5-sourcemaps/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "noEmit": true + }, + "include": ["**/*.ts", "**/*.vue"], + "exclude": ["node_modules", ".output", ".nuxt"] +}