Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

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
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,8 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott

Work in this release was contributed by @halillusion. Thank you for your contribution!

## 10.73.0

### Important Changes
Expand Down
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');

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
import { existsSync } from 'node:fs';
import { basename } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createResolver } from '@nuxt/kit';
import { debug } from '@sentry/core';
import * as fs from 'fs';
Expand All@@ -14,10 +16,21 @@ import {
SENTRY_REEXPORTED_FUNCTIONS,
SENTRY_WRAPPED_ENTRY,
SENTRY_WRAPPED_FUNCTIONS,
toResolvablePath,
} from './utils';

const SERVER_CONFIG_FILENAME = 'sentry.server.config';

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@@ -115,7 +128,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@@ -131,7 +144,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@@ -151,7 +164,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@@ -164,8 +177,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({
export function wrapEntryWithDynamicImport({
resolvedSentryConfigPath,
experimental_entrypointWrappedFunctions,
debug,
Expand All@@ -183,8 +198,16 @@ function wrapEntryWithDynamicImport({
return {
name: 'sentry-wrap-entry-with-dynamic-import',
async resolveId(source, importer, options) {
if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) {
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 (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) {
return { id: normalizedSource, moduleSideEffects: true };
}

if (source === 'import-in-the-middle/hook.mjs') {
Expand All@@ -195,8 +218,12 @@ function wrapEntryWithDynamicImport({
return { id: source, moduleSideEffects: true, external: true };
}

if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) {
const resolution = await this.resolve(source, importer, options);
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;
Expand All@@ -220,24 +247,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)
: '';

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` +
// By importing "import-in-the-middle/hook.mjs", we can make sure this file wil be included, as not all node builders are including files imported with `module.register()`.
"import 'import-in-the-middle/hook.mjs';\n" +
`${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@@ -204,6 +205,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
107 changes: 107 additions & 0 deletions packages/nuxt/test/vite/addServerConfig.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
import { fileURLToPath, pathToFileURL } from 'node:url';
import { describe, expect, it, vi } from 'vitest';
import { 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);
});
});
20 changes: 12 additions & 8 deletions packages/nuxt/test/vite/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import type { Nuxt } from '@nuxt/schema';
import * as fs from 'fs';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
addOTelCommonJSImportAlias,
Expand DownExpand Up@@ -36,7 +37,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('server');
expect(result).toMatch(`packages/nuxt/sentry.server.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.server.config.${ext}`));
},
);

Expand All@@ -48,7 +49,7 @@ describe('findDefaultSdkInitFile', () => {
});

const result = await findDefaultSdkInitFile('client');
expect(result).toMatch(`packages/nuxt/sentry.client.config.${ext}`);
expect(result).toMatch(path.join('packages', 'nuxt', `sentry.client.config.${ext}`));
},
);

Expand All@@ -67,7 +68,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.client.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.client.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand All@@ -87,7 +88,7 @@ describe('findDefaultSdkInitFile', () => {
configDir: '~/config',
});

expect(result).toBe(`${baseDir}/sentry.server.config.${ext}`);
expect(result).toBe(path.resolve(baseDir, `sentry.server.config.${ext}`));
expect(resolvePathMock).toHaveBeenCalledWith('~/config', { type: 'dir' });
},
);
Expand DownExpand Up@@ -138,7 +139,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.client.config.ts'));
});

it('should return the latest layer config file path if server config exists', async () => {
Expand All@@ -164,12 +165,15 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('server', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.server.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'sentry.server.config.ts'));
});

it('should return the latest layer config file path if client config exists in former layer', async () => {
vi.spyOn(fs, 'existsSync').mockImplementation(filePath => {
return !(filePath instanceof URL) && filePath.toString().includes('nuxt/sentry.client.config.ts');
return (
!(filePath instanceof URL) &&
filePath.toString().includes(path.join('nuxt', 'module', 'sentry.client.config.ts'))
);
});

const nuxtMock = {
Expand All@@ -186,7 +190,7 @@ describe('findDefaultSdkInitFile', () => {
} as unknown as Nuxt;

const result = await findDefaultSdkInitFile('client', nuxtMock);
expect(result).toMatch('packages/nuxt/sentry.client.config.ts');
expect(result).toMatch(path.join('packages', 'nuxt', 'module', 'sentry.client.config.ts'));
});
});

Expand Down
Loading
Loading