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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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