diff --git a/packages/sveltekit/.eslintrc.js b/packages/sveltekit/.eslintrc.js index c3ca0faa363b..4a8474637698 100644 --- a/packages/sveltekit/.eslintrc.js +++ b/packages/sveltekit/.eslintrc.js @@ -17,6 +17,12 @@ module.exports = { project: ['tsconfig.test.json'], }, }, + { + files: ['src/vite/**', 'src/server/**'], + rules: { + '@sentry-internal/sdk/no-optional-chaining': 'off', + }, + }, ], extends: ['../../.eslintrc.js'], }; diff --git a/packages/sveltekit/src/server/utils.ts b/packages/sveltekit/src/server/utils.ts index 17fc855ebc16..8ef6acced314 100644 --- a/packages/sveltekit/src/server/utils.ts +++ b/packages/sveltekit/src/server/utils.ts @@ -1,8 +1,16 @@ import type { DynamicSamplingContext, StackFrame, TraceparentData } from '@sentry/types'; -import { baggageHeaderToDynamicSamplingContext, basename, extractTraceparentData } from '@sentry/utils'; +import { + baggageHeaderToDynamicSamplingContext, + basename, + escapeStringForRegex, + extractTraceparentData, + GLOBAL_OBJ, + join, +} from '@sentry/utils'; import type { RequestEvent } from '@sveltejs/kit'; import { WRAPPED_MODULE_SUFFIX } from '../vite/autoInstrument'; +import type { GlobalWithSentryValues } from '../vite/injectGlobalValues'; /** * Takes a request event and extracts traceparent and DSC data @@ -35,7 +43,8 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame { if (!frame.filename) { return frame; } - + const globalWithSentryValues: GlobalWithSentryValues = GLOBAL_OBJ; + const svelteKitBuildOutDir = globalWithSentryValues.__sentry_sveltekit_output_dir; const prefix = 'app:///'; // Check if the frame filename begins with `/` or a Windows-style prefix such as `C:\` @@ -48,8 +57,16 @@ export function rewriteFramesIteratee(frame: StackFrame): StackFrame { .replace(/\\/g, '/') // replace all `\\` instances with `/` : frame.filename; - const base = basename(filename); - frame.filename = `${prefix}${base}`; + let strippedFilename; + if (svelteKitBuildOutDir) { + strippedFilename = filename.replace( + new RegExp(`^.*${escapeStringForRegex(join(svelteKitBuildOutDir, 'server'))}/`), + '', + ); + } else { + strippedFilename = basename(filename); + } + frame.filename = `${prefix}${strippedFilename}`; } delete frame.module; diff --git a/packages/sveltekit/src/vite/injectGlobalValues.ts b/packages/sveltekit/src/vite/injectGlobalValues.ts new file mode 100644 index 000000000000..d0f6424a338d --- /dev/null +++ b/packages/sveltekit/src/vite/injectGlobalValues.ts @@ -0,0 +1,41 @@ +import type { InternalGlobal } from '@sentry/utils'; + +export type GlobalSentryValues = { + __sentry_sveltekit_output_dir?: string; +}; + +/** + * Extend the `global` type with custom properties that are + * injected by the SvelteKit SDK at build time. + * @see packages/sveltekit/src/vite/sourcemaps.ts + */ +export type GlobalWithSentryValues = InternalGlobal & GlobalSentryValues; + +export const VIRTUAL_GLOBAL_VALUES_FILE = '\0sentry-inject-global-values-file'; + +/** + * @returns code that injects @param globalSentryValues into the global object. + */ +export function getGlobalValueInjectionCode(globalSentryValues: GlobalSentryValues): string { + if (Object.keys(globalSentryValues).length === 0) { + return ''; + } + + const sentryGlobal = '_global'; + + const globalCode = `var ${sentryGlobal} = + typeof window !== 'undefined' ? + window : + typeof globalThis !== 'undefined' ? + globalThis : + typeof global !== 'undefined' ? + global : + typeof self !== 'undefined' ? + self : + {};`; + const injectedValuesCode = Object.entries(globalSentryValues) + .map(([key, value]) => `${sentryGlobal}["${key}"] = ${JSON.stringify(value)};`) + .join('\n'); + + return `${globalCode}\n${injectedValuesCode}\n`; +} diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index e78a25adea10..60852424ded2 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -103,6 +103,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} const pluginOptions = { ...mergedOptions.sourceMapsUploadOptions, debug: mergedOptions.debug, // override the plugin's debug flag with the one from the top-level options + adapter: mergedOptions.adapter, }; sentryPlugins.push(await makeCustomSentryVitePlugin(pluginOptions)); } diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index 8c7662a93813..6f2b7086786a 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -11,7 +11,10 @@ import * as sorcery from 'sorcery'; import type { Plugin } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from './autoInstrument'; -import { getAdapterOutputDir, loadSvelteConfig } from './svelteConfig'; +import type { SupportedSvelteKitAdapters } from './detectAdapter'; +import type { GlobalSentryValues } from './injectGlobalValues'; +import { getGlobalValueInjectionCode, VIRTUAL_GLOBAL_VALUES_FILE } from './injectGlobalValues'; +import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from './svelteConfig'; // sorcery has no types, so these are some basic type definitions: type Chain = { @@ -26,6 +29,10 @@ type SentryVitePluginOptionsOptionalInclude = Omit { +export async function makeCustomSentryVitePlugin(options?: CustomSentryVitePluginOptions): Promise { const svelteConfig = await loadSvelteConfig(); - const outputDir = await getAdapterOutputDir(svelteConfig); + const usedAdapter = options?.adapter || 'other'; + const outputDir = await getAdapterOutputDir(svelteConfig, usedAdapter); const hasSentryProperties = fs.existsSync(path.resolve(process.cwd(), 'sentry.properties')); const defaultPluginOptions: SentryVitePluginOptions = { - include: [ - { paths: [`${outputDir}/client`] }, - { paths: [`${outputDir}/server/chunks`] }, - { paths: [`${outputDir}/server`], ignore: ['chunks/**'] }, - ], + include: [`${outputDir}/client`, `${outputDir}/server`], configFile: hasSentryProperties ? 'sentry.properties' : undefined, release, }; @@ -70,10 +74,16 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio const sentryPlugin: Plugin = sentryVitePlugin(mergedOptions); const { debug } = mergedOptions; - const { buildStart, resolveId, transform, renderChunk } = sentryPlugin; + const { buildStart, renderChunk } = sentryPlugin; let isSSRBuild = true; + const serverHooksFile = getHooksFileName(svelteConfig, 'server'); + + const globalSentryValues: GlobalSentryValues = { + __sentry_sveltekit_output_dir: outputDir, + }; + const customPlugin: Plugin = { name: 'sentry-upload-source-maps', apply: 'build', // only apply this plugin at build time @@ -82,9 +92,7 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio // These hooks are copied from the original Sentry Vite plugin. // They're mostly responsible for options parsing and release injection. buildStart, - resolveId, renderChunk, - transform, // Modify the config to generate source maps config: config => { @@ -99,6 +107,27 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio }; }, + resolveId: (id, _importer, _ref) => { + if (id === VIRTUAL_GLOBAL_VALUES_FILE) { + return { + id: VIRTUAL_GLOBAL_VALUES_FILE, + external: false, + moduleSideEffects: true, + }; + } + // @ts-ignore - this hook exists on the plugin! + return sentryPlugin.resolveId(id, _importer, _ref); + }, + + load: id => { + if (id === VIRTUAL_GLOBAL_VALUES_FILE) { + return { + code: getGlobalValueInjectionCode(globalSentryValues), + }; + } + return null; + }, + configResolved: config => { // The SvelteKit plugins trigger additional builds within the main (SSR) build. // We just need a mechanism to upload source maps only once. @@ -109,6 +138,18 @@ export async function makeCustomSentryVitePlugin(options?: SentryVitePluginOptio } }, + transform: async (code, id) => { + let modifiedCode = code; + const isServerHooksFile = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`).test(id); + + if (isServerHooksFile) { + const globalValuesImport = `; import "${VIRTUAL_GLOBAL_VALUES_FILE}";`; + modifiedCode = `${code}\n${globalValuesImport}\n`; + } + // @ts-ignore - this hook exists on the plugin! + return sentryPlugin.transform(modifiedCode, id); + }, + // We need to start uploading source maps later than in the original plugin // because SvelteKit is invoking the adapter at closeBundle. // This means that we need to wait until the adapter is done before we start uploading. diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 702e29cb9c3f..07c701e912f1 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -5,6 +5,8 @@ import * as fs from 'fs'; import * as path from 'path'; import * as url from 'url'; +import type { SupportedSvelteKitAdapters } from './detectAdapter'; + /** * Imports the svelte.config.js file and returns the config object. * The sveltekit plugins import the config in the same way. @@ -35,20 +37,38 @@ export async function loadSvelteConfig(): Promise { } } +/** + * Reads a custom hooks directory from the SvelteKit config. In case no custom hooks + * directory is specified, the default directory is returned. + */ +export function getHooksFileName(svelteConfig: Config, hookType: 'client' | 'server'): string { + return svelteConfig.kit?.files?.hooks?.[hookType] || `src/hooks.${hookType}`; +} + /** * Attempts to read a custom output directory that can be specidied in the options * of a SvelteKit adapter. If no custom output directory is specified, the default * directory is returned. - * - * To get the directory, we have to apply a hack and call the adapter's adapt method + */ +export async function getAdapterOutputDir(svelteConfig: Config, adapter: SupportedSvelteKitAdapters): Promise { + if (adapter === 'node') { + return await getNodeAdapterOutputDir(svelteConfig); + } + + // Auto and Vercel adapters simply use config.kit.outDir + // Let's also use this directory for the 'other' case + return path.join(svelteConfig.kit?.outDir || '.svelte-kit', 'output'); +} + +/** + * To get the Node adapter output directory, we have to apply a hack and call the adapter's adapt method * with a custom adapter `Builder` that only calls the `writeClient` method. * This method is the first method that is called with the output directory. * Once we obtained the output directory, we throw an error to exit the adapter. * * see: https://github.com/sveltejs/kit/blob/master/packages/adapter-node/index.js#L17 - * */ -export async function getAdapterOutputDir(svelteConfig: Config): Promise { +async function getNodeAdapterOutputDir(svelteConfig: Config): Promise { // 'build' is the default output dir for the node adapter let outputDir = 'build'; @@ -56,7 +76,7 @@ export async function getAdapterOutputDir(svelteConfig: Config): Promise return outputDir; } - const adapter = svelteConfig.kit.adapter; + const nodeAdapter = svelteConfig.kit.adapter; const adapterBuilder: Builder = { writeClient(dest: string) { @@ -85,7 +105,7 @@ export async function getAdapterOutputDir(svelteConfig: Config): Promise }; try { - await adapter.adapt(adapterBuilder); + await nodeAdapter.adapt(adapterBuilder); } catch (_) { // We expect the adapter to throw in writeClient! } diff --git a/packages/sveltekit/test/server/utils.test.ts b/packages/sveltekit/test/server/utils.test.ts index 179cc6682d85..2fd9b0492013 100644 --- a/packages/sveltekit/test/server/utils.test.ts +++ b/packages/sveltekit/test/server/utils.test.ts @@ -1,6 +1,8 @@ import { RewriteFrames } from '@sentry/integrations'; import type { StackFrame } from '@sentry/types'; +import { basename } from '@sentry/utils'; +import type { GlobalWithSentryValues } from '../../src/server/utils'; import { getTracePropagationData, rewriteFramesIteratee } from '../../src/server/utils'; const MOCK_REQUEST_EVENT: any = { @@ -69,7 +71,7 @@ describe('rewriteFramesIteratee', () => { expect(result).not.toHaveProperty('module'); }); - it('does the same filename modification as the default RewriteFrames iteratee', () => { + it('does the same filename modification as the default RewriteFrames iteratee if no output dir is available', () => { const frame: StackFrame = { filename: '/some/path/to/server/chunks/3-ab34d22f.js', lineno: 1, @@ -94,4 +96,36 @@ describe('rewriteFramesIteratee', () => { expect(result).toStrictEqual(defaultResult); }); + + it.each([ + ['adapter-node', 'build', '/absolute/path/to/build/server/chunks/3-ab34d22f.js', 'app:///chunks/3-ab34d22f.js'], + [ + 'adapter-auto', + '.svelte-kit/output', + '/absolute/path/to/.svelte-kit/output/server/entries/pages/page.ts.js', + 'app:///entries/pages/page.ts.js', + ], + ])( + 'removes the absolut path to the server output dir, if the output dir is available (%s)', + (_, outputDir, frameFilename, modifiedFilename) => { + (globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir = outputDir; + + const frame: StackFrame = { + filename: frameFilename, + lineno: 1, + colno: 1, + module: basename(frameFilename), + }; + + const result = rewriteFramesIteratee({ ...frame }); + + expect(result).toStrictEqual({ + filename: modifiedFilename, + lineno: 1, + colno: 1, + }); + + delete (globalThis as GlobalWithSentryValues).__sentry_sveltekit_output_dir; + }, + ); }); diff --git a/packages/sveltekit/test/vite/injectGlobalValues.test.ts b/packages/sveltekit/test/vite/injectGlobalValues.test.ts new file mode 100644 index 000000000000..0fe6b07dc989 --- /dev/null +++ b/packages/sveltekit/test/vite/injectGlobalValues.test.ts @@ -0,0 +1,34 @@ +import { getGlobalValueInjectionCode } from '../../src/vite/injectGlobalValues'; + +describe('getGlobalValueInjectionCode', () => { + it('returns code that injects values into the global object', () => { + const injectionCode = getGlobalValueInjectionCode({ + // @ts-ignore - just want to test this with multiple values + something: 'else', + __sentry_sveltekit_output_dir: '.svelte-kit/output', + }); + expect(injectionCode).toEqual(`var _global = + typeof window !== 'undefined' ? + window : + typeof globalThis !== 'undefined' ? + globalThis : + typeof global !== 'undefined' ? + global : + typeof self !== 'undefined' ? + self : + {}; +_global["something"] = "else"; +_global["__sentry_sveltekit_output_dir"] = ".svelte-kit/output"; +`); + + // Check that the code above is in fact valid and works as expected + // The return value of eval here is the value of the last expression in the code + expect(eval(`${injectionCode}`)).toEqual('.svelte-kit/output'); + + delete globalThis.__sentry_sveltekit_output_dir; + }); + + it('returns empty string if no values are passed', () => { + expect(getGlobalValueInjectionCode({})).toEqual(''); + }); +}); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index 026d347d777d..923005b2e9f9 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -72,6 +72,7 @@ describe('sentrySvelteKit()', () => { ignore: ['bar.js'], }, autoInstrument: false, + adapter: 'vercel', }); const plugin = plugins[0]; @@ -80,6 +81,7 @@ describe('sentrySvelteKit()', () => { debug: true, ignore: ['bar.js'], include: ['foo.js'], + adapter: 'vercel', }); }); diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts index 7d412811b92e..9d565aceab58 100644 --- a/packages/sveltekit/test/vite/sourceMaps.test.ts +++ b/packages/sveltekit/test/vite/sourceMaps.test.ts @@ -6,7 +6,7 @@ const mockedSentryVitePlugin = { buildStart: vi.fn(), resolveId: vi.fn(), renderChunk: vi.fn(), - transform: vi.fn(), + transform: vi.fn().mockImplementation((code: string, _id: string) => code), writeBundle: vi.fn(), }; @@ -54,6 +54,15 @@ describe('makeCustomSentryVitePlugin()', () => { }); }); + it('injects the output dir into the server hooks file', async () => { + const plugin = await makeCustomSentryVitePlugin(); + // @ts-ignore this function exists! + const transformedCode = await plugin.transform('foo', '/src/hooks.server.ts'); + const expectedtransformedCode = 'foo\n; import "\0sentry-inject-global-values-file";\n'; + expect(mockedSentryVitePlugin.transform).toHaveBeenCalledWith(expectedtransformedCode, '/src/hooks.server.ts'); + expect(transformedCode).toEqual(expectedtransformedCode); + }); + it('uploads source maps during the SSR build', async () => { const plugin = await makeCustomSentryVitePlugin(); // @ts-ignore this function exists! diff --git a/packages/sveltekit/test/vite/svelteConfig.test.ts b/packages/sveltekit/test/vite/svelteConfig.test.ts index 73f624c8b1be..5f079deb7c1a 100644 --- a/packages/sveltekit/test/vite/svelteConfig.test.ts +++ b/packages/sveltekit/test/vite/svelteConfig.test.ts @@ -1,6 +1,7 @@ import { vi } from 'vitest'; -import { getAdapterOutputDir, loadSvelteConfig } from '../../src/vite/svelteConfig'; +import type { SupportedSvelteKitAdapters } from '../../src/vite/detectAdapter'; +import { getAdapterOutputDir, getHooksFileName, loadSvelteConfig } from '../../src/vite/svelteConfig'; let existsFile; @@ -62,8 +63,33 @@ describe('getAdapterOutputDir', () => { }, }; - it('returns the output directory of the adapter', async () => { - const outputDir = await getAdapterOutputDir({ kit: { adapter: mockedAdapter } }); + it('returns the output directory of the Node adapter', async () => { + const outputDir = await getAdapterOutputDir({ kit: { adapter: mockedAdapter } }, 'node'); expect(outputDir).toEqual('customBuildDir'); }); + + it.each(['vercel', 'auto', 'other'] as SupportedSvelteKitAdapters[])( + 'returns the config.kit.outdir directory for adapter-%s', + async adapter => { + const outputDir = await getAdapterOutputDir({ kit: { outDir: 'customOutDir' } }, adapter); + expect(outputDir).toEqual('customOutDir/output'); + }, + ); + + it('falls back to the default out dir for all other adapters if outdir is not specified in the config', async () => { + const outputDir = await getAdapterOutputDir({ kit: {} }, 'vercel'); + expect(outputDir).toEqual('.svelte-kit/output'); + }); +}); + +describe('getHooksFileName', () => { + it('returns the default hooks file name if no custom hooks file is specified', () => { + const hooksFileName = getHooksFileName({}, 'server'); + expect(hooksFileName).toEqual('src/hooks.server'); + }); + + it('returns the custom hooks file name if specified in the config', () => { + const hooksFileName = getHooksFileName({ kit: { files: { hooks: { server: 'serverhooks' } } } }, 'server'); + expect(hooksFileName).toEqual('serverhooks'); + }); });