diff --git a/packages/nuxt/rollup.module.config.mjs b/packages/nuxt/rollup.module.config.mjs index f53676433cef..991ea9c58fae 100644 --- a/packages/nuxt/rollup.module.config.mjs +++ b/packages/nuxt/rollup.module.config.mjs @@ -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`: @@ -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', diff --git a/packages/nuxt/src/vite/addServerConfig.ts b/packages/nuxt/src/vite/addServerConfig.ts index e88e2ff514f7..754bdcd8592f 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs'; +import { basename } from 'node:path'; import { pathToFileURL } from 'node:url'; import { addTemplate, createResolver } from '@nuxt/kit'; import type { Nuxt } from '@nuxt/schema'; @@ -20,6 +21,7 @@ import { SENTRY_WRAPPED_FUNCTIONS, SERVER_CONFIG_FILENAME, toImportSpecifier, + toResolvablePath, } from './utils'; /** Path of the generated dev-mode config file, relative to the Nuxt build directory. */ @@ -31,7 +33,7 @@ export const DEV_SERVER_CONFIG_PATH = `dev/${SERVER_CONFIG_FILENAME}.mjs`; * In dev-mode, Nitro v3 has no server bundle to emit into, so Node loads the server config file as it is written. */ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): void { - const configPath = createResolver(nuxt.options.rootDir).resolve(`/${serverConfigFile}`); + const configPath = createResolver(nuxt.options.rootDir).resolve(serverConfigFile); const importSpecifier = toImportSpecifier( nuxt.options.rootDir, path.join(nuxt.options.buildDir, DEV_SERVER_CONFIG_PATH), @@ -59,6 +61,15 @@ export function addDevServerConfigFile(nuxt: Nuxt, serverConfigFile: string): vo ].join('\n'), }); } +const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + +function isServerConfigFile(sourcePath: string, resolvedPath: string): boolean { + if (sourcePath === resolvedPath) { + return true; + } + const name = basename(sourcePath); + return name === SERVER_CONFIG_FILENAME || CONFIG_EXTENSIONS.some(ext => name === `${SERVER_CONFIG_FILENAME}${ext}`); +} /** * Adds the `sentry.server.config.ts` file as `sentry.server.config.mjs` to the `.output` directory to be able to reference this file in the node --import option. @@ -157,7 +168,7 @@ export function addDynamicImportEntryFileWrapper( nitro.options.rollupConfig.plugins.push( wrapEntryWithDynamicImport({ - resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`), + resolvedSentryConfigPath: createResolver(nitro.options.rootDir).resolve(serverConfigFile), experimental_entrypointWrappedFunctions: moduleOptions.experimental_entrypointWrappedFunctions, }), ); @@ -173,7 +184,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu name: 'rollup-plugin-inject-sentry-server-config', buildStart() { - const configPath = createResolver(nitro.options.rootDir).resolve(`/${serverConfigFile}`); + const configPath = createResolver(nitro.options.rootDir).resolve(serverConfigFile); if (!existsSync(configPath)) { if (isDebug) { @@ -193,7 +204,7 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu resolveId(source) { if (source.startsWith(filePrefix)) { const originalFilePath = source.replace(filePrefix, ''); - const configPath = createResolver(nitro.options.rootDir).resolve(`/${originalFilePath}`); + const configPath = createResolver(nitro.options.rootDir).resolve(originalFilePath); return { id: configPath }; } @@ -206,8 +217,10 @@ function injectServerConfigPlugin(nitro: Nitro, serverConfigFile: string, isDebu * A Rollup plugin which wraps the server entry with a dynamic `import()`. This makes it possible to initialize Sentry first * by using a regular `import` and load the server after that. * This also works with serverless `handler` functions, as it re-exports the `handler`. + * + * Only exported for testing. */ -function wrapEntryWithDynamicImport({ +export function wrapEntryWithDynamicImport({ resolvedSentryConfigPath, experimental_entrypointWrappedFunctions, debug, @@ -225,12 +238,24 @@ function wrapEntryWithDynamicImport({ return { name: 'sentry-wrap-entry-with-dynamic-import', async resolveId(source, importer, options) { - if (source.includes(`/${SERVER_CONFIG_FILENAME}`)) { - return { id: source, moduleSideEffects: true }; + // `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows paths, + // but Rollup's resolver only understands filesystem paths. + const resolvable = toResolvablePath(source); + if (!resolvable) { + return null; } + const { path: normalizedSource, wasFileUrl } = resolvable; - if (options.isEntry && source.includes('.mjs') && !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { - const resolution = await this.resolve(source, importer, options); + if (isServerConfigFile(normalizedSource, resolvedSentryConfigPath)) { + return { id: normalizedSource, moduleSideEffects: true }; + } + + 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; @@ -254,24 +279,36 @@ function wrapEntryWithDynamicImport({ ) .concat(QUERY_END_INDICATOR)}`; } + + // Pass isEntry:false to avoid re-entering the isEntry branch and double-wrapping + // (normalizedSource strips the SENTRY_WRAPPED_ENTRY query suffix). + if (wasFileUrl) { + const resolved = await this.resolve(normalizedSource, importer, { ...options, isEntry: false }); + if (resolved) return resolved; + return { id: normalizedSource }; + } + return null; }, load(id: string) { if (id.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`)) { const entryId = removeSentryQueryFromPath(id).slice(resolutionIdPrefix.length); + const entryIdUrl = pathToFileURL(entryId).href; + const configUrl = pathToFileURL(resolvedSentryConfigPath).href; + // Use entryIdUrl so Node's runtime ESM loader receives file:// on Windows; Rollup normalizes it in resolveId. // Mostly useful for serverless `handler` functions const reExportedFunctions = id.includes(SENTRY_WRAPPED_FUNCTIONS) || id.includes(SENTRY_REEXPORTED_FUNCTIONS) - ? constructFunctionReExport(id, entryId) + ? constructFunctionReExport(id, entryIdUrl) : ''; 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` + `${reExportedFunctions}\n` ); } diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 976320676236..d1f0e95843b4 100644 --- a/packages/nuxt/src/vite/utils.ts +++ b/packages/nuxt/src/vite/utils.ts @@ -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'; @@ -199,6 +200,31 @@ export function constructFunctionReExport(pathWithQuery: string, entryId: string ); } +/** + * `load()` emits `file://` specifiers because Node's ESM loader rejects bare Windows + * paths (`ERR_UNSUPPORTED_ESM_URL_SCHEME`), but Rollup's resolver only understands + * filesystem paths. Returns `undefined` for a malformed `file://` URL. + * + * Only exported for testing. + */ +export function toResolvablePath(source: string): { path: string; wasFileUrl: boolean } | undefined { + if (!source.startsWith('file://')) { + return { path: source, wasFileUrl: false }; + } + if (source === 'file://' || source === 'file:///') { + return undefined; + } + try { + const filePath = fileURLToPath(source); + if (!filePath || filePath === '/' || filePath === '\\') { + return undefined; + } + return { path: filePath, wasFileUrl: true }; + } catch { + return undefined; + } +} + /** * Sets up alias to work around OpenTelemetry's incomplete ESM imports. * https://github.com/getsentry/sentry-javascript/issues/15204 diff --git a/packages/nuxt/test/vite/addServerConfig.test.ts b/packages/nuxt/test/vite/addServerConfig.test.ts index c82ffdebbde1..54fdc0e1a33d 100644 --- a/packages/nuxt/test/vite/addServerConfig.test.ts +++ b/packages/nuxt/test/vite/addServerConfig.test.ts @@ -1,7 +1,116 @@ import type { Nuxt } from '@nuxt/schema'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import * as path from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { addDevServerConfigFile, DEV_SERVER_CONFIG_PATH } from '../../src/vite/addServerConfig'; +import { + addDevServerConfigFile, + DEV_SERVER_CONFIG_PATH, + wrapEntryWithDynamicImport, +} from '../../src/vite/addServerConfig'; +import { + QUERY_END_INDICATOR, + SENTRY_REEXPORTED_FUNCTIONS, + SENTRY_WRAPPED_ENTRY, + toResolvablePath, +} from '../../src/vite/utils'; + +const configPath = '/project/sentry.server.config.ts'; +const entryPath = '/project/.nuxt/entry.mjs'; + +describe('toResolvablePath', () => { + it('passes through non-file specifiers', () => { + expect(toResolvablePath('./module')).toEqual({ path: './module', wasFileUrl: false }); + expect(toResolvablePath(configPath)).toEqual({ path: configPath, wasFileUrl: false }); + }); + + it('converts file:// URLs to filesystem paths', () => { + const url = pathToFileURL(entryPath).href; + expect(toResolvablePath(url)).toEqual({ path: fileURLToPath(url), wasFileUrl: true }); + }); + + it('returns undefined for malformed file:// URLs', () => { + expect(toResolvablePath('file://')).toBeUndefined(); + expect(toResolvablePath('file:///')).toBeUndefined(); + expect(toResolvablePath('file://%E0%A4%A')).toBeUndefined(); + }); +}); + +describe('wrapEntryWithDynamicImport', () => { + const plugin = wrapEntryWithDynamicImport({ + resolvedSentryConfigPath: configPath, + experimental_entrypointWrappedFunctions: ['handler'], + }) as unknown as { + resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise; + load: (id: string) => string | null; + }; + const { resolveId, load } = plugin; + + it('emits file:// URLs from load() so Node resolves them on Windows', () => { + const code = load.call({}, `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${QUERY_END_INDICATOR}`); + + expect(code).toContain(`import ${JSON.stringify(pathToFileURL(configPath).href)}`); + expect(code).toContain(`import(${JSON.stringify(pathToFileURL(entryPath).href)})`); + expect(code).not.toContain(`import ${JSON.stringify(configPath)}`); + }); + + it('uses file:// URLs for re-exported functions', () => { + const id = `\0raw${entryPath}${SENTRY_WRAPPED_ENTRY}${SENTRY_REEXPORTED_FUNCTIONS}handler${QUERY_END_INDICATOR}`; + const code = load.call({}, id); + + expect(code).toContain(`export { handler } from ${JSON.stringify(pathToFileURL(entryPath).href)}`); + }); + + it('resolves a file:// config URL to a filesystem path with moduleSideEffects', async () => { + const source = pathToFileURL(configPath).href; + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, source, undefined, { isEntry: false }); + + expect(result).toEqual({ id: fileURLToPath(source), moduleSideEffects: true }); + }); + + it('resolves a plain config path without converting it', async () => { + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, configPath, undefined, { isEntry: false }); + + expect(result).toEqual({ id: configPath, moduleSideEffects: true }); + }); + + it('does not mark backup or test config files as the Sentry server config', async () => { + const backupPath = '/project/sentry.server.config.backup.ts'; + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, backupPath, undefined, { isEntry: false }); + + expect(result).toBeNull(); + }); + + it('resolves file:// entry specifiers without re-entering the entry branch', async () => { + const source = pathToFileURL(entryPath).href; + const fakeResolve = vi.fn(async () => ({ id: 'resolved-id', external: false })); + const result = await resolveId.call({ resolve: fakeResolve, load: vi.fn() }, source, undefined, { isEntry: false }); + + expect(fakeResolve).toHaveBeenCalledWith( + fileURLToPath(source), + undefined, + expect.objectContaining({ isEntry: false }), + ); + expect(result).toEqual({ id: 'resolved-id', external: false }); + }); + + it('returns null for malformed file:// URLs', async () => { + const result = await resolveId.call({ resolve: vi.fn(), load: vi.fn() }, 'file://', undefined, { isEntry: false }); + + expect(result).toBeNull(); + }); + + it('wraps the entry with the dynamic-import query suffix', async () => { + const fakeResolve = vi.fn(async () => ({ id: entryPath, external: false })); + const fakeLoad = vi.fn(async () => ({ exportedBindings: { '.': ['handler'] }, moduleSideEffects: false })); + const result = await resolveId.call({ resolve: fakeResolve, load: fakeLoad }, entryPath, undefined, { + isEntry: true, + }); + + expect(result).toContain(SENTRY_WRAPPED_ENTRY); + expect(result).toContain('?sentry-query-wrapped-functions=handler'); + expect(result?.startsWith('\0raw')).toBe(true); + }); +}); const addTemplateMock = vi.hoisted(() => vi.fn()); @@ -40,7 +149,7 @@ describe('addDevServerConfigFile', () => { }); it('imports the user config as a file URL so Node can load it directly', () => { - expect(generate(APP_CONFIG)).toContain(`await import("file://${APP_CONFIG}")`); + expect(generate(APP_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(APP_CONFIG).href)})`); }); it('sets the dev flag before importing the config', () => { @@ -64,7 +173,7 @@ describe('addDevServerConfigFile', () => { describe('when the config comes from a layer outside the project root', () => { it('imports the config from the layer it belongs to', () => { - expect(generate(LAYER_CONFIG)).toContain(`await import("file://${LAYER_CONFIG}")`); + expect(generate(LAYER_CONFIG)).toContain(`await import(${JSON.stringify(pathToFileURL(LAYER_CONFIG).href)})`); }); it('keeps the preload path relative to the project root', () => { diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index fc8147197f70..3d135a489b6f 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -38,7 +38,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}`)); }, ); @@ -50,7 +50,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}`)); }, ); @@ -69,7 +69,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' }); }, ); @@ -89,7 +89,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' }); }, ); @@ -136,7 +136,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 () => { @@ -158,12 +158,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 = { @@ -180,7 +183,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')); }); }); diff --git a/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts b/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts index ef22da065d14..6e19df28a3e2 100644 --- a/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts +++ b/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts @@ -1,3 +1,5 @@ +import { basename } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { consoleSandbox } from '@sentry/core'; import type { InputPluginOption } from 'rollup'; @@ -8,6 +10,41 @@ export const SENTRY_WRAPPED_FUNCTIONS = '?sentry-query-wrapped-functions='; export const SENTRY_REEXPORTED_FUNCTIONS = '?sentry-query-reexported-functions='; export const QUERY_END_INDICATOR = 'SENTRY-QUERY-END'; +/** + * `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; + } +} + +const CONFIG_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']; + +function isServerConfigFile(sourcePath: string, resolvedPath: string, configFileName: string): boolean { + if (sourcePath === resolvedPath) { + return true; + } + const name = basename(sourcePath); + return name === configFileName || CONFIG_EXTENSIONS.some(ext => name === `${configFileName}${ext}`); +} + export type WrapServerEntryPluginOptions = { serverEntrypointFileName: string; serverConfigFileName: string; @@ -46,17 +83,25 @@ export function wrapServerEntryWithDynamicImport(config: WrapServerEntryPluginOp return { name: 'sentry-wrap-server-entry-with-dynamic-import', async resolveId(source, importer, options) { - if (source.includes(`/${serverConfigFileName}`)) { - 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, resolvedServerConfigPath, serverConfigFileName)) { + return { id: normalizedSource, moduleSideEffects: true }; } if ( options.isEntry && - source.includes(serverEntrypointFileName) && - source.includes('.mjs') && - !source.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) + normalizedSource.includes(serverEntrypointFileName) && + normalizedSource.includes('.mjs') && + !normalizedSource.includes(`.mjs${SENTRY_WRAPPED_ENTRY}`) ) { - const resolution = await this.resolve(source, importer, options); + 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; @@ -76,24 +121,35 @@ export function wrapServerEntryWithDynamicImport(config: WrapServerEntryPluginOp ) .concat(QUERY_END_INDICATOR)}`; } + + // Pass isEntry:false to avoid double-wrapping (normalizedSource lacks 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(resolvedServerConfigPath).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(resolvedServerConfigPath)};\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` + `${reExportedFunctions}\n` ); } diff --git a/packages/solidstart/test/config/wrapServerEntryWithDynamicImport.test.ts b/packages/solidstart/test/config/wrapServerEntryWithDynamicImport.test.ts new file mode 100644 index 000000000000..1b3ea9dfb0d2 --- /dev/null +++ b/packages/solidstart/test/config/wrapServerEntryWithDynamicImport.test.ts @@ -0,0 +1,109 @@ +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { + QUERY_END_INDICATOR, + SENTRY_REEXPORTED_FUNCTIONS, + SENTRY_WRAPPED_ENTRY, + toResolvablePath, + wrapServerEntryWithDynamicImport, +} from '../../src/config/wrapServerEntryWithDynamicImport'; + +const configPath = '/project/instrument.server.ts'; +const entryPath = '/project/.build/server/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('wrapServerEntryWithDynamicImport', () => { + const plugin = wrapServerEntryWithDynamicImport({ + serverConfigFileName: 'instrument.server', + serverEntrypointFileName: 'entry', + resolvedServerConfigPath: configPath, + entrypointWrappedFunctions: ['handler'], + }) as unknown as { + resolveId: (source: string, importer: string | undefined, options: { isEntry?: boolean }) => Promise; + 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 server config', async () => { + const backupPath = '/project/instrument.server.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); + }); +});