Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff 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 number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { isAbsolute, join } from 'node:path';
import esbuild from 'rollup-plugin-esbuild';

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

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

const transpile = esbuild({
target: 'es2020',
Expand Down
61 changes: 50 additions & 11 deletions packages/nuxt/src/vite/addServerConfig.ts
Original file line number Diff line number Diff 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 Down Expand 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 number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Nuxt } from '@nuxt/schema';
import { consoleSandbox } from '@sentry/core';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'node:url';
import type { SentryNuxtModuleOptions } from '../common/types';
import { resolvePath } from '@nuxt/kit';

Expand Down Expand Up @@ -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 number Diff line number Diff 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 number Diff line number Diff 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 Down Expand 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 Down Expand 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