From 4ed6ff3e4a7064b1798e3cb3989f5749803801b4 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Thu, 8 Oct 2026 17:00:52 +0200 Subject: [PATCH] fix(sveltekit): Stop adapter-node 6 output dir probe from deleting the build To learn the Node adapter's output directory we call its adapt() with a stub builder. adapter-node 6 deletes its output dir with fs.rmSync before it uses the builder, so loading the Vite config (vite dev, svelte-kit sync, ...) wiped the user's build/ folder. Run adapt() from an empty temp cwd so the relative delete hits the temp dir instead. The stub builder also only had the SvelteKit 2 config shape (config.kit.paths), so adapter-node 6 threw before reporting a custom out dir and we fell back to build. Provide the SvelteKit 3 shape (config.paths) as well. Fixes #25186 Co-Authored-By: Claude Opus 5.5 --- .../sveltekit/src/vite/sentryVitePlugins.ts | 6 ++-- packages/sveltekit/src/vite/svelteConfig.ts | 30 +++++++++++++++- .../sveltekit/test/vite/svelteConfig.test.ts | 36 ++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/sveltekit/src/vite/sentryVitePlugins.ts b/packages/sveltekit/src/vite/sentryVitePlugins.ts index 9052560af04b..d4bf6ead91ec 100644 --- a/packages/sveltekit/src/vite/sentryVitePlugins.ts +++ b/packages/sveltekit/src/vite/sentryVitePlugins.ts @@ -38,9 +38,9 @@ export async function sentrySvelteKit(options: SentrySvelteKitPluginOptions = {} const getAdapter = (): Promise => (adapterPromise ??= (async () => options.adapter || detectAdapter(await getKitConfig(), options.debug))()); - // Side effect: for the Node adapter we invoke `adapter.adapt()` to learn the output directory, - // and `@sveltejs/adapter-node` v6 wipes that directory when it runs. So this must happen once, - // before the build writes anything - never from a late hook like `closeBundle`. + // For the Node adapter we invoke `adapter.adapt()` to learn the output directory. It runs from a temp + // cwd so it can't wipe the real output, but it's still resolved once and before the build writes + // anything, never from a late hook like `closeBundle`, in case an adapter deletes an absolute `out`. let adapterOutputDirPromise: Promise | undefined; const getAdapterOutputDirOnce = (): Promise => (adapterOutputDirPromise ??= (async () => getAdapterOutputDir(await getKitConfig(), await getAdapter()))()); diff --git a/packages/sveltekit/src/vite/svelteConfig.ts b/packages/sveltekit/src/vite/svelteConfig.ts index 3941dc3dbc59..7151ba053f4c 100644 --- a/packages/sveltekit/src/vite/svelteConfig.ts +++ b/packages/sveltekit/src/vite/svelteConfig.ts @@ -1,5 +1,6 @@ import type { Builder } from '@sveltejs/kit'; import * as fs from 'fs'; +import * as os from 'os'; import * as path from 'path'; import * as url from 'url'; import type { SupportedSvelteKitAdapters } from './detectAdapter'; @@ -106,7 +107,11 @@ async function getNodeAdapterOutputDir(kitConfig: ResolvedKitConfig): Promise {}, mkdirp: () => {}, + // SvelteKit 3 passes the kit config directly (`config.paths`), SvelteKit 2 nests it (`config.kit.paths`) config: { + paths: { + base: kitConfig.paths?.base || '', + }, kit: { // @ts-expect-error - the builder expects a validated config but for our purpose it's fine to just pass this partial config paths: { @@ -116,10 +121,33 @@ async function getNodeAdapterOutputDir(kitConfig: ResolvedKitConfig): Promise { - vi.mock('fs', () => { + vi.mock('fs', async importOriginal => { + const actual = await importOriginal(); return { + ...actual, existsSync: () => existsFile, }; }); @@ -67,6 +72,35 @@ describe('getAdapterOutputDir', () => { expect(outputDir).toEqual('customBuildDir'); }); + it("doesn't let the Node adapter delete the real output directory", async () => { + const actualFs = await vi.importActual('fs'); + const originalCwd = process.cwd(); + const projectDir = actualFs.mkdtempSync(path.join(os.tmpdir(), 'sentry-sveltekit-test-')); + actualFs.mkdirSync(path.join(projectDir, 'customOut')); + actualFs.writeFileSync(path.join(projectDir, 'customOut', 'index.js'), ''); + + // Like `@sveltejs/adapter-node` v6, which removes its output dir itself instead of via `builder.rimraf` + const nodeAdapter = { + name: '@sveltejs/adapter-node', + async adapt(builder: any) { + actualFs.rmSync('customOut', { force: true, recursive: true }); + builder.writeClient(`customOut/client${builder.config.paths.base}`); + }, + }; + + process.chdir(projectDir); + try { + const outputDir = await getAdapterOutputDir({ adapter: nodeAdapter }, 'node'); + + expect(outputDir).toEqual('customOut'); + expect(process.cwd()).toEqual(actualFs.realpathSync(projectDir)); + expect(actualFs.readdirSync(path.join(projectDir, 'customOut'))).toEqual(['index.js']); + } finally { + process.chdir(originalCwd); + actualFs.rmSync(projectDir, { force: true, recursive: true }); + } + }); + it('returns the output directory of the Cloudflare adapter', async () => { const outputDir = await getAdapterOutputDir({ outDir: 'customOutDir' }, 'cloudflare'); expect(outputDir).toEqual('customOutDir/cloudflare');