From 4e557dc31486c4320b262a26442d982568630aaf Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 12 Aug 2026 22:57:37 +0200 Subject: [PATCH] fix: Better loading of SvelteKit routes directory Instead of accessing private files from SvelteKit, we use @sveltejs/load-config to load the Svelte config (that package also knows about checking Vite config). The deadlock is avoided by having a module-level Set to see if we're currently recursing or not. This is necessary for SvelteKit 3 since there the config lives exclusively in the vite config, and the previous logic did not handle that. This also uncovered that we're needlessly rebuilding the generated files in sub builds/workers (SvelteKit, at least below 3, starts off secondary builds; and some things are done in workers), which a new file cache now checks. Picked from stable branch PR #3474 Signed-off-by: Simon Holthausen --- .changeset/sveltekit-public-config-loader.md | 5 + packages/sveltekit/package.json | 5 +- packages/sveltekit/src/builder.ts | 40 +---- packages/sveltekit/src/index.ts | 8 - packages/sveltekit/src/plugin.test.ts | 147 ++++++++++++++++++ packages/sveltekit/src/plugin.ts | 150 +++++++++++++++++-- pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 1 + 8 files changed, 311 insertions(+), 59 deletions(-) create mode 100644 .changeset/sveltekit-public-config-loader.md create mode 100644 packages/sveltekit/src/plugin.test.ts diff --git a/.changeset/sveltekit-public-config-loader.md b/.changeset/sveltekit-public-config-loader.md new file mode 100644 index 0000000000..3970092254 --- /dev/null +++ b/.changeset/sveltekit-public-config-loader.md @@ -0,0 +1,5 @@ +--- +'@workflow/sveltekit': patch +--- + +Load SvelteKit route configuration through `@sveltejs/load-config`, including projects that configure SvelteKit exclusively in `vite.config`. diff --git a/packages/sveltekit/package.json b/packages/sveltekit/package.json index 4c0c4bd817..b68dbc75c0 100644 --- a/packages/sveltekit/package.json +++ b/packages/sveltekit/package.json @@ -22,9 +22,11 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", + "test": "vitest run src", "clean": "tsc --build --clean && rm -rf dist" }, "dependencies": { + "@sveltejs/load-config": "^0.2.3", "@swc/core": "catalog:", "@workflow/builders": "workspace:*", "@workflow/rollup": "workspace:*", @@ -39,6 +41,7 @@ "@types/node": "catalog:", "@workflow/tsconfig": "workspace:*", "typescript": "catalog:", - "vite": "7.3.6" + "vite": "7.3.6", + "vitest": "catalog:" } } diff --git a/packages/sveltekit/src/builder.ts b/packages/sveltekit/src/builder.ts index 27514b3d94..87d17e4337 100644 --- a/packages/sveltekit/src/builder.ts +++ b/packages/sveltekit/src/builder.ts @@ -8,9 +8,7 @@ import { stat, writeFile, } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { dirname, join, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { join, resolve } from 'node:path'; import { BaseBuilder, createBaseBuilderConfig, @@ -27,7 +25,7 @@ const SVELTEKIT_VIRTUAL_MODULES = [ ]; export class SvelteKitBuilder extends BaseBuilder { - #routesDir: string | undefined; + #routesDir: string; constructor(config: Partial & { routesDir?: string } = {}) { const workingDir = resolve(config.workingDir || process.cwd()); @@ -38,9 +36,7 @@ export class SvelteKitBuilder extends BaseBuilder { 'src/routes', ]; const projectRoot = config.projectRoot ?? resolveProjectRoot(workingDir); - const routesDir = config.routesDir - ? resolve(workingDir, config.routesDir) - : undefined; + const routesDir = resolve(workingDir, config.routesDir ?? 'src/routes'); super({ ...createBaseBuilderConfig({ workingDir, @@ -197,37 +193,11 @@ export const OPTIONS = createSvelteKitHandler('OPTIONS');` } private async loadRoutesDirectory(): Promise { - const routesDir = - this.#routesDir ?? (await loadSvelteKitRoutesDir(this.config.workingDir)); - await assertDirectory(routesDir); - return routesDir; + await assertDirectory(this.#routesDir); + return this.#routesDir; } } -export async function loadSvelteKitRoutesDir( - workingDir: string -): Promise { - const require = createRequire(join(workingDir, 'package.json')); - const packageJsonPath = require.resolve('@sveltejs/kit/package.json'); - const loaderPath = join(dirname(packageJsonPath), 'src/core/config/index.js'); - - const configModule = await import(pathToFileURL(loaderPath).href); - // SvelteKit 2.62+ `load_config()` resolves Vite config first. Calling it - // while `workflow/sveltekit` is imported from vite.config.ts recursively - // reloads vite.config.ts and leaves this top-level build unresolved. - const config = - configModule.load_svelte_config != null - ? await configModule.load_svelte_config(workingDir) - : await configModule.load_config({ cwd: workingDir }); - const routesDir = config.kit?.files?.routes; - if (routesDir == null || typeof routesDir !== 'string') { - throw new Error( - 'Expected SvelteKit config loader to return kit.files.routes as a string.' - ); - } - return routesDir; -} - async function assertDirectory(path: string): Promise { await access(path, constants.F_OK); const stats = await stat(path); diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts index ffb635d8ef..7c92a8b9b5 100644 --- a/packages/sveltekit/src/index.ts +++ b/packages/sveltekit/src/index.ts @@ -2,16 +2,8 @@ import path from 'node:path'; import { getWorkflowQueueTrigger } from '@workflow/builders'; import fs from 'fs-extra'; -import { SvelteKitBuilder } from './builder.js'; import { stripWorkflowQueueTriggers } from './vc-config.js'; -const builder = new SvelteKitBuilder(); - -// This needs to be in the top-level as we need to create these -// entries before svelte plugin is started or the entries are -// a race to be created before svelte discovers entries -await builder.build(); - process.on('beforeExit', () => { // Don't patch functions output if not in Vercel adapter if (!process.env.VERCEL_DEPLOYMENT_ID) { diff --git a/packages/sveltekit/src/plugin.test.ts b/packages/sveltekit/src/plugin.test.ts new file mode 100644 index 0000000000..0dd1dc421a --- /dev/null +++ b/packages/sveltekit/src/plugin.test.ts @@ -0,0 +1,147 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + build: vi.fn<() => Promise>(), + builderConfigs: [] as Array>, + loadConfig: vi.fn(), +})); + +vi.mock('@sveltejs/load-config', () => ({ + loadConfig: mocks.loadConfig, +})); + +vi.mock('@workflow/builders', () => ({ + createBuildQueue: () => (fn: () => Promise) => fn(), +})); + +vi.mock('@workflow/rollup', () => ({ + workflowTransformPlugin: () => ({ name: 'workflow:transform' }), +})); + +vi.mock('@workflow/vite', () => ({ + workflowHotUpdatePlugin: () => ({ name: 'workflow:hot-update' }), +})); + +vi.mock('./builder.js', () => ({ + SvelteKitBuilder: class { + constructor(config: Record) { + mocks.builderConfigs.push(config); + } + + build = mocks.build; + }, +})); + +import { workflowPlugin } from './plugin.js'; + +const originalWorkingDir = process.cwd(); +let workingDir: string; + +beforeEach(async () => { + workingDir = await mkdtemp(join(tmpdir(), 'workflow-sveltekit-')); + process.chdir(workingDir); + workingDir = process.cwd(); // MacOS: resolves symlink of generated /var/folders/... through to /private/var/folders/... + mocks.build.mockResolvedValue(undefined); +}); + +afterEach(async () => { + process.chdir(originalWorkingDir); + await rm(workingDir, { force: true, recursive: true }); + vi.clearAllMocks(); + mocks.builderConfigs.length = 0; +}); + +describe('workflowPlugin', () => { + it('loads svelte.config.js directly and uses the default routes directory', async () => { + const configPath = join(workingDir, 'svelte.config.js'); + await writeFile(configPath, 'export default {};'); + mocks.loadConfig.mockResolvedValue({ + config: {}, + configFilePath: configPath, + configSource: 'svelte', + }); + + const [plugins] = workflowPlugin(); + await plugins; + + expect(mocks.loadConfig).toHaveBeenCalledWith(configPath, { + traverse: false, + }); + expect(mocks.builderConfigs).toEqual([ + { routesDir: join(workingDir, 'src/routes'), workingDir }, + ]); + expect(mocks.build).toHaveBeenCalledOnce(); + }); + + it('loads routes from the SvelteKit Vite plugin without recursing', async () => { + const routesDir = join(workingDir, 'app/routes'); + let nestedPlugins: ReturnType | undefined; + mocks.loadConfig.mockImplementation(async () => { + nestedPlugins = workflowPlugin(); + return { + config: { kit: { files: { routes: routesDir } } }, + configFilePath: join(workingDir, 'vite.config.ts'), + configSource: 'vite', + }; + }); + + const [plugins] = workflowPlugin(); + await plugins; + + expect(nestedPlugins).toEqual([]); + expect(mocks.loadConfig).toHaveBeenCalledWith(workingDir, { + traverse: false, + }); + expect(mocks.builderConfigs).toEqual([{ routesDir, workingDir }]); + expect(mocks.build).toHaveBeenCalledOnce(); + }); + + it('reuses routes generated by another Vite config context', async () => { + const routesDir = join(workingDir, 'app/routes'); + await mkdir(join(workingDir, 'node_modules/.cache/workflow'), { + recursive: true, + }); + await mkdir(join(routesDir, '.well-known/workflow/v1/flow'), { + recursive: true, + }); + await writeFile( + join(workingDir, 'node_modules/.cache/workflow/sveltekit-build.json'), + JSON.stringify({ pid: process.pid, routesDir }) + ); + await writeFile( + join(routesDir, '.well-known/workflow/v1/flow/+server.js'), + '' + ); + + const [plugins] = workflowPlugin(); + await plugins; + + expect(mocks.loadConfig).not.toHaveBeenCalled(); + expect(mocks.builderConfigs).toEqual([{ routesDir, workingDir }]); + expect(mocks.build).not.toHaveBeenCalled(); + }); + + it('passes source map options to the builder', async () => { + const configPath = join(workingDir, 'svelte.config.js'); + await writeFile(configPath, 'export default {};'); + mocks.loadConfig.mockResolvedValue({ + config: {}, + configFilePath: configPath, + configSource: 'svelte', + }); + + const [plugins] = workflowPlugin({ sourcemap: 'external' }); + await plugins; + + expect(mocks.builderConfigs).toEqual([ + { + routesDir: join(workingDir, 'src/routes'), + workingDir, + sourcemap: 'external', + }, + ]); + }); +}); diff --git a/packages/sveltekit/src/plugin.ts b/packages/sveltekit/src/plugin.ts index 5e01ad4743..34db464527 100644 --- a/packages/sveltekit/src/plugin.ts +++ b/packages/sveltekit/src/plugin.ts @@ -1,7 +1,11 @@ +import { existsSync } from 'node:fs'; +import { access, mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { loadConfig } from '@sveltejs/load-config'; import { createBuildQueue } from '@workflow/builders'; import { workflowTransformPlugin } from '@workflow/rollup'; import { workflowHotUpdatePlugin } from '@workflow/vite'; -import type { Plugin } from 'vite'; +import type { Plugin, PluginOption } from 'vite'; import { SvelteKitBuilder } from './builder.js'; export interface WorkflowPluginOptions { @@ -14,8 +18,29 @@ export interface WorkflowPluginOptions { sourcemap?: boolean | 'inline' | 'linked' | 'external' | 'both'; } -export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { - let builder: SvelteKitBuilder | undefined; +const resolvingConfig = new Set(); +const builders = new Map>(); +// SvelteKit starts secondary Vite builds in worker threads. Those workers +// reevaluate vite.config with a fresh module cache, so they cannot see the +// builder above. Persist the resolved routes directory in the dependency cache +// to avoid loading the config and rebuilding routes again. +const BUILD_CACHE_PATH = 'node_modules/.cache/workflow/sveltekit-build.json'; + +export function workflowPlugin( + options: WorkflowPluginOptions = {} +): PluginOption[] { + const workingDir = process.cwd(); + + // loadConfig() resolves vite.config when SvelteKit options live there. That + // recursively evaluates this plugin, which must not wait on its own setup. + if (resolvingConfig.has(workingDir)) { + return []; + } + + return [getBuilder(workingDir, options).then(createWorkflowPlugins)]; +} + +function createWorkflowPlugins(builder: SvelteKitBuilder): Plugin[] { const enqueue = createBuildQueue(); return [ @@ -49,13 +74,6 @@ export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { // bundled lib that, on seeing `require`, does `require()` of an ESM-only // dependency on a Node version without `require(ESM)` support. configResolved(config) { - if (config.command === 'serve') { - builder = new SvelteKitBuilder({ - workingDir: config.root, - sourcemap: options.sourcemap, - }); - } - if (!config.build?.ssr) { return; } @@ -80,8 +98,118 @@ export function workflowPlugin(options: WorkflowPluginOptions = {}): Plugin[] { }, }, workflowHotUpdatePlugin({ - builder: () => builder, + builder, enqueue, }), ]; } + +function getBuilder( + workingDir: string, + options: WorkflowPluginOptions +): Promise { + const existing = builders.get(workingDir); + if (existing) { + return existing; + } + + const builder = loadBuilder(workingDir, options).catch((error) => { + builders.delete(workingDir); + throw error; + }); + builders.set(workingDir, builder); + return builder; +} + +async function loadBuilder( + workingDir: string, + options: WorkflowPluginOptions +): Promise { + const cachedRoutesDir = await readCachedRoutesDir(workingDir); + if (cachedRoutesDir) { + return new SvelteKitBuilder({ + routesDir: cachedRoutesDir, + workingDir, + ...options, + }); + } + + const svelteConfigPath = join(workingDir, 'svelte.config.js'); + resolvingConfig.add(workingDir); + + let result: Awaited>; + try { + result = await loadConfig( + existsSync(svelteConfigPath) ? svelteConfigPath : workingDir, + { traverse: false } + ); + } finally { + resolvingConfig.delete(workingDir); + } + + if (result == null) { + throw new Error(`Could not find a Svelte config in ${workingDir}.`); + } + if ('error' in result) { + throw new Error( + `Failed to load Svelte config from ${result.configFilePath}.`, + { + cause: result.error, + } + ); + } + + const routesDir = ( + result.config as { kit?: { files?: { routes?: unknown } } } + ).kit?.files?.routes; + if (routesDir !== undefined && typeof routesDir !== 'string') { + throw new Error('Expected kit.files.routes to be a string.'); + } + + const resolvedRoutesDir = resolve(workingDir, routesDir ?? 'src/routes'); + const builder = new SvelteKitBuilder({ + routesDir: resolvedRoutesDir, + workingDir, + ...options, + }); + await builder.build(); + await writeBuildCache(workingDir, resolvedRoutesDir); + return builder; +} + +async function readCachedRoutesDir( + workingDir: string +): Promise { + try { + const cache = JSON.parse( + await readFile(join(workingDir, BUILD_CACHE_PATH), 'utf8') + ) as { pid?: unknown; routesDir?: unknown }; + // Worker threads have isolated module state but share the parent process's + // PID. A later Vite command has a new PID and must perform a fresh build. + if (cache.pid !== process.pid || typeof cache.routesDir !== 'string') { + return; + } + + await access( + join(cache.routesDir, '.well-known/workflow/v1/flow/+server.js') + ); + return cache.routesDir; + } catch { + return; + } +} + +async function writeBuildCache( + workingDir: string, + routesDir: string +): Promise { + // Bail if node_modules not available; it's fine since this is more of a perf/correctness optimization + if (!existsSync(join(workingDir, 'node_modules'))) return; + + const cacheDir = join(workingDir, 'node_modules/.cache/workflow'); + await mkdir(cacheDir, { recursive: true }); + await writeFile( + join(workingDir, BUILD_CACHE_PATH), + JSON.stringify({ pid: process.pid, routesDir }) + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 392d99262c..189388b6b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -887,6 +887,9 @@ importers: packages/sveltekit: dependencies: + '@sveltejs/load-config': + specifier: ^0.2.3 + version: 0.2.3 '@swc/core': specifier: 'catalog:' version: 1.15.3 @@ -927,6 +930,9 @@ importers: vite: specifier: 7.3.6 version: 7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.19.0)(@vitest/coverage-v8@4.1.10)(jsdom@26.1.0)(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0)) packages/swc-plugin-workflow: dependencies: @@ -8853,8 +8859,8 @@ packages: typescript: optional: true - '@sveltejs/load-config@0.2.0': - resolution: {integrity: sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==} + '@sveltejs/load-config@0.2.3': + resolution: {integrity: sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==} engines: {node: '>= 18.0.0'} '@sveltejs/vite-plugin-svelte@7.1.2': @@ -25163,7 +25169,7 @@ snapshots: '@opentelemetry/api': 1.9.1 typescript: 6.0.3 - '@sveltejs/load-config@0.2.0': {} + '@sveltejs/load-config@0.2.3': {} '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.4(@typescript-eslint/types@8.46.4))(vite@7.3.6(@types/node@22.19.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.9.0))': dependencies: @@ -34879,7 +34885,7 @@ snapshots: svelte-check@4.7.1(picomatch@4.0.4)(svelte@5.56.4(@typescript-eslint/types@8.46.4))(typescript@6.0.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 - '@sveltejs/load-config': 0.2.0 + '@sveltejs/load-config': 0.2.3 chokidar: 4.0.3 fdir: 6.5.0(picomatch@4.0.4) picocolors: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1c02e89a7..9c569d0d7c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -55,4 +55,5 @@ minimumReleaseAgeExclude: - '@workflow/*' - 'esbuild' - '@esbuild/*' + - '@sveltejs/load-config' - 'quickjs-wasi'