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 09324f1c07..1d77ca833f 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 462c3c4b08..bbd235f0e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -893,6 +893,9 @@ importers: packages/sveltekit: dependencies: + '@sveltejs/load-config': + specifier: ^0.2.3 + version: 0.2.3 '@swc/core': specifier: 'catalog:' version: 1.15.3 @@ -933,6 +936,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: @@ -8865,8 +8871,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': @@ -25175,7 +25181,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: @@ -34891,7 +34897,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 ba8c3d839c..20b6cdebc6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,6 +56,7 @@ minimumReleaseAgeExclude: - '@workflow/*' - 'esbuild' - '@esbuild/*' + - '@sveltejs/load-config' - 'quickjs-wasi' # The CI canary lanes pin `next` to a fresh canary at install time # (see "Setup canary" in .github/workflows/tests.yml); canaries are