From 903e39c2d8e3a3750f1b5a91213e1dc6fa760190 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 27 Aug 2026 13:45:38 +0200 Subject: [PATCH] fix(sveltekit): Read SvelteKit config from the Vite plugin SvelteKit 3 removed `svelte.config.js` (adapter, `files` and `outDir` now go to the `sveltekit()` Vite plugin), and SvelteKit 2.66+ lets users move their config there too. The SDK still imported `svelte.config.js`, so those setups silently fell back to defaults - breaking source map upload paths and `rewriteFrames` for custom adapter `out`, `outDir` or hooks paths. Read the config from the SvelteKit Vite plugin's `api.options` instead, normalized across both majors. `svelte.config.js` stays as the fallback - SvelteKit only exposes `api.options` from 2.62 on, so older 2.x apps still resolve through the file. Not `@sveltejs/load-config`: it re-resolves the `vite.config.js` we're being constructed by, so it waits on itself and hangs - and it reads this same `api.options` anyway. Co-Authored-By: Claude Opus 5 --- packages/sveltekit/src/vite/autoInstrument.ts | 63 ++---- packages/sveltekit/src/vite/detectAdapter.ts | 16 +- .../sveltekit/src/vite/injectGlobalValues.ts | 68 +++++-- packages/sveltekit/src/vite/kitConfig.ts | 155 +++++++++++++++ .../sveltekit/src/vite/sentryVitePlugins.ts | 48 +++-- packages/sveltekit/src/vite/sourceMaps.ts | 18 +- packages/sveltekit/src/vite/svelteConfig.ts | 63 +++--- packages/sveltekit/src/vite/types.ts | 4 +- .../test/vite/autoInstrument.test.ts | 70 +++---- .../sveltekit/test/vite/detectAdapter.test.ts | 34 ++-- .../test/vite/injectGlobalValues.test.ts | 117 +++++++++++- .../sveltekit/test/vite/kitConfig.test.ts | 179 ++++++++++++++++++ .../test/vite/sentrySvelteKitPlugins.test.ts | 32 ++-- .../sveltekit/test/vite/sourceMaps.test.ts | 11 +- .../sveltekit/test/vite/svelteConfig.test.ts | 10 +- 15 files changed, 655 insertions(+), 233 deletions(-) create mode 100644 packages/sveltekit/src/vite/kitConfig.ts create mode 100644 packages/sveltekit/test/vite/kitConfig.test.ts diff --git a/packages/sveltekit/src/vite/autoInstrument.ts b/packages/sveltekit/src/vite/autoInstrument.ts index c8f0dfc29aec..9af2cf0442ac 100644 --- a/packages/sveltekit/src/vite/autoInstrument.ts +++ b/packages/sveltekit/src/vite/autoInstrument.ts @@ -4,7 +4,8 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Plugin } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; -import type { BackwardsForwardsCompatibleKitConfig, BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig'; +import type { ResolvedKitConfig } from './kitConfig'; +import { isNativeServerTracingEnabled } from './kitConfig'; const AcornParser = acorn.Parser.extend(tsPlugin()); @@ -29,7 +30,7 @@ export type AutoInstrumentSelection = { type AutoInstrumentPluginOptions = AutoInstrumentSelection & { debug: boolean; - onlyInstrumentClient: boolean; + getKitConfig: () => Promise; }; /** @@ -42,14 +43,15 @@ type AutoInstrumentPluginOptions = AutoInstrumentSelection & { * @returns the plugin */ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptions): Plugin { - const { load: wrapLoadEnabled, serverLoad: wrapServerLoadEnabled, debug } = options; + const { load: wrapLoadEnabled, serverLoad: wrapServerLoadEnabled, debug, getKitConfig } = options; let isServerBuild: boolean | undefined = undefined; // Whether we should skip server-side load instrumentation because SvelteKit's native server - // tracing is enabled. Initialized from the option (derived from `svelte.config.js`), but may be - // flipped to `true` in `configResolved` once we can read SvelteKit's resolved config (see below). - let onlyInstrumentClient = options.onlyInstrumentClient; + // tracing is enabled. If it is, adding our own wrapper on top would emit duplicate spans. + let onlyInstrumentClientPromise: Promise | undefined; + const shouldOnlyInstrumentClient = (): Promise => + (onlyInstrumentClientPromise ??= getKitConfig().then(isNativeServerTracingEnabled)); return { name: 'sentry-auto-instrumentation', @@ -62,16 +64,6 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio // `config.build.ssr` is `true` for that first build and `false` in the other ones. // Hence we can use it as a switch to upload source maps only once in main build. isServerBuild = !!config.build.ssr; - - // As of SvelteKit 3, the native server-tracing config is no longer read from - // `svelte.config.js` (so the `onlyInstrumentClient` option, derived from it, is `false`). - // It's passed to the `sveltekit()` Vite plugin instead, which exposes the resolved config - // via its plugin `api.options`. Reading it here lets us reliably detect native tracing - // regardless of SvelteKit version. When it's enabled, we must not add our own server-side - // load instrumentation, otherwise we'd emit duplicate spans on top of SvelteKit's. - if (!onlyInstrumentClient && isNativeServerTracingEnabled(config.plugins)) { - onlyInstrumentClient = true; - } }, async load(id) { @@ -81,7 +73,7 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio const environmentName = (this as { environment?: { name?: string } }).environment?.name; const isServerEnvironment = environmentName != null ? environmentName === 'ssr' : !!isServerBuild; - if (onlyInstrumentClient && isServerEnvironment) { + if (isServerEnvironment && (await shouldOnlyInstrumentClient())) { return null; } @@ -96,9 +88,9 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio return getWrapperCode('wrapLoadWithSentry', `${id}${WRAPPED_MODULE_SUFFIX}`); } - if (onlyInstrumentClient) { + if (await shouldOnlyInstrumentClient()) { // Now that we've checked universal files, we can early return and avoid further - // regexp checks below for server-only files, in case `onlyInstrumentClient` is `true`. + // regexp checks below for server-only files. return null; } @@ -118,39 +110,6 @@ export function makeAutoInstrumentationPlugin(options: AutoInstrumentPluginOptio }; } -/** - * Detects whether SvelteKit's native server-side tracing is enabled by reading the resolved - * SvelteKit config that the `sveltekit()` Vite plugin exposes via its plugin `api.options`. - * - * This is the source of truth as of SvelteKit 3, where the config moved out of `svelte.config.js` - * and into the `sveltekit()` plugin. On older SvelteKit versions that don't expose the config this - * way, it simply returns `false` and we fall back to the `svelte.config.js`-derived value. - */ -function isNativeServerTracingEnabled(plugins: readonly Plugin[] | undefined): boolean { - if (!plugins) { - return false; - } - - for (const plugin of plugins) { - const options = ( - plugin?.api as - | { options?: BackwardsForwardsCompatibleSvelteConfig & BackwardsForwardsCompatibleKitConfig } - | undefined - )?.options; - - // SvelteKit 3 flattened the plugin config: what used to live under `kit` now sits - // at the top level of the exposed options. - const kitConfig = options?.kit ?? options; - - // SvelteKit 3 promoted `tracing` out of `experimental`; older versions nest it there. - if (kitConfig?.tracing?.server || kitConfig?.experimental?.tracing?.server) { - return true; - } - } - - return false; -} - /** * We only want to apply our wrapper to files that * diff --git a/packages/sveltekit/src/vite/detectAdapter.ts b/packages/sveltekit/src/vite/detectAdapter.ts index 0770b50b36b8..eb064d2a564c 100644 --- a/packages/sveltekit/src/vite/detectAdapter.ts +++ b/packages/sveltekit/src/vite/detectAdapter.ts @@ -1,7 +1,7 @@ import type { Package } from '@sentry/core'; import * as fs from 'fs'; import * as path from 'path'; -import type { BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig'; +import type { ResolvedKitConfig } from './kitConfig'; /** * Supported @sveltejs/adapters-[adapter] SvelteKit adapters @@ -21,33 +21,33 @@ const ADAPTER_NAME_MAP: Record = { /** * Tries to detect the used adapter for SvelteKit. - * 1. If svelteConfig is provided and has kit.adapter.name, uses that (source of truth from svelte.config.js). + * 1. If kitConfig is provided and has adapter.name, uses that (source of truth from SvelteKit itself). * 2. Otherwise falls back to inferring from package.json dependencies. * Returns the name of the adapter or 'other' if no supported adapter was found. * - * @param svelteConfig - Loaded svelte config (e.g. from loadSvelteConfig()). Pass `undefined` to skip config-based detection. + * @param kitConfig - Resolved SvelteKit config (e.g. from the kit config resolver). Pass `undefined` to skip config-based detection. * @param debug - Whether to log detection result. Pass `undefined` for false. */ export async function detectAdapter( - svelteConfig: BackwardsForwardsCompatibleSvelteConfig | undefined, + kitConfig: ResolvedKitConfig | undefined, debug: boolean | undefined, ): Promise { - const adapterName = svelteConfig?.kit?.adapter?.name; + const adapterName = kitConfig?.adapter?.name; if (adapterName && typeof adapterName === 'string') { const mapped = ADAPTER_NAME_MAP[adapterName]; if (mapped) { if (debug) { // eslint-disable-next-line no-console - console.log(`[Sentry SvelteKit Plugin] Detected SvelteKit ${mapped} adapter from \`svelte.config.js\``); + console.log(`[Sentry SvelteKit Plugin] Detected SvelteKit ${mapped} adapter from your SvelteKit config`); } return mapped; } // We found an adapter name but it's not in our supported list -> return 'other' - // svelte.config.js is the source of truth, so we don't need to fall back to package.json. + // The SvelteKit config is the source of truth, so we don't need to fall back to package.json. if (debug) { // eslint-disable-next-line no-console console.warn( - `[Sentry SvelteKit Plugin] Detected unsupported adapter name ${adapterName} in \`svelte.config.js\`. Please set the 'adapter' option manually`, + `[Sentry SvelteKit Plugin] Detected unsupported adapter name ${adapterName} in your SvelteKit config. Please set the 'adapter' option manually`, ); } return 'other'; diff --git a/packages/sveltekit/src/vite/injectGlobalValues.ts b/packages/sveltekit/src/vite/injectGlobalValues.ts index 9afd1716546b..ec0ad67b0ae7 100644 --- a/packages/sveltekit/src/vite/injectGlobalValues.ts +++ b/packages/sveltekit/src/vite/injectGlobalValues.ts @@ -1,8 +1,8 @@ import { escapeStringForRegex, type InternalGlobal } from '@sentry/core'; import MagicString from 'magic-string'; import type { Plugin } from 'vite'; -import { type BackwardsForwardsCompatibleSvelteConfig, getAdapterOutputDir, getHooksFileName } from './svelteConfig'; -import type { SentrySvelteKitPluginOptions } from './types'; +import type { ResolvedKitConfig } from './kitConfig'; +import { getHooksFileName } from './svelteConfig'; export type GlobalSentryValues = { __sentry_sveltekit_output_dir?: string; @@ -32,33 +32,56 @@ export function getGlobalValueInjectionCode(globalSentryValues: GlobalSentryValu return `${injectedValuesCode}\n`; } +type GlobalValuesInjectionOptions = { + getKitConfig: () => Promise; + getAdapterOutputDir: () => Promise; + debug?: boolean; +}; + /** - * Injects SvelteKit app configuration values the svelte.config.js into the - * server's global object so that the SDK can pick up the information at runtime + * Injects SvelteKit app configuration values into the server's global object + * so that the SDK can pick up the information at runtime. */ -export async function makeGlobalValuesInjectionPlugin( - svelteConfig: BackwardsForwardsCompatibleSvelteConfig, - options: Pick, -): Promise { - const { adapter = 'other', debug = false } = options; +export function makeGlobalValuesInjectionPlugin(options: GlobalValuesInjectionOptions): Plugin { + const { getKitConfig, getAdapterOutputDir, debug = false } = options; - const serverHooksFile = getHooksFileName(svelteConfig, 'server'); - const adapterOutputDir = await getAdapterOutputDir(svelteConfig, adapter); + // The SvelteKit config is only available once Vite has resolved its plugins, so we compute + // the injected values lazily (but only once) instead of at plugin creation time. + let injectionValuesPromise: Promise<{ globalSentryValues: GlobalSentryValues; hooksFileRegexp: RegExp }> | undefined; - const globalSentryValues: GlobalSentryValues = { - __sentry_sveltekit_output_dir: adapterOutputDir, - }; + const getInjectionValues = (): Promise<{ globalSentryValues: GlobalSentryValues; hooksFileRegexp: RegExp }> => + (injectionValuesPromise ??= (async () => { + const kitConfig = await getKitConfig(); - if (debug) { - // eslint-disable-next-line no-console - console.log('[Sentry SvelteKit] Global values:', globalSentryValues); - } + const serverHooksFile = getHooksFileName(kitConfig, 'server'); + const adapterOutputDir = await getAdapterOutputDir(); + + const globalSentryValues: GlobalSentryValues = { + __sentry_sveltekit_output_dir: adapterOutputDir, + }; - // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway - const hooksFileRegexp = new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`); + if (debug) { + // eslint-disable-next-line no-console + console.log('[Sentry SvelteKit] Global values:', globalSentryValues); + } + + return { + globalSentryValues, + // oxlint-disable-next-line sdk/no-regexp-constructor -- not end user input + escaped anyway + hooksFileRegexp: new RegExp(`/${escapeStringForRegex(serverHooksFile)}(.(js|ts|mjs|mts))?`), + }; + })()); return { name: 'sentry-sveltekit-global-values-injection-plugin', + + // Resolve eagerly rather than on the first `transform`: see the note on the adapter output dir + // in `sentrySvelteKit()`. Awaited so a failure surfaces as a config error instead of an + // unhandled rejection. + configResolved: async () => { + await getInjectionValues(); + }, + resolveId: (id, _importer, _ref) => { if (id === VIRTUAL_GLOBAL_VALUES_FILE) { return { @@ -70,8 +93,9 @@ export async function makeGlobalValuesInjectionPlugin( return null; }, - load: id => { + load: async id => { if (id === VIRTUAL_GLOBAL_VALUES_FILE) { + const { globalSentryValues } = await getInjectionValues(); return { code: getGlobalValueInjectionCode(globalSentryValues), }; @@ -80,6 +104,8 @@ export async function makeGlobalValuesInjectionPlugin( }, transform: async (code, id) => { + const { hooksFileRegexp } = await getInjectionValues(); + const isServerEntryFile = /instrumentation\.server\./.test(id) || hooksFileRegexp.test(id); if (isServerEntryFile) { diff --git a/packages/sveltekit/src/vite/kitConfig.ts b/packages/sveltekit/src/vite/kitConfig.ts new file mode 100644 index 000000000000..e4dad7b23d53 --- /dev/null +++ b/packages/sveltekit/src/vite/kitConfig.ts @@ -0,0 +1,155 @@ +import type { Adapter } from '@sveltejs/kit'; +import type { Plugin } from 'vite'; +import { loadSvelteConfig } from './svelteConfig'; + +/** + * The subset of SvelteKit's configuration that this SDK reads, in a shape that's normalized + * across SvelteKit majors: + * + * - SvelteKit 2 keeps its options nested under `kit` in `svelte.config.js` + * - SvelteKit 3 removed `svelte.config.js` and passes a flat config to the `sveltekit()` Vite plugin + * + * We always work with the flat shape (see {@link normalizeKitConfig}). + */ +export type ResolvedKitConfig = { + adapter?: Adapter; + outDir?: string; + files?: { + hooks?: { + client?: string; + server?: string; + }; + }; + paths?: { + // Matches SvelteKit's own type for this option, so it can be handed to an adapter `Builder` as-is + base?: '' | `/${string}`; + }; + /** SvelteKit 3 (>= 3.0.0-next.8) promoted native tracing out of `experimental` */ + tracing?: { + server?: boolean; + }; + /** SvelteKit 2.31+ and early SvelteKit 3 prereleases nest native tracing here */ + experimental?: { + tracing?: { + server?: boolean; + }; + }; +}; + +/** + * The SvelteKit Vite plugin exposes the resolved SvelteKit config on its plugin `api`. + * This is the case in SvelteKit 2 and 3 alike. + */ +const SVELTEKIT_SETUP_PLUGIN_NAME = 'vite-plugin-sveltekit-setup'; + +type KitPluginApi = { + options?: ResolvedKitConfig & { kit?: ResolvedKitConfig }; +}; + +export type KitConfigResolver = { + /** + * Add this to the Vite plugins **before** any plugin that calls {@link KitConfigResolver.get}, + * so that its `config` hook runs first. Otherwise `get()` never resolves. + */ + plugin: Plugin; + get: () => Promise; +}; + +/** + * Creates a Vite plugin that resolves the SvelteKit config once, plus a getter for other plugins + * to await it. + * + * We read the config from the `sveltekit()` Vite plugin's `api.options` instead of importing + * `svelte.config.js`: SvelteKit 3 removed that file entirely, and SvelteKit 2.66+ lets users move + * their config into `vite.config.js` as well. + * + * Loading `svelte.config.js` stays as the fallback, and isn't just an edge case: SvelteKit only + * exposes `api.options` from 2.62 on, so every older 2.x app still resolves through the file, as + * do setups where the SvelteKit plugin isn't registered at all (or is added by a plugin factory we + * can't see in time). + * + * Not `@sveltejs/load-config`: it re-resolves the `vite.config.js` we're being constructed by, so + * it ends up waiting on itself and hangs - and it reads this same `api.options` to begin with. + */ +export function createKitConfigResolver(): KitConfigResolver { + let resolveConfig: (config: ResolvedKitConfig) => void; + const configPromise = new Promise(resolve => { + resolveConfig = resolve; + }); + + let isResolved = false; + const settle = (config: ResolvedKitConfig): void => { + if (!isResolved) { + isResolved = true; + resolveConfig(config); + } + }; + + const plugin: Plugin = { + name: 'sentry-sveltekit-kit-config-resolver', + // Run before our other plugins so that they can await `get()` from within their own hooks. + enforce: 'pre', + + config: config => { + const kitConfig = findKitConfigInPlugins(config.plugins); + if (kitConfig) { + settle(kitConfig); + } + return null; + }, + + configResolved: async config => { + if (isResolved) { + return; + } + + // Plugins added by a promise-returning factory aren't visible in the `config` hook yet, + // so we take a second look at the fully resolved plugin list. + const kitConfig = findKitConfigInPlugins(config.plugins); + + settle(kitConfig ?? normalizeKitConfig(await loadSvelteConfig())); + }, + }; + + return { plugin, get: () => configPromise }; +} + +/** + * Picks the SvelteKit options off the SvelteKit Vite plugin, if it's registered. + * Exported only for testing. + */ +export function findKitConfigInPlugins(plugins: unknown): ResolvedKitConfig | undefined { + if (!Array.isArray(plugins)) { + return undefined; + } + + // Plugins can be nested arrays; entries can also be (unresolved) promises, which we skip. + for (const plugin of plugins.flat(Infinity)) { + if (!plugin || typeof plugin !== 'object' || (plugin as Plugin).name !== SVELTEKIT_SETUP_PLUGIN_NAME) { + continue; + } + + const options = ((plugin as Plugin).api as KitPluginApi | undefined)?.options; + if (options) { + return normalizeKitConfig(options); + } + } + + return undefined; +} + +/** + * Flattens a SvelteKit 2 config (`{ kit: { ... } }`) to the SvelteKit 3 shape (`{ ... }`). + * Exported only for testing. + */ +export function normalizeKitConfig(config: ResolvedKitConfig & { kit?: ResolvedKitConfig }): ResolvedKitConfig { + return config?.kit ?? config ?? {}; +} + +/** + * Whether SvelteKit's native server-side tracing is enabled. If it is, we must not add our own + * server-side instrumentation on top of SvelteKit's, or we'd emit duplicate spans. + */ +export function isNativeServerTracingEnabled(kitConfig: ResolvedKitConfig): boolean { + return !!(kitConfig.tracing?.server || kitConfig.experimental?.tracing?.server); +} diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 8609e67d21cd..16f86370d19d 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -5,10 +5,12 @@ import * as path from 'path'; import type { Plugin } from 'vite'; import type { AutoInstrumentSelection } from './autoInstrument'; import { makeAutoInstrumentationPlugin } from './autoInstrument'; +import type { SupportedSvelteKitAdapters } from './detectAdapter'; import { detectAdapter } from './detectAdapter'; import { makeGlobalValuesInjectionPlugin } from './injectGlobalValues'; +import { createKitConfigResolver } from './kitConfig'; import { makeCustomSentryVitePlugins } from './sourceMaps'; -import { loadSvelteConfig } from './svelteConfig'; +import { getAdapterOutputDir } from './svelteConfig'; import type { CustomSentryVitePluginOptions, SentrySvelteKitPluginOptions } from './types'; const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { @@ -27,20 +29,33 @@ const DEFAULT_PLUGIN_OPTIONS: SentrySvelteKitPluginOptions = { export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {}): Promise { warnOnRemovedBuildOptions(options, ['unstable_sentryVitePluginOptions']); - const svelteConfig = await loadSvelteConfig(); + const kitConfigResolver = createKitConfigResolver(); + const getKitConfig = kitConfigResolver.get; + + // The adapter can only be detected once the SvelteKit config is available, so it's resolved + // lazily (but only once) by the plugins that need it. + let adapterPromise: Promise | undefined; + const getAdapter = (): Promise => + (adapterPromise ??= (async () => options.adapter || detectAdapter(await getKitConfig(), options.debug))()); + + // Resolving this has a side effect: for the Node adapter we have to invoke `adapter.adapt()` to + // learn the output directory, and `@sveltejs/adapter-node` v6 wipes that directory when it runs. + // So it must happen once, and early (while the build output doesn't exist yet) - never lazily + // from a late hook like `closeBundle`, which runs *after* SvelteKit invoked the adapter. + let adapterOutputDirPromise: Promise | undefined; + const getAdapterOutputDirOnce = (): Promise => + (adapterOutputDirPromise ??= (async () => getAdapterOutputDir(await getKitConfig(), await getAdapter()))()); const mergedOptions = { ...DEFAULT_PLUGIN_OPTIONS, ...options, - adapter: options.adapter || (await detectAdapter(svelteConfig, options.debug)), }; - const sentryPlugins: Plugin[] = [makeBrowserTracingVariantResolverPlugin()]; + // The config resolver has to come first so that its `config` hook runs before any plugin + // below awaits the resolved SvelteKit config. + const sentryPlugins: Plugin[] = [kitConfigResolver.plugin, makeBrowserTracingVariantResolverPlugin()]; if (mergedOptions.autoInstrument) { - // SvelteKit 3 (>= next.8) promoted `tracing` out of `experimental`; older versions nest it there. - const kitTracingEnabled = !!(svelteConfig.kit?.tracing?.server || svelteConfig.kit?.experimental?.tracing?.server); - const pluginOptions: AutoInstrumentSelection = { load: true, serverLoad: true, @@ -51,8 +66,7 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} makeAutoInstrumentationPlugin({ ...pluginOptions, debug: options.debug || false, - // if kit-internal tracing is enabled, we only want to wrap and instrument client-side code. - onlyInstrumentClient: kitTracingEnabled, + getKitConfig, }), ); } @@ -72,11 +86,19 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} // TODO: I don't think this is technically correct. Either we always or never inject the output directory. // Stack traces shouldn't be different, depending on source maps config. With debugIds, we might not even // need to rewrite frames anymore. - sentryPlugins.push(await makeGlobalValuesInjectionPlugin(svelteConfig, mergedOptions)); + sentryPlugins.push( + makeGlobalValuesInjectionPlugin({ + getKitConfig, + getAdapterOutputDir: getAdapterOutputDirOnce, + debug: mergedOptions.debug, + }), + ); } if (sentryVitePluginsOptions) { - const sentryVitePlugins = await makeCustomSentryVitePlugins(sentryVitePluginsOptions, svelteConfig); + const sentryVitePlugins = await makeCustomSentryVitePlugins(sentryVitePluginsOptions, { + getAdapterOutputDir: getAdapterOutputDirOnce, + }); sentryPlugins.push(...sentryVitePlugins); } @@ -197,6 +219,9 @@ export function generateVitePluginOptions( autoUploadSourceMaps: _filtered1, // eslint-disable-next-line @typescript-eslint/no-unused-vars autoInstrument: _filtered2, + // Consumed by the kit config resolver, not by the Vite plugin + // eslint-disable-next-line @typescript-eslint/no-unused-vars + adapter: _filtered3, sentryUrl, ...newSvelteKitPluginOptions } = svelteKitPluginOptions; @@ -208,7 +233,6 @@ export function generateVitePluginOptions( url: sentryUrl, - adapter: svelteKitPluginOptions.adapter, // override the plugin's debug flag with the one from the top-level options debug: svelteKitPluginOptions.debug, }; diff --git a/packages/sveltekit/src/vite/sourceMaps.ts b/packages/sveltekit/src/vite/sourceMaps.ts index f165d33afdf4..d69b08211b1a 100644 --- a/packages/sveltekit/src/vite/sourceMaps.ts +++ b/packages/sveltekit/src/vite/sourceMaps.ts @@ -7,8 +7,6 @@ import * as fs from 'fs'; import * as path from 'path'; import type { Plugin, UserConfig } from 'vite'; import { WRAPPED_MODULE_SUFFIX } from '../common/utils'; -import type { BackwardsForwardsCompatibleSvelteConfig } from './svelteConfig'; -import { getAdapterOutputDir } from './svelteConfig'; import type { CustomSentryVitePluginOptions } from './types'; // sorcery has no types, so these are some basic type definitions: @@ -44,10 +42,12 @@ type FilesToDeleteAfterUpload = string | string[] | undefined; */ export async function makeCustomSentryVitePlugins( options: CustomSentryVitePluginOptions, - svelteConfig: BackwardsForwardsCompatibleSvelteConfig, + deps: { + /** Resolved once and shared with the other Sentry plugins - see the note in `sentrySvelteKit()` */ + getAdapterOutputDir: () => Promise; + }, ): Promise { - const usedAdapter = options?.adapter || 'other'; - const adapterOutputDir = await getAdapterOutputDir(svelteConfig, usedAdapter); + const { getAdapterOutputDir } = deps; const defaultPluginOptions: SentryVitePluginOptions = { release: { @@ -128,7 +128,11 @@ export async function makeCustomSentryVitePlugins( const filesToDeleteAfterUploadConfigPlugin: Plugin = { name: 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', apply: 'build', // only apply this plugin at build time - config: (config: UserConfig) => { + config: async (config: UserConfig) => { + // Kick this off here (not in `closeBundle`) so the adapter is invoked before the build + // writes its output - see the note on the adapter output dir in `sentrySvelteKit()`. + const adapterOutputDir = await getAdapterOutputDir(); + const originalFilesToDeleteAfterUpload = options?.sourcemaps?.filesToDeleteAfterUpload; if (typeof originalFilesToDeleteAfterUpload === 'undefined' && typeof config.build?.sourcemap === 'undefined') { @@ -176,7 +180,7 @@ export async function makeCustomSentryVitePlugins( return; } - const outDir = path.resolve(process.cwd(), adapterOutputDir); + const outDir = path.resolve(process.cwd(), await getAdapterOutputDir()); // eslint-disable-next-line no-console debug && console.log('[Source Maps Plugin] Looking up source maps in', outDir); diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index da38ea0e1022..bf043cb2f140 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -1,39 +1,27 @@ -import type { Builder, Config } from '@sveltejs/kit'; +import type { Builder } from '@sveltejs/kit'; import * as fs from 'fs'; import * as path from 'path'; import * as url from 'url'; import type { SupportedSvelteKitAdapters } from './detectAdapter'; - -export type SvelteKitTracingConfig = { - tracing?: { - server: boolean; - }; - // TODO: Once instrumentation is promoted stable, this will be removed! - instrumentation?: { - server: boolean; - }; -}; +import type { ResolvedKitConfig } from './kitConfig'; /** - * The location of SvelteKit's native tracing config differs by version: - * - SvelteKit 2.31+ and early Kit 3 prereleases nest it under `kit.experimental.tracing` - * - SvelteKit 3 (>= 3.0.0-next.8) promoted it to `kit.tracing` - * - SvelteKit 3 (>= 3.0.0-next.21) dropped the `kit` nesting entirely, leaving `tracing` - * We type (and read) all of them so detection works across the supported peer range. + * The contents of a `svelte.config.js` file. SvelteKit 3 removed this file; it only exists in + * SvelteKit 1 and 2, where all SvelteKit options are nested under `kit`. */ -export type BackwardsForwardsCompatibleKitConfig = Config['kit'] & - Pick & { experimental?: SvelteKitTracingConfig }; - -export interface BackwardsForwardsCompatibleSvelteConfig extends Config { - kit?: BackwardsForwardsCompatibleKitConfig; -} +export type SvelteConfigFileContents = { + kit?: ResolvedKitConfig; +}; /** * Imports the svelte.config.js file and returns the config object. * The sveltekit plugins import the config in the same way. * See: https://github.com/sveltejs/kit/blob/master/packages/kit/src/core/config/index.js#L63 + * + * Only a fallback these days: as of SvelteKit 3 there is no `svelte.config.js` anymore, and the + * config is read from the SvelteKit Vite plugin instead (see `kitConfig.ts`). */ -export async function loadSvelteConfig(): Promise { +export async function loadSvelteConfig(): Promise { // This can only be .js (see https://github.com/sveltejs/kit/pull/4031#issuecomment-1049475388) const SVELTE_CONFIG_FILE = 'svelte.config.js'; @@ -46,7 +34,7 @@ export async function loadSvelteConfig(): Promise { +export async function getAdapterOutputDir( + kitConfig: ResolvedKitConfig, + adapter: SupportedSvelteKitAdapters, +): Promise { if (adapter === 'node') { - return getNodeAdapterOutputDir(svelteConfig); + return getNodeAdapterOutputDir(kitConfig); } if (adapter === 'cloudflare') { // Cloudflare outputs to outDir\cloudflare as the output dir - return path.join(svelteConfig.kit?.outDir || '.svelte-kit', 'cloudflare'); + return path.join(kitConfig.outDir || '.svelte-kit', 'cloudflare'); } // 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'); + return path.join(kitConfig.outDir || '.svelte-kit', 'output'); } /** @@ -96,15 +83,15 @@ export async function getAdapterOutputDir(svelteConfig: Config, adapter: Support * * see: https://github.com/sveltejs/kit/blob/master/packages/adapter-node/index.js#L17 */ -async function getNodeAdapterOutputDir(svelteConfig: Config): Promise { +async function getNodeAdapterOutputDir(kitConfig: ResolvedKitConfig): Promise { // 'build' is the default output dir for the node adapter let outputDir = 'build'; - if (!svelteConfig.kit?.adapter) { + if (!kitConfig.adapter) { return outputDir; } - const nodeAdapter = svelteConfig.kit.adapter; + const nodeAdapter = kitConfig.adapter; const adapterBuilder: Builder = { writeClient(dest: string) { @@ -123,7 +110,7 @@ async function getNodeAdapterOutputDir(svelteConfig: Config): Promise { kit: { // @ts-expect-error - the builder expects a validated config but for our purpose it's fine to just pass this partial config paths: { - base: svelteConfig.kit?.paths?.base || '', + base: kitConfig.paths?.base || '', }, }, }, diff --git a/packages/sveltekit/src/vite/types.ts b/packages/sveltekit/src/vite/types.ts index 443ac4cffb5c..b2630a95d158 100644 --- a/packages/sveltekit/src/vite/types.ts +++ b/packages/sveltekit/src/vite/types.ts @@ -4,9 +4,7 @@ import type { AutoInstrumentSelection } from './autoInstrument'; import type { SupportedSvelteKitAdapters } from './detectAdapter'; /** Options for the Custom Sentry Vite plugin */ -export type CustomSentryVitePluginOptions = SentryVitePluginOptions & { - adapter?: SupportedSvelteKitAdapters; -}; +export type CustomSentryVitePluginOptions = SentryVitePluginOptions; /** Options for the Sentry SvelteKit plugin */ export type SentrySvelteKitPluginOptions = BuildTimeOptionsBase & { diff --git a/packages/sveltekit/test/vite/autoInstrument.test.ts b/packages/sveltekit/test/vite/autoInstrument.test.ts index 20c6d5e5db59..c18286f46570 100644 --- a/packages/sveltekit/test/vite/autoInstrument.test.ts +++ b/packages/sveltekit/test/vite/autoInstrument.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { canWrapLoad, makeAutoInstrumentationPlugin } from '../../src/vite/autoInstrument'; +import type { ResolvedKitConfig } from '../../src/vite/kitConfig'; const DEFAULT_CONTENT = ` export const load = () => {}; @@ -45,7 +46,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: true, load: true, serverLoad: true, - onlyInstrumentClient: false, + getKitConfig: async () => ({}), }); expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(plugin.enforce).toEqual('pre'); @@ -67,7 +68,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: false, + getKitConfig: async () => ({}), }); // @ts-expect-error this exists const loadResult = await plugin.load(path); @@ -84,7 +85,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: false, serverLoad: false, - onlyInstrumentClient: false, + getKitConfig: async () => ({}), }); // @ts-expect-error this exists const loadResult = await plugin.load(path); @@ -107,7 +108,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: false, serverLoad: true, - onlyInstrumentClient: false, + getKitConfig: async () => ({}), }); // @ts-expect-error this exists const loadResult = await plugin.load(path); @@ -124,7 +125,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: false, serverLoad: false, - onlyInstrumentClient: false, + getKitConfig: async () => ({}), }); // @ts-expect-error this exists const loadResult = await plugin.load(path); @@ -132,7 +133,7 @@ describe('makeAutoInstrumentationPlugin()', () => { }); }); - describe('when `onlyInstrumentClient` is `true`', () => { + describe('when SvelteKit native server tracing is enabled (client-only instrumentation)', () => { it.each([ // server-only files 'path/to/+page.server.ts', @@ -145,7 +146,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: true, + getKitConfig: async () => ({ tracing: { server: true } }), }); // @ts-expect-error this exists and is callable @@ -168,7 +169,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: true, + getKitConfig: async () => ({ tracing: { server: true } }), }); // @ts-expect-error this exists and is callable @@ -202,7 +203,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: true, + getKitConfig: async () => ({ tracing: { server: true } }), }); // @ts-expect-error this exists and is callable @@ -220,48 +221,29 @@ describe('makeAutoInstrumentationPlugin()', () => { ); }); - describe('when SvelteKit native server tracing is detected via the Vite plugin `api`', () => { - // SvelteKit 3 no longer reads native-tracing config from `svelte.config.js` (so the - // `onlyInstrumentClient` option computed from it is `false`); the config is exposed on the - // SvelteKit Vite plugin's `api.options` instead. - // The tracing config location differs by SvelteKit version: - // - SvelteKit 3 (>= 3.0.0-next.21): `tracing.server` (the `kit` nesting was flattened away) - // - SvelteKit 3 (>= 3.0.0-next.8): `kit.tracing.server` - // - SvelteKit 2.31+ and early Kit 3 prereleases: `kit.experimental.tracing.server` - function configWithKitTracing( - ssr: boolean, - serverTracing: boolean, - location: 'tracing' | 'experimental' | 'flat' = 'tracing', - ): unknown { + describe('when SvelteKit native server tracing is enabled', () => { + // The location of the tracing flag differs by SvelteKit version: + // - SvelteKit 3 (>= 3.0.0-next.8): `tracing.server` + // - SvelteKit 2.31+ and early Kit 3 prereleases: `experimental.tracing.server` + // (the `kit` nesting of SvelteKit 2 configs is flattened away by the kit config resolver) + function kitConfigWithTracing(serverTracing: boolean, location: 'tracing' | 'experimental'): ResolvedKitConfig { const tracing = { tracing: { server: serverTracing } }; - const options = - location === 'flat' ? tracing : { kit: location === 'tracing' ? tracing : { experimental: tracing } }; - - return { - build: { ssr }, - plugins: [ - { name: 'some-other-plugin' }, - { - name: 'vite-plugin-sveltekit-setup', - api: { options }, - }, - ], - }; + return location === 'tracing' ? tracing : { experimental: tracing }; } - describe.each(['tracing', 'experimental', 'flat'] as const)('with the config in the `%s` location', location => { + describe.each(['tracing', 'experimental'] as const)('with the flag in the `%s` location', location => { it.each(['path/to/+page.server.ts', 'path/to/+layout.server.js', 'path/to/+page.ts', 'path/to/+layout.mjs'])( - "doesn't wrap %s in the SSR build when native tracing is enabled, even if `onlyInstrumentClient` is `false`", + "doesn't wrap %s in the SSR build", async (path: string) => { const plugin = makeAutoInstrumentationPlugin({ debug: false, load: true, serverLoad: true, - onlyInstrumentClient: false, + getKitConfig: async () => kitConfigWithTracing(true, location), }); // @ts-expect-error this exists and is callable - plugin.configResolved(configWithKitTracing(true, true, location)); + plugin.configResolved({ build: { ssr: true } }); // @ts-expect-error this exists and is callable const loadResult = await plugin.load(path); @@ -275,11 +257,11 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: false, + getKitConfig: async () => kitConfigWithTracing(false, location), }); // @ts-expect-error this exists and is callable - plugin.configResolved(configWithKitTracing(true, false, location)); + plugin.configResolved({ build: { ssr: true } }); const path = 'path/to/+page.server.ts'; // @ts-expect-error this exists and is callable @@ -298,7 +280,7 @@ describe('makeAutoInstrumentationPlugin()', () => { describe('when the server build is detected via the Vite Environment API', () => { // On Vite 6+ `config.build.ssr` no longer reliably reflects the per-environment // build, so the plugin relies on the current environment (`this.environment.name`). When - // `onlyInstrumentClient` is `true`, universal load must not be wrapped in the `ssr` environment + // native tracing is enabled, universal load must not be wrapped in the `ssr` environment // (but should still be wrapped in `client`), even when `config.build.ssr`/`configResolved` // didn't flag a server build. it.each(['path/to/+page.ts', 'path/to/+layout.js', 'path/to/+page.server.ts'])( @@ -308,7 +290,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: true, + getKitConfig: async () => ({ tracing: { server: true } }), }); // `configResolved` is intentionally not called - `isServerBuild` stays `undefined` @@ -324,7 +306,7 @@ describe('makeAutoInstrumentationPlugin()', () => { debug: false, load: true, serverLoad: true, - onlyInstrumentClient: true, + getKitConfig: async () => ({ tracing: { server: true } }), }); const path = 'path/to/+page.ts'; diff --git a/packages/sveltekit/test/vite/detectAdapter.test.ts b/packages/sveltekit/test/vite/detectAdapter.test.ts index 806f50fdf456..bb2e40684a2a 100644 --- a/packages/sveltekit/test/vite/detectAdapter.test.ts +++ b/packages/sveltekit/test/vite/detectAdapter.test.ts @@ -28,40 +28,40 @@ describe('detectAdapter', () => { const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - describe('svelte.config.js (source of truth)', () => { + describe('SvelteKit config (source of truth)', () => { it.each(['auto', 'vercel', 'node', 'cloudflare'])( - 'returns adapter from kit.adapter.name when provided (adapter %s)', + 'returns adapter from adapter.name when provided (adapter %s)', async adapter => { - const svelteConfig = { kit: { adapter: { name: `@sveltejs/adapter-${adapter}` } } }; - const detectedAdapter = await detectAdapter(svelteConfig, undefined); + const kitConfig = { adapter: { name: `@sveltejs/adapter-${adapter}` } }; + const detectedAdapter = await detectAdapter(kitConfig, undefined); expect(detectedAdapter).toEqual(adapter); }, ); - it('prefers svelte.config.js over package.json when both are present', async () => { + it('prefers the SvelteKit config over package.json when both are present', async () => { pkgJson.dependencies['@sveltejs/adapter-vercel'] = '1.0.0'; - const svelteConfig = { kit: { adapter: { name: '@sveltejs/adapter-node' } } }; - const detectedAdapter = await detectAdapter(svelteConfig, undefined); + const kitConfig = { adapter: { name: '@sveltejs/adapter-node' } }; + const detectedAdapter = await detectAdapter(kitConfig, undefined); expect(detectedAdapter).toEqual('node'); }); - it('returns "other" when found adapter name in svelte.config.js is unsupported', async () => { + it('returns "other" when the adapter name in the SvelteKit config is unsupported', async () => { pkgJson.dependencies['@sveltejs/adapter-vercel'] = '1.0.0'; - const svelteConfig = { kit: { adapter: { name: '@sveltejs/adapter-netlify' } } }; - const detectedAdapter = await detectAdapter(svelteConfig, undefined); + const kitConfig = { adapter: { name: '@sveltejs/adapter-netlify' } }; + const detectedAdapter = await detectAdapter(kitConfig, undefined); expect(detectedAdapter).toEqual('other'); }); - it('logs a warning if in debug mode and an unsupported adapter name is found in svelte.config.js', async () => { - const svelteConfig = { kit: { adapter: { name: '@sveltejs/adapter-netlify' } } }; - await detectAdapter(svelteConfig, true); + it('logs a warning if in debug mode and an unsupported adapter name is found in the SvelteKit config', async () => { + const kitConfig = { adapter: { name: '@sveltejs/adapter-netlify' } }; + await detectAdapter(kitConfig, true); expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('Detected unsupported adapter name')); }); - it('logs "from svelte.config.js" in debug when adapter comes from config', async () => { - const svelteConfig = { kit: { adapter: { name: '@sveltejs/adapter-vercel' } } }; - await detectAdapter(svelteConfig, true); - expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('from `svelte.config.js`')); + it('logs the config as the source in debug when the adapter comes from it', async () => { + const kitConfig = { adapter: { name: '@sveltejs/adapter-vercel' } }; + await detectAdapter(kitConfig, true); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('from your SvelteKit config')); }); }); diff --git a/packages/sveltekit/test/vite/injectGlobalValues.test.ts b/packages/sveltekit/test/vite/injectGlobalValues.test.ts index 50f41c84880f..af3a601f1da3 100644 --- a/packages/sveltekit/test/vite/injectGlobalValues.test.ts +++ b/packages/sveltekit/test/vite/injectGlobalValues.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest'; -import { getGlobalValueInjectionCode } from '../../src/vite/injectGlobalValues'; +import type { Plugin } from 'vite'; +import { describe, expect, it, vi } from 'vitest'; +import { getGlobalValueInjectionCode, VIRTUAL_GLOBAL_VALUES_FILE } from '../../src/vite/injectGlobalValues'; +import { sentrySvelteKit } from '../../src/vite/sentryVitePlugins'; describe('getGlobalValueInjectionCode', () => { it('returns code that injects values into the global object', () => { @@ -24,3 +26,114 @@ describe('getGlobalValueInjectionCode', () => { expect(getGlobalValueInjectionCode({})).toEqual(''); }); }); + +function getGlobalValuesPlugin(plugins: Plugin[]): Plugin { + return plugins.find(plugin => plugin.name === 'sentry-sveltekit-global-values-injection-plugin')!; +} + +describe('global values injection plugin', () => { + // The whole chain: the SvelteKit Vite plugin's `api.options` -> kit config resolver -> + // adapter detection -> adapter output dir + hooks file. Before SvelteKit 3 this came from + // `svelte.config.js`, which no longer exists there. + async function getPluginsForKitConfig(kitConfigOptions: unknown): Promise { + const plugins = await sentrySvelteKit({ autoUploadSourceMaps: true, autoInstrument: false }); + + const resolver = plugins.find(plugin => plugin.name === 'sentry-sveltekit-kit-config-resolver')!; + // @ts-expect-error this hook exists and is callable + resolver.config({ + plugins: [{ name: 'vite-plugin-sveltekit-setup', api: { options: kitConfigOptions } }], + }); + + return plugins; + } + + const nodeAdapterWithCustomOutDir = { + name: '@sveltejs/adapter-node', + adapt: (builder: { writeClient: (dest: string) => void }) => { + builder.writeClient('custom-build/client'); + }, + }; + + it("injects the adapter's custom output directory", async () => { + const plugins = await getPluginsForKitConfig({ adapter: nodeAdapterWithCustomOutDir }); + + // @ts-expect-error this hook exists and is callable + const result = await getGlobalValuesPlugin(plugins).load(VIRTUAL_GLOBAL_VALUES_FILE); + + expect(result.code).toContain('globalThis["__sentry_sveltekit_output_dir"] = "custom-build";'); + }); + + it('injects into a custom server hooks file', async () => { + const plugins = await getPluginsForKitConfig({ + adapter: nodeAdapterWithCustomOutDir, + files: { hooks: { server: 'src/my-hooks.server' } }, + }); + const plugin = getGlobalValuesPlugin(plugins); + + // @ts-expect-error this hook exists and is callable + const customHooksResult = await plugin.transform('const a = 1;', '/project/src/my-hooks.server.ts'); + // @ts-expect-error this hook exists and is callable + const defaultHooksResult = await plugin.transform('const a = 1;', '/project/src/hooks.server.ts'); + + expect(customHooksResult.code).toContain(VIRTUAL_GLOBAL_VALUES_FILE); + expect(defaultHooksResult).toBeNull(); + }); + + it('falls back to the default output directory if the config has no adapter', async () => { + const plugins = await getPluginsForKitConfig({}); + + // @ts-expect-error this hook exists and is callable + const result = await getGlobalValuesPlugin(plugins).load(VIRTUAL_GLOBAL_VALUES_FILE); + + expect(result.code).toContain('globalThis["__sentry_sveltekit_output_dir"]'); + }); +}); + +describe('adapter output dir resolution', () => { + // Resolving the output directory for the Node adapter means calling `adapter.adapt()`, and + // `@sveltejs/adapter-node` v6 wipes the output directory when it runs. So it has to happen + // once, at config time - if it were deferred to e.g. the source maps plugin's `closeBundle`, + // it would delete the app SvelteKit just built. + it('resolves once, at config time, even when `filesToDeleteAfterUpload` is user-specified', async () => { + const adapt = vi.fn((builder: { writeClient: (dest: string) => void }) => { + builder.writeClient('custom-build/client'); + }); + + const plugins = await sentrySvelteKit({ + autoUploadSourceMaps: true, + autoInstrument: false, + sourcemaps: { filesToDeleteAfterUpload: ['./custom-build/**/*.map'] }, + }); + + const resolver = plugins.find(plugin => plugin.name === 'sentry-sveltekit-kit-config-resolver')!; + // @ts-expect-error this hook exists and is callable + resolver.config({ + plugins: [ + { + name: 'vite-plugin-sveltekit-setup', + api: { options: { adapter: { name: '@sveltejs/adapter-node', adapt } } }, + }, + ], + }); + + const filesToDeletePlugin = plugins.find( + plugin => plugin.name === 'sentry-sveltekit-files-to-delete-after-upload-setting-plugin', + )!; + const globalValuesPlugin = getGlobalValuesPlugin(plugins); + + // This takes the branch that leaves `filesToDeleteAfterUpload` untouched - the adapter still + // has to be resolved here, not later in `closeBundle` + // @ts-expect-error these hooks exist and are callable + await filesToDeletePlugin.config({ build: { sourcemap: true } }); + + expect(adapt).toHaveBeenCalledTimes(1); + + // @ts-expect-error these hooks exist and are callable + await globalValuesPlugin.configResolved({}); + // @ts-expect-error these hooks exist and are callable + await globalValuesPlugin.load(VIRTUAL_GLOBAL_VALUES_FILE); + + // Shared across the plugins: the adapter must not be invoked once per consumer + expect(adapt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/sveltekit/test/vite/kitConfig.test.ts b/packages/sveltekit/test/vite/kitConfig.test.ts new file mode 100644 index 000000000000..c8315d1799dc --- /dev/null +++ b/packages/sveltekit/test/vite/kitConfig.test.ts @@ -0,0 +1,179 @@ +import type { Plugin } from 'vite'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { VIRTUAL_GLOBAL_VALUES_FILE } from '../../src/vite/injectGlobalValues'; +import { createKitConfigResolver, isNativeServerTracingEnabled } from '../../src/vite/kitConfig'; +import { sentrySvelteKit } from '../../src/vite/sentryVitePlugins'; + +const loadSvelteConfig = vi.hoisted(() => vi.fn().mockResolvedValue({})); + +vi.mock('../../src/vite/svelteConfig', async () => { + const actual = (await vi.importActual('../../src/vite/svelteConfig')) as object; + return { ...actual, loadSvelteConfig }; +}); + +/** The SvelteKit Vite plugin exposes the resolved SvelteKit config on its plugin `api`. */ +function kitPlugin(options: unknown): Plugin { + return { name: 'vite-plugin-sveltekit-setup', api: { options } } as Plugin; +} + +function callConfigHook(resolver: ReturnType, plugins: unknown): void { + // @ts-expect-error this hook exists and is callable + resolver.plugin.config({ plugins }); +} + +function callConfigResolvedHook(resolver: ReturnType, plugins: unknown): Promise { + // @ts-expect-error this hook exists and is callable + return resolver.plugin.configResolved({ plugins, build: {} }); +} + +describe('createKitConfigResolver', () => { + beforeEach(() => { + vi.clearAllMocks(); + loadSvelteConfig.mockResolvedValue({}); + }); + + it('returns a plugin that runs before other plugins', () => { + const resolver = createKitConfigResolver(); + + expect(resolver.plugin.name).toEqual('sentry-sveltekit-kit-config-resolver'); + expect(resolver.plugin.enforce).toEqual('pre'); + }); + + describe('from the SvelteKit Vite plugin `api.options`', () => { + it('reads the flat config of SvelteKit 3', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [ + { name: 'some-other-plugin' }, + kitPlugin({ outDir: 'custom-out', files: { hooks: { server: 'src/my-hooks.server' } } }), + ]); + + await expect(resolver.get()).resolves.toEqual({ + outDir: 'custom-out', + files: { hooks: { server: 'src/my-hooks.server' } }, + }); + }); + + it('flattens the `kit`-nested config of SvelteKit 2', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [kitPlugin({ preprocess: {}, kit: { outDir: 'custom-out' } })]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'custom-out' }); + }); + + it('finds the plugin in nested plugin arrays', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [[{ name: 'some-other-plugin' }], [kitPlugin({ outDir: 'nested' })]]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'nested' }); + }); + + it('skips entries that are still unresolved promises', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [Promise.resolve([{ name: 'some-plugin' }]), kitPlugin({ outDir: 'from-plugin' })]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'from-plugin' }); + }); + + it('resolves in `configResolved` if the plugin was not visible in `config` yet', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [Promise.resolve(kitPlugin({ outDir: 'late' }))]); + await callConfigResolvedHook(resolver, [kitPlugin({ outDir: 'late' })]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'late' }); + expect(loadSvelteConfig).not.toHaveBeenCalled(); + }); + + it('keeps the config from the `config` hook, even if `configResolved` runs later', async () => { + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [kitPlugin({ outDir: 'first' })]); + await callConfigResolvedHook(resolver, [kitPlugin({ outDir: 'second' })]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'first' }); + }); + }); + + describe('svelte.config.js fallback', () => { + it('falls back if no SvelteKit plugin is registered', async () => { + loadSvelteConfig.mockResolvedValue({ kit: { outDir: 'from-svelte-config' } }); + const resolver = createKitConfigResolver(); + + callConfigHook(resolver, [{ name: 'some-other-plugin' }]); + await callConfigResolvedHook(resolver, [{ name: 'some-other-plugin' }]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'from-svelte-config' }); + }); + + it('falls back if the SvelteKit plugin exposes no options', async () => { + loadSvelteConfig.mockResolvedValue({ kit: { outDir: 'from-svelte-config' } }); + const resolver = createKitConfigResolver(); + + await callConfigResolvedHook(resolver, [{ name: 'vite-plugin-sveltekit-setup' }]); + + await expect(resolver.get()).resolves.toEqual({ outDir: 'from-svelte-config' }); + }); + + it('resolves to an empty config if there is no svelte.config.js either', async () => { + const resolver = createKitConfigResolver(); + + await callConfigResolvedHook(resolver, undefined); + + await expect(resolver.get()).resolves.toEqual({}); + }); + }); +}); + +describe('isNativeServerTracingEnabled', () => { + it.each([ + ['SvelteKit 3 (>= next.8)', { tracing: { server: true } }, true], + ['SvelteKit 2.31+ / early Kit 3 prereleases', { experimental: { tracing: { server: true } } }, true], + ['explicitly disabled', { tracing: { server: false } }, false], + ['not configured', {}, false], + ])('returns %s -> %s', (_name, config, expected) => { + expect(isNativeServerTracingEnabled(config)).toBe(expected); + }); +}); + +describe('resolution through a real Vite config resolution', () => { + // The unit tests above invoke the hooks by hand, so they'd still pass if the ordering invariant + // broke (resolver no longer first / no longer `enforce: 'pre'`). This lets Vite drive the hooks + // instead: if a consumer ever awaits the resolver from a hook that runs before it settles, this + // hangs instead of passing. + it('resolves the SvelteKit config when Vite runs the plugins', async () => { + const { resolveConfig } = await import('vite'); + + const sentryPlugins = await sentrySvelteKit({ autoUploadSourceMaps: true, autoInstrument: false }); + + const resolved = await resolveConfig( + { + configFile: false, + logLevel: 'error', + plugins: [ + sentryPlugins, + { + name: 'vite-plugin-sveltekit-setup', + api: { options: { outDir: 'resolved-through-vite' } }, + }, + ], + }, + 'build', + ); + + expect(resolved.plugins.map(plugin => plugin.name)).toContain('sentry-sveltekit-kit-config-resolver'); + + const globalValuesPlugin = resolved.plugins.find( + plugin => plugin.name === 'sentry-sveltekit-global-values-injection-plugin', + )!; + + // @ts-expect-error this hook exists and is callable + const result = await globalValuesPlugin.load(VIRTUAL_GLOBAL_VALUES_FILE); + + // `.svelte-kit` is the default; `resolved-through-vite` proves the plugin config was read + expect(result.code).toContain('resolved-through-vite/output'); + }); +}); diff --git a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts index e268639a777b..8a4c22597c2c 100644 --- a/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts +++ b/packages/sveltekit/test/vite/sentrySvelteKitPlugins.test.ts @@ -64,15 +64,18 @@ describe('sentrySvelteKit()', () => { const plugins = await getSentrySvelteKitPlugins(); expect(plugins).toBeInstanceOf(Array); - // 1 browser-tracing variant resolver + 1 auto instrument plugin + 1 orchestrion plugin - // + 1 global values injection plugin + 1 modified main plugin + 3 custom plugins - expect(plugins).toHaveLength(8); + // 1 kit config resolver + 1 browser-tracing variant resolver + 1 auto instrument plugin + // + 1 orchestrion plugin + 1 global values injection plugin + 1 modified main plugin + // + 3 custom plugins + expect(plugins).toHaveLength(9); }); it('returns the custom sentry source maps upload plugin, unmodified sourcemaps plugins and the auto-instrument plugin by default', async () => { const plugins = await getSentrySvelteKitPlugins(); const pluginNames = plugins.map(plugin => plugin.name); expect(pluginNames).toEqual([ + // kit config resolver (must come first so later plugins can await the resolved config): + 'sentry-sveltekit-kit-config-resolver', // browser-tracing variant resolver: 'sentry-sveltekit-browser-tracing-variant', // auto instrument plugin: @@ -92,7 +95,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the sentry source maps plugins if autoUploadSourcemaps is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: false }); - expect(plugins).toHaveLength(3); // browser-tracing variant resolver + auto instrument + orchestrion + expect(plugins).toHaveLength(4); // kit config resolver + browser-tracing variant resolver + auto instrument + orchestrion }); it("doesn't return the sentry source maps plugins if `NODE_ENV` is development", async () => { @@ -100,9 +103,9 @@ describe('sentrySvelteKit()', () => { process.env.NODE_ENV = 'development'; const plugins = await getSentrySvelteKitPlugins({ autoUploadSourceMaps: true, autoInstrument: true }); - const instrumentPlugin = plugins[1]; + const instrumentPlugin = plugins[2]; - expect(plugins).toHaveLength(4); // browser-tracing variant resolver + auto instrument + orchestrion + global values injection + expect(plugins).toHaveLength(5); // kit config resolver + browser-tracing variant resolver + auto instrument + orchestrion + global values injection expect(instrumentPlugin?.name).toEqual('sentry-auto-instrumentation'); process.env.NODE_ENV = previousEnv; @@ -111,7 +114,7 @@ describe('sentrySvelteKit()', () => { it("doesn't return the auto instrument plugin if autoInstrument is `false`", async () => { const plugins = await getSentrySvelteKitPlugins({ autoInstrument: false }); const pluginNames = plugins.map(plugin => plugin.name); - expect(plugins).toHaveLength(7); // browser-tracing variant resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins + expect(plugins).toHaveLength(8); // kit config resolver + browser-tracing variant resolver + orchestrion + global values injection + 1 modified main plugin + 3 custom plugins expect(pluginNames).not.toContain('sentry-auto-instrumentation'); }); @@ -161,9 +164,8 @@ describe('sentrySvelteKit()', () => { ignore: ['bar/*.js'], filesToDeleteAfterUpload: ['baz/*.js'], }, - adapter: 'vercel', }, - {}, + { getAdapterOutputDir: expect.any(Function) }, ); }); @@ -208,9 +210,8 @@ describe('sentrySvelteKit()', () => { headers: { 'X-My-Header': 'foo', }, - adapter: 'vercel', }, - {}, + { getAdapterOutputDir: expect.any(Function) }, ); }); @@ -225,14 +226,14 @@ describe('sentrySvelteKit()', () => { // just to ignore the source maps plugin: autoUploadSourceMaps: false, }); - const plugin = plugins[1]!; + const plugin = plugins[2]!; expect(plugin.name).toEqual('sentry-auto-instrumentation'); expect(makePluginSpy).toHaveBeenCalledWith({ debug: true, load: true, serverLoad: false, - onlyInstrumentClient: false, + getKitConfig: expect.any(Function), }); }); }); @@ -326,7 +327,7 @@ describe('generateVitePluginOptions', () => { process.env.NODE_ENV = originalEnv; }); - it('handles adapter and debug options correctly', () => { + it("handles the debug option and doesn't forward the adapter", () => { const originalEnv = process.env.NODE_ENV; process.env.NODE_ENV = 'production'; // Ensure we're not in development mode @@ -338,11 +339,11 @@ describe('generateVitePluginOptions', () => { org: 'org', project: 'project', }; + // The adapter is resolved through the kit config resolver, not forwarded to the Vite plugin const expected: CustomSentryVitePluginOptions = { authToken: 'token', org: 'org', project: 'project', - adapter: 'vercel', debug: true, }; const result = generateVitePluginOptions(options); @@ -451,7 +452,6 @@ describe('generateVitePluginOptions', () => { name: 'root-1.0.0', inject: false, }, - adapter: undefined, debug: false, }); }); diff --git a/packages/sveltekit/test/vite/sourceMaps.test.ts b/packages/sveltekit/test/vite/sourceMaps.test.ts index f38e7e38fd45..49c3a6757915 100644 --- a/packages/sveltekit/test/vite/sourceMaps.test.ts +++ b/packages/sveltekit/test/vite/sourceMaps.test.ts @@ -47,9 +47,8 @@ async function getSentryViteSubPlugin(name: string): Promise authToken: 'token', org: 'org', project: 'project', - adapter: 'other', }, - { kit: {} }, + { getAdapterOutputDir: async () => '.svelte-kit/output' }, ); return plugins.find(plugin => plugin.name === name); @@ -313,9 +312,8 @@ describe('deleteFilesAfterUpload', () => { authToken: 'token', org: 'org', project: 'project', - adapter: 'other', }, - { kit: {} }, + { getAdapterOutputDir: async () => '.svelte-kit/output' }, ); // @ts-expect-error this function exists! @@ -330,7 +328,6 @@ describe('deleteFilesAfterUpload', () => { authToken: 'token', org: 'org', project: 'project', - adapter: 'other', release: { name: expect.any(String), }, @@ -391,12 +388,11 @@ describe('deleteFilesAfterUpload', () => { authToken: 'token', org: 'org', project: 'project', - adapter: 'other', sourcemaps: { filesToDeleteAfterUpload, }, }, - { kit: {} }, + { getAdapterOutputDir: async () => '.svelte-kit/output' }, ); // @ts-expect-error this function exists! @@ -411,7 +407,6 @@ describe('deleteFilesAfterUpload', () => { authToken: 'token', org: 'org', project: 'project', - adapter: 'other', release: { name: expect.any(String), }, diff --git a/packages/sveltekit/test/vite/svelteConfig.test.ts b/packages/sveltekit/test/vite/svelteConfig.test.ts index d2b62e2e0def..8fc78ff37647 100644 --- a/packages/sveltekit/test/vite/svelteConfig.test.ts +++ b/packages/sveltekit/test/vite/svelteConfig.test.ts @@ -63,25 +63,25 @@ describe('getAdapterOutputDir', () => { }; it('returns the output directory of the Node adapter', async () => { - const outputDir = await getAdapterOutputDir({ kit: { adapter: mockedAdapter } }, 'node'); + const outputDir = await getAdapterOutputDir({ adapter: mockedAdapter }, 'node'); expect(outputDir).toEqual('customBuildDir'); }); it('returns the output directory of the Cloudflare adapter', async () => { - const outputDir = await getAdapterOutputDir({ kit: { outDir: 'customOutDir' } }, 'cloudflare'); + const outputDir = await getAdapterOutputDir({ outDir: 'customOutDir' }, 'cloudflare'); expect(outputDir).toEqual('customOutDir/cloudflare'); }); 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); + const outputDir = await getAdapterOutputDir({ 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'); + const outputDir = await getAdapterOutputDir({}, 'vercel'); expect(outputDir).toEqual('.svelte-kit/output'); }); }); @@ -93,7 +93,7 @@ describe('getHooksFileName', () => { }); it('returns the custom hooks file name if specified in the config', () => { - const hooksFileName = getHooksFileName({ kit: { files: { hooks: { server: 'serverhooks' } } } }, 'server'); + const hooksFileName = getHooksFileName({ files: { hooks: { server: 'serverhooks' } } }, 'server'); expect(hooksFileName).toEqual('serverhooks'); }); });