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
4 changes: 2 additions & 2 deletions packages/nuxt/rollup.module.config.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { isAbsolute, join } from 'node:path';
import esbuild from 'rollup-plugin-esbuild';

// The Nuxt module ships two kinds of output that live side by side in `build/module`:
Expand All @@ -11,7 +11,7 @@ import esbuild from 'rollup-plugin-esbuild';

// Anything that isn't a relative path is provided by the consuming app or Node at runtime
// (this covers `@sentry/*`, `nuxt/app`, `#imports`, node builtins), so it stays external.
const isExternal = id => !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('\0');
const isExternal = id => !id.startsWith('.') && !isAbsolute(id) && !id.startsWith('\0');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch!


const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 49 additions & 12 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { addTemplate, createResolver } from '@nuxt/kit';
import type { Nuxt } from '@nuxt/schema';
Expand All @@ -20,6 +21,7 @@ import {
SENTRY_WRAPPED_FUNCTIONS,
SERVER_CONFIG_FILENAME,
toImportSpecifier,
toResolvablePath,
} from './utils';

/** Path of the generated dev-mode config file, relative to the Nuxt build directory. */
Expand All @@ -31,7 +33,7 @@ export const DEV_SERVER_CONFIG_PATH = `dev/${SERVER_CONFIG_FILENAME}.mjs`;
* In dev-mode, Nitro v3 has no server bundle to emit into, so Node loads the server config file as it is written.
*/
export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): void {
const configPath = createResolver(nuxt.options.rootDir).resolve(`/${serverConfigFile}`);
const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile);
const importSpecifier = toImportSpecifier(
nuxt.options.rootDir,
path.join(nuxt.options.buildDir, DEV_SERVER_CONFIG_PATH),
Expand Down Expand Up @@ -59,6 +61,15 @@ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): vo
].join('\n'),
});
}
const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts'];

function isServerConfigFile(sourcePath: string, resolvedPath: string): boolean {
if (sourcePath === resolvedPath) {
return true;
}
const name = basename(sourcePath);
return name === SERVER_CONFIG_FILENAME || CONFIG_EXTENSIONS.some(ext => name === `${SERVER_CONFIG_FILENAME}${ext}`);
}

/**
* 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.
Expand Down Expand Up @@ -157,7 +168,7 @@ export function addDynamicImportEntryFileWrapper(

nitro.options.rollupConfig.plugins.push(
wrapEntryWithDynamicImport({
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`),
resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(serverConfigFile),
experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions,
}),
);
Expand All @@ -173,7 +184,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
name: 'rollup-plugin-inject-sentry-server-config',

buildStart() {
const configPath = createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`);
const configPath = createResolver(nitro.options.rootDir).resolve(serverConfigFile);

if (!existsSync(configPath)) {
if (isDebug) {
Expand All @@ -193,7 +204,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
resolveId(source) {
if (source.startsWith(filePrefix)) {
const originalFilePath = source.replace(filePrefix, '');
const configPath = createResolver(nitro.options.rootDir).resolve(`/${originalFilePath}`);
const configPath = createResolver(nitro.options.rootDir).resolve(originalFilePath);

return { id: configPath };
}
Expand All @@ -206,8 +217,10 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu
* A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first
* by using a regular `import` and load the server after that.
* This also works with serverless `handler` functions, as it re-exports the `handler`.
*
* Only exported for testing.
*/
function wrapEntryWithDynamicImport({
Comment thread
cursor[bot] marked this conversation as resolved.
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All @@ -225,12 +238,24 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably just do this:

if (path.basename(source).startsWith(SERVER_CONFIG_FILENAME)) {

This does not include the forward slash anymore.

return { id: source, moduleSideEffects: true };
// `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows paths,
// but Rollup's resolver only understands filesystem paths.
const resolvable = toResolvablePath(source);
if (!resolvable) {
return null;
}
const { path: normalizedSource, wasFileUrl } = resolvable;

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
if (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (
options.isEntry &&
normalizedSource.includes('.mjs') &&
!normalizedSource.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)
) {
const resolution = await this.resolve(normalizedSource, importer, options);

// If it cannot be resolved or is external, just return it so that Rollup can display an error
if (!resolution || resolution?.external) return resolution;
Comment thread
halillusion marked this conversation as resolved.
Expand All @@ -254,24 +279,36 @@ function wrapEntryWithDynamicImport({
)
.concat(QUERY_END_INDICATOR)}`;
}

// Pass isEntry:false to avoid re-entering the isEntry branch and double-wrapping
// (normalizedSource strips the SENTRY_WRAPPED_ENTRY query suffix).
if (wasFileUrl) {
const resolved = await this.resolve(normalizedSource, importer, { ...options, isEntry: false });
if (resolved) return resolved;
return { id: normalizedSource };
}

return null;
},
load(id: string) {
if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length);
const entryIdUrl = pathToFileURL(entryId).href;
const configUrl = pathToFileURL(resolvedSentryConfigPath).href;

// Use entryIdUrl so Node's runtime ESM loader receives file:// on Windows; Rollup normalizes it in resolveId.
// Mostly useful for serverless `handler` functions
const reExportedFunctions =
id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS)
? constructFunctionReExport(id, entryId)
? constructFunctionReExport(id, entryIdUrl)
Comment thread
sentry[bot] marked this conversation as resolved.
: '';

return (
// Regular `import` of the Sentry config
`import ${JSON.stringify(resolvedSentryConfigPath)};\n` +
`import ${JSON.stringify(configUrl)};\n` +
// Dynamic `import()` for the previous, actual entry point.
// `import()` can be used for any code that should be run after the hooks are registered (https://nodejs.org/api/module.html#enabling)
`import(${JSON.stringify(entryId)});\n` +
`import(${JSON.stringify(entryIdUrl)});\n` +
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
halillusion marked this conversation as resolved.
Comment thread
sentry[bot] marked this conversation as resolved.
`${reExportedFunctions}\n`
);
}
Expand Down
26 changes: 26 additions & 0 deletions packages/nuxt/src/vite/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Nuxt } from '@nuxt/schema';
import { consoleSandbox } from '@sentry/core';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'node:url';
import type { SentryNuxtModuleOptions } from '../common/types';
import { resolvePath } from '@nuxt/kit';

Expand Down Expand Up @@ -199,6 +200,31 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string
);
}

/**
* `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows
* paths (`ERR_UNSUPPORTED_ESM_URL_SCHEME`), but Rollup's resolver only understands
* filesystem paths. Returns `undefined` for a malformed `file://` URL.
*
* Only exported for testing.
*/
export function toResolvablePath(source: string): { path: string; wasFileUrl: boolean } | undefined {
if (!source.startsWith('file://')) {
return { path: source, wasFileUrl: false };
}
if (source === 'file://' || source === 'file:///') {
return undefined;
}
try {
const filePath = fileURLToPath(source);
if (!filePath || filePath === '/' || filePath === '\\') {
return undefined;
}
return { path: filePath, wasFileUrl: true };
} catch {
return undefined;
}
}

/**
* Sets up alias to work around OpenTelemetry's incomplete ESM imports.
* https://github.com/getsentry/sentry-javascript/issues/15204
Expand Down
115 changes: 112 additions & 3 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,116 @@
import type { Nuxt } from '@nuxt/schema';
import { fileURLToPath, pathToFileURL } from 'node:url';
import * as path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { addDevServerConfigFile, DEV_SERVER_CONFIG_PATH } from '../../src/vite/addServerConfig';
import {
addDevServerConfigFile,
DEV_SERVER_CONFIG_PATH,
wrapEntryWithDynamicImport,
} from '../../src/vite/addServerConfig';
import {
QUERY_END_INDICATOR,
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
toResolvablePath,
} from '../../src/vite/utils';

const configPath = '/project/sentry.server.config.ts';
const entryPath = '/project/.nuxt/entry.mjs';

describe('toResolvablePath', () => {
it('passes through non-file specifiers', () => {
expect(toResolvablePath('./module')).toEqual({ path: './module', wasFileUrl: false });
expect(toResolvablePath(configPath)).toEqual({ path: configPath, wasFileUrl: false });
});

it('converts file:// URLs to filesystem paths', () => {
const url = pathToFileURL(entryPath).href;
expect(toResolvablePath(url)).toEqual({ path: fileURLToPath(url), wasFileUrl: true });
});

it('returns undefined for malformed file:// URLs', () => {
expect(toResolvablePath('file://')).toBeUndefined();
expect(toResolvablePath('file:///')).toBeUndefined();
expect(toResolvablePath('file://%E0%A4%A')).toBeUndefined();
});
});

describe('wrapEntryWithDynamicImport', () => {
const plugin = wrapEntryWithDynamicImport({
resolvedSentryConfigPath: configPath,
experimental_entrypointWrappedFunctions: ['handler'],
}) as unknown as {
resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise<unknown>;
load: (id: string) => string | null;
};
const { resolveId, load } = plugin;

it('emits file:// URLs from load() so Node resolves them on Windows', () => {
const code = load.call({}, `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`);

expect(code).toContain(`import ${JSON.stringify(pathToFileURL(configPath).href)}`);
expect(code).toContain(`import(${JSON.stringify(pathToFileURL(entryPath).href)})`);
expect(code).not.toContain(`import ${JSON.stringify(configPath)}`);
});

it('uses file:// URLs for re-exported functions', () => {
const id = `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${SENTRY_REEXPORTED_FUNCTIONS}handler${QUERY_END_INDICATOR}`;
const code = load.call({}, id);

expect(code).toContain(`export { handler } from ${JSON.stringify(pathToFileURL(entryPath).href)}`);
});

it('resolves a file:// config URL to a filesystem path with moduleSideEffects', async () => {
const source = pathToFileURL(configPath).href;
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, source, undefined, { isEntry: false });

expect(result).toEqual({ id: fileURLToPath(source), moduleSideEffects: true });
});

it('resolves a plain config path without converting it', async () => {
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, configPath, undefined, { isEntry: false });

expect(result).toEqual({ id: configPath, moduleSideEffects: true });
});

it('does not mark backup or test config files as the Sentry server config', async () => {
const backupPath = '/project/sentry.server.config.backup.ts';
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, backupPath, undefined, { isEntry: false });

expect(result).toBeNull();
});

it('resolves file:// entry specifiers without re-entering the entry branch', async () => {
const source = pathToFileURL(entryPath).href;
const fakeResolve = vi.fn(async () => ({ id: 'resolved-id', external: false }));
const result = await resolveId.call({ resolve: fakeResolve, load: vi.fn() }, source, undefined, { isEntry: false });

expect(fakeResolve).toHaveBeenCalledWith(
fileURLToPath(source),
undefined,
expect.objectContaining({ isEntry: false }),
);
expect(result).toEqual({ id: 'resolved-id', external: false });
});

it('returns null for malformed file:// URLs', async () => {
const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, 'file://', undefined, { isEntry: false });

expect(result).toBeNull();
});

it('wraps the entry with the dynamic-import query suffix', async () => {
const fakeResolve = vi.fn(async () => ({ id: entryPath, external: false }));
const fakeLoad = vi.fn(async () => ({ exportedBindings: { '.': ['handler'] }, moduleSideEffects: false }));
const result = await resolveId.call({ resolve: fakeResolve, load: fakeLoad }, entryPath, undefined, {
isEntry: true,
});

expect(result).toContain(SENTRY_WRAPPED_ENTRY);
expect(result).toContain('?sentry-query-wrapped-functions=handler');
expect(result?.startsWith('\0raw')).toBe(true);
});
});

const addTemplateMock = vi.hoisted(() => vi.fn());

Expand Down Expand Up @@ -40,7 +149,7 @@ describe('addDevServerConfigFile', () => {
});

it('imports the user config as a file URL so Node can load it directly', () => {
expect(generate(APP_CONFIG)).toContain(`await import("file://${APP_CONFIG}")`);
expect(generate(APP_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(APP_CONFIG).href)})`);
});

it('sets the dev flag before importing the config', () => {
Expand All @@ -64,7 +173,7 @@ describe('addDevServerConfigFile', () => {

describe('when the config comes from a layer outside the project root', () => {
it('imports the config from the layer it belongs to', () => {
expect(generate(LAYER_CONFIG)).toContain(`await import("file://${LAYER_CONFIG}")`);
expect(generate(LAYER_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(LAYER_CONFIG).href)})`);
});

it('keeps the preload path relative to the project root', () => {
Expand Down
Loading
Loading