diff --git a/CHANGELOG.md b/CHANGELOG.md index e16918310df8..d3a85f591cf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 7638764bbfc2..5a7a27a35e43 100644 --- a/packages/nuxt/src/vite/addServerConfig.ts +++ b/packages/nuxt/src/vite/addServerConfig.ts @@ -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'; @@ -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. * @@ -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, }), ); @@ -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) { @@ -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 }; } @@ -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, @@ -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') { @@ -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; @@ -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` diff --git a/packages/nuxt/src/vite/utils.ts b/packages/nuxt/src/vite/utils.ts index 58288302ae0d..ce77aed94fcc 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'; @@ -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 diff --git a/packages/nuxt/test/vite/addServerConfig.test.ts b/packages/nuxt/test/vite/addServerConfig.test.ts new file mode 100644 index 000000000000..e0c9187f575d --- /dev/null +++ b/packages/nuxt/test/vite/addServerConfig.test.ts @@ -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; + 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); + }); +}); diff --git a/packages/nuxt/test/vite/utils.test.ts b/packages/nuxt/test/vite/utils.test.ts index 359ea36452e9..4098dedf102d 100644 --- a/packages/nuxt/test/vite/utils.test.ts +++ b/packages/nuxt/test/vite/utils.test.ts @@ -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, @@ -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}`)); }, ); @@ -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}`)); }, ); @@ -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' }); }, ); @@ -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' }); }, ); @@ -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 () => { @@ -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 = { @@ -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')); }); }); diff --git a/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts b/packages/solidstart/src/config/wrapServerEntryWithDynamicImport.ts index 24c21b405cc4..088654d1823a 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; @@ -49,8 +86,16 @@ 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 (additionalImports?.includes(source)) { @@ -63,11 +108,11 @@ export function wrapServerEntryWithDynamicImport(config: WrapServerEntryPluginOp 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; @@ -87,24 +132,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` + // By importing additional imports like "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()`. `${additionalImports ? additionalImports.map(importPath => `import "${importPath}";\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); + }); +});