From a6026e42f35f2b25bd4f1bbf0da563d65bda83ab Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 12 Feb 2026 12:06:25 +0100 Subject: [PATCH 01/21] feat: support ful bundle mode in ssr --- packages/vite/src/node/config.ts | 26 ++++- .../environments/fullBundleEnvironment.ts | 25 +++-- .../fullBundleRunnableEnvironment.ts | 105 ++++++++++++++++++ packages/vite/src/node/ssr/fetchModule.ts | 51 ++++++++- .../runtime/__tests__/server-runtime.spec.ts | 12 +- 5 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index d45c6372f7d77b..a254337108e2ac 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -131,6 +131,7 @@ import { } from './server/pluginContainer' import { nodeResolveWithVite } from './nodeResolve' import { FullBundleDevEnvironment } from './server/environments/fullBundleEnvironment' +import { FullBundleRunnableDevEnvironment } from './server/environments/fullBundleRunnableEnvironment' const debug = createDebugger('vite:config', { depth: 10 }) const promisifiedRealpath = promisify(fs.realpath) @@ -257,6 +258,13 @@ function defaultCreateClientDevEnvironment( }) } +function defaultCreateSSRDevEnvironment(name: string, config: ResolvedConfig) { + if (config.experimental.ssrBundledDev) { + return new FullBundleRunnableDevEnvironment(name, config) + } + return createRunnableDevEnvironment(name, config) +} + function defaultCreateDevEnvironment(name: string, config: ResolvedConfig) { return createRunnableDevEnvironment(name, config) } @@ -585,6 +593,14 @@ export interface ExperimentalOptions { * @default false */ bundledDev?: boolean + /** + * Enable full bundle mode in SSR. + * + * This is highly experimental. + * + * @experimental + */ + ssrBundledDev?: boolean } export interface LegacyOptions { @@ -835,6 +851,7 @@ const configDefaults = Object.freeze({ hmrPartialAccept: false, enableNativePlugin: process.env._VITE_TEST_JS_PLUGIN ? false : 'v2', bundledDev: false, + ssrBundledDev: false, }, future: { removePluginHookHandleHotUpdate: undefined, @@ -897,7 +914,9 @@ export function resolveDevEnvironmentOptions( createEnvironment: environmentName === 'client' ? defaultCreateClientDevEnvironment - : defaultCreateDevEnvironment, + : environmentName === 'ssr' + ? defaultCreateSSRDevEnvironment + : defaultCreateDevEnvironment, recoverable: consumer === 'client', moduleRunnerTransform: consumer === 'server', }, @@ -1888,7 +1907,10 @@ export async function resolveConfig( cacheDir, command, mode, - isBundled: config.experimental?.bundledDev || isBuild, + isBundled: + config.experimental?.bundledDev || + config.experimental?.ssrBundledDev || + isBuild, // TODO: ssr shouldn't break client isWorker: false, mainConfig: null, bundleChain: [], diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index d74e65c654291d..0e15cb4d26ec82 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -72,17 +72,18 @@ export class FullBundleDevEnvironment extends DevEnvironment { }) memoryFiles: MemoryFiles = new MemoryFiles() + facadeToChunk: Map = new Map() constructor( name: string, config: ResolvedConfig, context: DevEnvironmentContext, ) { - if (name !== 'client') { - throw new Error( - 'currently full bundle mode is only available for client environment', - ) - } + // if (name !== 'client') { + // throw new Error( + // 'currently full bundle mode is only available for client environment', + // ) + // } super(name, config, { ...context, disableDepsOptimizer: true }) } @@ -158,6 +159,12 @@ export class FullBundleDevEnvironment extends DevEnvironment { // NOTE: don't clear memoryFiles here as incremental build re-uses the files for (const outputFile of result.output) { + if (outputFile.type === 'chunk' && outputFile.facadeModuleId) { + this.facadeToChunk.set( + outputFile.facadeModuleId, + outputFile.fileName, + ) + } this.memoryFiles.set(outputFile.fileName, () => { const source = outputFile.type === 'chunk' ? outputFile.code : outputFile.source @@ -187,7 +194,7 @@ export class FullBundleDevEnvironment extends DevEnvironment { }) } - private async waitForInitialBuildFinish(): Promise { + protected async waitForInitialBuildFinish(): Promise { await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { await setTimeout(10) @@ -275,12 +282,16 @@ export class FullBundleDevEnvironment extends DevEnvironment { await Promise.all([super.close(), this.devEngine.close()]) } + protected async getDevRuntimeImplementation(): Promise { + return await getHmrImplementation(this.getTopLevelConfig()) + } + private async getRolldownOptions() { const chunkMetadataMap = new ChunkMetadataMap() const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) rolldownOptions.experimental ??= {} rolldownOptions.experimental.devMode = { - implement: await getHmrImplementation(this.getTopLevelConfig()), + implement: await this.getDevRuntimeImplementation(), } if (rolldownOptions.optimization) { diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts new file mode 100644 index 00000000000000..181ed64a2af982 --- /dev/null +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -0,0 +1,105 @@ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { ModuleRunner } from 'vite/module-runner' +import { + type ResolvedConfig, + createServerHotChannel, + createServerModuleRunner, +} from '../../index' +import { slash } from '../../../shared/utils' +import { FullBundleDevEnvironment } from './fullBundleEnvironment' + +export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { + private _runner: ModuleRunner | undefined + + constructor(name: string, config: ResolvedConfig) { + // Since this is not yet exposed, we create hot channel here + super(name, config, { + hot: true, + transport: createServerHotChannel(), + }) + } + + get runner(): ModuleRunner { + if (this._runner) { + return this._runner + } + this._runner = createServerModuleRunner(this) + // TODO: don't patch + const importModule = this.runner.import.bind(this.runner) + this._runner.import = async (url: string) => { + await this.waitForInitialBuildFinish() + const fileName = this.resolveEntryFilename(url) + if (!fileName) { + throw new Error( + `[vite] Entrypoint '${url}' was not defined in the config. Available entry points: \n- ${[...this.facadeToChunk.keys()].join('\n- ')}`, + ) + } + return importModule(fileName) + } + return this._runner + } + + private resolveEntryFilename(url: string) { + // Already resolved by the user to be a url + if (this.facadeToChunk.has(url)) { + return this.facadeToChunk.get(url) + } + const moduleId = url.startsWith('file://') + ? // new URL(path) + fileURLToPath(url) + : // ./index.js + // NOTE: we don't try to find it if extension is not passed + // It will throw an error instead + slash(resolve(this.config.root, url)) + return this.facadeToChunk.get(moduleId) + } + + protected override async getDevRuntimeImplementation(): Promise { + // TODO: this shoult not be in this file + return ` + class ViteDevRuntime extends DevRuntime { + override createModuleHotContext(moduleId) { + const ctx = __vite_ssr_import_meta__.hot + // TODO: what is this? + // ctx._internal = { updateStyle, removeStyle } + return ctx + } + + override applyUpdates() { + // noop, handled in the HMR client + } + } + + const wrappedSocket = { + send(message) { + switch (message.type) { + case 'hmr:module-registered': { + // TODO + // transport.send({ + // type: 'custom', + // event: 'vite:module-loaded', + // // clone array as the runtime reuses the array instance + // data: { modules: message.modules.slice() }, + // }) + break + } + default: + throw new Error(\`Unknown message type: \${JSON.stringify(message)}\`) + } + }, + } + + globalThis.__rolldown_runtime__ ??= new ViteDevRuntime( + wrappedSocket, + ) + ` + } + + override async close(): Promise { + await super.close() + if (this._runner) { + await this._runner.close() + } + } +} diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 5c9aac410eddfe..114c3297643b27 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,15 +1,19 @@ import { pathToFileURL } from 'node:url' +import { resolve } from 'node:path' import type { FetchResult } from 'vite/module-runner' -import type { EnvironmentModuleNode, TransformResult } from '..' +import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import { unwrapId } from '../../shared/utils' +import { slash, unwrapId } from '../../shared/utils' import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, } from '../../shared/constants' import { genSourceMapUrl } from '../server/sourcemap' import type { DevEnvironment } from '../server/environment' +import { FullBundleDevEnvironment } from '../server/environments/fullBundleEnvironment' +import type { ViteFetchResult } from '../../shared/invokeMethods' +import { ssrTransform } from './ssrTransform' export interface FetchModuleOptions { cached?: boolean @@ -80,6 +84,43 @@ export async function fetchModule( url = unwrapId(url) + if (environment instanceof FullBundleDevEnvironment) { + const memoryFile = environment.memoryFiles.get(url) + // TODO: how do you check caching? + const code = memoryFile?.source + if (code == null) { + throw new Error( + `[vite] the module '${url}'${ + importer ? ` imported from '${importer}'` : '' + } was not bundled. Is server established?`, + ) + } + + const file = slash( + importer ? resolve(importer, url) : resolve(environment.config.root, url), + ) + // TODO: map + const result: ViteFetchResult & { map?: undefined } = { + code: code.toString(), + url, + id: file, + file, + // TODO + invalidate: false, + } + const ssrResult = await ssrTransform(result.code, null, url, result.code) + if (!ssrResult) { + throw new Error(`[vite] cannot apply ssr transform to '${url}'.`) + } + result.code = ssrResult.code + + // remove shebang + if (result.code[0] === '#') + result.code = result.code.replace(/^#!.*/, (s) => ' '.repeat(s.length)) + + return result + } + const mod = await environment.moduleGraph.ensureEntryFromUrl(url) const cached = !!mod.transformResult @@ -99,7 +140,7 @@ export async function fetchModule( } if (options.inlineSourceMap !== false) { - result = inlineSourceMap(mod, result, options.startOffset) + result = inlineSourceMap(mod.id!, result, options.startOffset) } // remove shebang @@ -121,7 +162,7 @@ const OTHER_SOURCE_MAP_REGEXP = new RegExp( ) function inlineSourceMap( - mod: EnvironmentModuleNode, + id: string, result: TransformResult, startOffset: number | undefined, ) { @@ -146,7 +187,7 @@ function inlineSourceMap( }) : map result.code = `${code.trimEnd()}\n//# sourceURL=${ - mod.id + id }\n${MODULE_RUNNER_SOURCEMAPPING_SOURCE}\n//# ${SOURCEMAPPING_URL}=${genSourceMapUrl(sourceMap)}\n` return result diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 5114dab3b593bc..ac380ea09c8ae4 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' import { isWindows } from '../../../../shared/utils' import type { ExternalFetchResult } from '../../../../shared/invokeMethods' +import type { RunnableDevEnvironment } from '../../../server/environments/runnableEnvironment' import { createModuleRunnerTester } from './utils' const _URL = URL @@ -13,10 +14,17 @@ describe('module runner initialization', async () => { resolve: { external: ['tinyglobby'], }, + experimental: { + ssrBundledDev: true, + }, + build: { + ssr: './fixtures/simple.js', + }, }) - it('correctly runs ssr code', async ({ runner }) => { - const mod = await runner.import('/fixtures/simple.js') + it.only('correctly runs ssr code', async ({ server }) => { + const runner = (server.environments.ssr as RunnableDevEnvironment).runner + const mod = await runner.import('./fixtures/simple.js') expect(mod.test).toEqual('I am initialized') // loads the same module if id is a file url From 570c5c87046fcda1c53c34f6d3a73451d0f6b385 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 12 Feb 2026 13:12:16 +0100 Subject: [PATCH 02/21] fix: resolve import's url correctly --- .../environments/fullBundleEnvironment.ts | 3 +++ .../fullBundleRunnableEnvironment.ts | 12 +++++++--- packages/vite/src/node/ssr/fetchModule.ts | 24 +++++++++++++------ .../runtime/__tests__/server-runtime.spec.ts | 19 +++++++++++---- .../src/node/ssr/runtime/__tests__/utils.ts | 24 ++++++++++--------- 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index 0e15cb4d26ec82..da7d51b37759d5 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -71,6 +71,9 @@ export class FullBundleDevEnvironment extends DevEnvironment { this.logger.info(colors.green(`page reload`), { timestamp: true }) }) + // TODO: is needed? + public isFullBundle = true + memoryFiles: MemoryFiles = new MemoryFiles() facadeToChunk: Map = new Map() diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 181ed64a2af982..3d8d78560d9bd9 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,4 +1,4 @@ -import { resolve } from 'node:path' +import path, { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { ModuleRunner } from 'vite/module-runner' import { @@ -15,7 +15,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { constructor(name: string, config: ResolvedConfig) { // Since this is not yet exposed, we create hot channel here super(name, config, { - hot: true, + hot: false, // TODO transport: createServerHotChannel(), }) } @@ -52,7 +52,13 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { // NOTE: we don't try to find it if extension is not passed // It will throw an error instead slash(resolve(this.config.root, url)) - return this.facadeToChunk.get(moduleId) + if (this.facadeToChunk.get(moduleId)) { + return this.facadeToChunk.get(moduleId) + } + if (url[0] === '/') { + const tryAbsouteUrl = path.join(this.config.root, url) + return this.facadeToChunk.get(tryAbsouteUrl) + } } protected override async getDevRuntimeImplementation(): Promise { diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 114c3297643b27..5d72088f0284e4 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,10 +1,10 @@ import { pathToFileURL } from 'node:url' -import { resolve } from 'node:path' +import path from 'node:path' import type { FetchResult } from 'vite/module-runner' import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import { slash, unwrapId } from '../../shared/utils' +import { unwrapId } from '../../shared/utils' import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, @@ -85,8 +85,18 @@ export async function fetchModule( url = unwrapId(url) if (environment instanceof FullBundleDevEnvironment) { - const memoryFile = environment.memoryFiles.get(url) - // TODO: how do you check caching? + let fileName: string = url + // Browser does this automatically when serving files, + // But for SSR we have to resolve paths ourselves. + if (url[0] === '.') { + const importerDirectory = importer + ? path.posix.dirname(importer) + : environment.config.root + const moduleId = path.posix.resolve(importerDirectory, url) + fileName = moduleId.slice(environment.config.root.length + 1) + } + const memoryFile = environment.memoryFiles.get(fileName) + // TODO: how to check caching? const code = memoryFile?.source if (code == null) { throw new Error( @@ -96,9 +106,9 @@ export async function fetchModule( ) } - const file = slash( - importer ? resolve(importer, url) : resolve(environment.config.root, url), - ) + const file = importer + ? path.posix.resolve(importer, url) + : path.posix.resolve(environment.config.root, url) // TODO: map const result: ViteFetchResult & { map?: undefined } = { code: code.toString(), diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index ac380ea09c8ae4..2ab3eb70cbb87d 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -18,11 +18,19 @@ describe('module runner initialization', async () => { ssrBundledDev: true, }, build: { - ssr: './fixtures/simple.js', + rolldownOptions: { + input: [ + './fixtures/dynamic-import.js', + './fixtures/simple.js', + './fixtures/test.css', + './fixtures/test.module.css', + './fixtures/assets.js', + ], + }, }, }) - it.only('correctly runs ssr code', async ({ server }) => { + it('correctly runs ssr code', async ({ server }) => { const runner = (server.environments.ssr as RunnableDevEnvironment).runner const mod = await runner.import('./fixtures/simple.js') expect(mod.test).toEqual('I am initialized') @@ -38,7 +46,7 @@ describe('module runner initialization', async () => { expect(mod).toBe(mod3) }) - it('can load virtual modules as an entry point', async ({ runner }) => { + it.skip('can load virtual modules as an entry point', async ({ runner }) => { const mod = await runner.import('virtual:test') expect(mod.msg).toBe('virtual') @@ -200,8 +208,9 @@ describe('module runner initialization', async () => { }) }) - it("dynamic import doesn't produce duplicates", async ({ runner }) => { - const mod = await runner.import('/fixtures/dynamic-import.js') + it("dynamic import doesn't produce duplicates", async ({ server }) => { + const runner = (server.environments.ssr as RunnableDevEnvironment).runner + const mod = await runner.import('./fixtures/dynamic-import.js') const modules = await mod.initialize() // toBe checks that objects are actually the same, not just structurally // using toEqual here would be a mistake because it check the structural difference diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 8ebe6708224d9c..5fc0f3e2a462ab 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -4,12 +4,13 @@ import { fileURLToPath } from 'node:url' import type { TestAPI } from 'vitest' import { afterEach, beforeEach, onTestFinished, test } from 'vitest' import type { ModuleRunner } from 'vite/module-runner' -import type { ServerModuleRunnerOptions } from '../serverModuleRunner' +// import type { ServerModuleRunnerOptions } from '../serverModuleRunner' import type { ViteDevServer } from '../../../server' import type { InlineConfig } from '../../../config' import { createServer } from '../../../server' -import { createServerModuleRunner } from '../serverModuleRunner' +// import { createServerModuleRunner } from '../serverModuleRunner' import type { DevEnvironment } from '../../../server/environment' +import type { RunnableDevEnvironment } from '../../..' interface TestClient { server: ViteDevServer @@ -19,7 +20,7 @@ interface TestClient { export async function createModuleRunnerTester( config: InlineConfig = {}, - runnerConfig: ServerModuleRunnerOptions = {}, + // runnerConfig: ServerModuleRunnerOptions = {}, ): Promise> { function waitForWatcher(server: ViteDevServer) { return new Promise((resolve) => { @@ -82,14 +83,15 @@ export async function createModuleRunnerTester( ...config, }) t.environment = t.server.environments.ssr - t.runner = createServerModuleRunner(t.environment, { - hmr: { - logger: false, - }, - // don't override by default so Vitest source maps are correct - sourcemapInterceptor: false, - ...runnerConfig, - }) + t.runner = (t.environment as RunnableDevEnvironment).runner + // createServerModuleRunner(t.environment, { + // hmr: { + // logger: false, + // }, + // // don't override by default so Vitest source maps are correct + // sourcemapInterceptor: false, + // ...runnerConfig, + // }) if (config.server?.watch) { await waitForWatcher(t.server) } From 7f78249256e227961f834bf4e0a0567e474591a3 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 12 Feb 2026 18:34:15 +0100 Subject: [PATCH 03/21] fix: scope rolldown runtime to a module runner --- packages/vite/src/module-runner/constants.ts | 2 + .../vite/src/module-runner/esmEvaluator.ts | 3 + packages/vite/src/module-runner/index.ts | 1 + packages/vite/src/module-runner/runner.ts | 27 ++ packages/vite/src/module-runner/types.ts | 6 + .../fullBundleRunnableEnvironment.ts | 13 +- packages/vite/src/node/ssr/fetchModule.ts | 8 +- .../runtime/__tests__/server-runtime.spec.ts | 334 ++++++++++-------- .../src/node/ssr/runtime/__tests__/utils.ts | 72 ++-- 9 files changed, 263 insertions(+), 203 deletions(-) diff --git a/packages/vite/src/module-runner/constants.ts b/packages/vite/src/module-runner/constants.ts index b850d69ac4b680..236ce6ec2be87a 100644 --- a/packages/vite/src/module-runner/constants.ts +++ b/packages/vite/src/module-runner/constants.ts @@ -5,3 +5,5 @@ export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__` export const ssrExportAllKey = `__vite_ssr_exportAll__` export const ssrExportNameKey = `__vite_ssr_exportName__` export const ssrImportMetaKey = `__vite_ssr_import_meta__` +export const ssrRolldownRuntimeKey = `__rolldown_runtime__` +export const ssrRolldownRuntimeDefineMethod = `__vite_ssr_define__` diff --git a/packages/vite/src/module-runner/esmEvaluator.ts b/packages/vite/src/module-runner/esmEvaluator.ts index b08e7bcf474c18..cf158352ceb2d0 100644 --- a/packages/vite/src/module-runner/esmEvaluator.ts +++ b/packages/vite/src/module-runner/esmEvaluator.ts @@ -9,6 +9,7 @@ import { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, } from './constants' import type { ModuleEvaluator, ModuleRunnerContext } from './types' @@ -28,6 +29,7 @@ export class ESModulesEvaluator implements ModuleEvaluator { ssrDynamicImportKey, ssrExportAllKey, ssrExportNameKey, + ssrRolldownRuntimeKey, // source map should already be inlined by Vite '"use strict";' + code, ) @@ -39,6 +41,7 @@ export class ESModulesEvaluator implements ModuleEvaluator { context[ssrDynamicImportKey], context[ssrExportAllKey], context[ssrExportNameKey], + context[ssrRolldownRuntimeKey], ) Object.seal(context[ssrModuleExportsKey]) diff --git a/packages/vite/src/module-runner/index.ts b/packages/vite/src/module-runner/index.ts index e23db77dde3a86..537e28bce9815a 100644 --- a/packages/vite/src/module-runner/index.ts +++ b/packages/vite/src/module-runner/index.ts @@ -37,5 +37,6 @@ export { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, } from './constants' export type { InterceptorOptions } from './sourcemap/interceptor' diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 8a65a378bc7840..aafffad1974bbd 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -1,3 +1,4 @@ +import type { DevRuntime } from 'rolldown/experimental/runtime-types' import type { ViteHotContext } from '#types/hot' import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' import { cleanUrl, isPrimitive } from '../shared/utils' @@ -24,6 +25,8 @@ import { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeKey, } from './constants' import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandlerForRunner } from './hmrHandler' @@ -47,6 +50,29 @@ export class ModuleRunner { >() private isBuiltin?: (id: string) => boolean private builtinsPromise?: Promise + private rolldownDevRuntime?: DevRuntime + + // We need the proxy because the runtime MUST be ready before the first import is processed. + // Because `context['__rolldown_runtime__']` is passed down even before the modules are executed. + private rolldownDevRuntimeProxy = new Proxy( + {}, + { + get: (_, p, receiver) => { + // Special `__rolldown_runtime__.__vite_ssr_define__` method only for the module runner + if (p === ssrRolldownRuntimeDefineMethod) { + return (runtime: DevRuntime) => { + this.rolldownDevRuntime = runtime + } + } + + if (!this.rolldownDevRuntime) { + throw new Error(`__rolldown_runtime__ was not initialized.`) + } + + return Reflect.get(this.rolldownDevRuntime, p, receiver) + }, + }, + ) as DevRuntime private closed = false @@ -418,6 +444,7 @@ export class ModuleRunner { get: getter, }), [ssrImportMetaKey]: meta, + [ssrRolldownRuntimeKey]: this.rolldownDevRuntimeProxy, } this.debug?.('[module runner] executing', href) diff --git a/packages/vite/src/module-runner/types.ts b/packages/vite/src/module-runner/types.ts index 8ccf4b5edb0a4c..4398265f61d104 100644 --- a/packages/vite/src/module-runner/types.ts +++ b/packages/vite/src/module-runner/types.ts @@ -1,3 +1,4 @@ +import type { DevRuntime } from 'rolldown/experimental/runtime-types' import type { ViteHotContext } from '#types/hot' import type { HMRLogger } from '../shared/hmr' import type { @@ -19,6 +20,7 @@ import type { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeKey, } from './constants' import type { InterceptorOptions } from './sourcemap/interceptor' @@ -41,6 +43,10 @@ export interface ModuleRunnerContext { [ssrExportAllKey]: (obj: any) => void [ssrExportNameKey]: (name: string, getter: () => unknown) => void [ssrImportMetaKey]: ModuleRunnerImportMeta + /** + * @internal + */ + [ssrRolldownRuntimeKey]: DevRuntime | undefined } export interface ModuleEvaluator { diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 3d8d78560d9bd9..55c55ea2df5673 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,12 +1,13 @@ import path, { resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { ModuleRunner } from 'vite/module-runner' +import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' import { type ResolvedConfig, createServerHotChannel, createServerModuleRunner, } from '../../index' import { slash } from '../../../shared/utils' +import { ssrRolldownRuntimeDefineMethod } from '../../../module-runner/constants' import { FullBundleDevEnvironment } from './fullBundleEnvironment' export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { @@ -15,7 +16,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { constructor(name: string, config: ResolvedConfig) { // Since this is not yet exposed, we create hot channel here super(name, config, { - hot: false, // TODO + hot: true, transport: createServerHotChannel(), }) } @@ -26,7 +27,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { } this._runner = createServerModuleRunner(this) // TODO: don't patch - const importModule = this.runner.import.bind(this.runner) + const importModule = this._runner.import.bind(this._runner) this._runner.import = async (url: string) => { await this.waitForInitialBuildFinish() const fileName = this.resolveEntryFilename(url) @@ -62,7 +63,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { } protected override async getDevRuntimeImplementation(): Promise { - // TODO: this shoult not be in this file + // TODO: this should not be in this file return ` class ViteDevRuntime extends DevRuntime { override createModuleHotContext(moduleId) { @@ -96,9 +97,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { }, } - globalThis.__rolldown_runtime__ ??= new ViteDevRuntime( - wrappedSocket, - ) + ;${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeDefineMethod}(new ViteDevRuntime(wrappedSocket)) ` } diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 5d72088f0284e4..3457bf14352ac3 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -86,12 +86,12 @@ export async function fetchModule( if (environment instanceof FullBundleDevEnvironment) { let fileName: string = url + const importerDirectory = importer + ? path.posix.dirname(importer) + : environment.config.root // Browser does this automatically when serving files, // But for SSR we have to resolve paths ourselves. if (url[0] === '.') { - const importerDirectory = importer - ? path.posix.dirname(importer) - : environment.config.root const moduleId = path.posix.resolve(importerDirectory, url) fileName = moduleId.slice(environment.config.root.length + 1) } @@ -107,7 +107,7 @@ export async function fetchModule( } const file = importer - ? path.posix.resolve(importer, url) + ? path.posix.resolve(importerDirectory, url) : path.posix.resolve(environment.config.root, url) // TODO: map const result: ViteFetchResult & { map?: undefined } = { diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 2ab3eb70cbb87d..5dba863f6a8d65 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -3,29 +3,58 @@ import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' import { isWindows } from '../../../../shared/utils' -import type { ExternalFetchResult } from '../../../../shared/invokeMethods' +// import type { ExternalFetchResult } from '../../../../shared/invokeMethods' import type { RunnableDevEnvironment } from '../../../server/environments/runnableEnvironment' -import { createModuleRunnerTester } from './utils' +import { runnerTest } from './utils' const _URL = URL -describe('module runner initialization', async () => { - const it = await createModuleRunnerTester({ - resolve: { - external: ['tinyglobby'], - }, - experimental: { - ssrBundledDev: true, - }, - build: { - rolldownOptions: { - input: [ - './fixtures/dynamic-import.js', - './fixtures/simple.js', - './fixtures/test.css', - './fixtures/test.module.css', - './fixtures/assets.js', - ], +const it = runnerTest + +describe.only('module runner initialization', async () => { + it.scoped({ + config: { + configFile: false, + resolve: { + external: ['tinyglobby'], + }, + experimental: { + ssrBundledDev: true, + }, + build: { + rolldownOptions: { + input: [ + './fixtures/dynamic-import.js', + './fixtures/simple.js', + './fixtures/test.css', + './fixtures/test.module.css', + './fixtures/assets.js', + './fixtures/top-level-object.js', + './fixtures/cyclic2/test9/index.js', + './fixtures/live-binding/test4/index.js', + './fixtures/live-binding/test3/index.js', + './fixtures/live-binding/test2/index.js', + './fixtures/live-binding/test1/index.js', + './fixtures/execution-order-re-export/index.js', + './fixtures/cyclic2/test7/Ion.js', + './fixtures/cyclic2/test6/index.js', + './fixtures/cyclic2/test5/index.js', + './fixtures/cyclic2/test4/index.js', + './fixtures/cyclic2/test3/index.js', + './fixtures/cyclic2/test2/index.js', + './fixtures/cyclic2/test1/index.js', + './fixtures/no-this/importer.js', + './fixtures/native.js', + './fixtures/installed.js', + './fixtures/virtual.js', + // './fixtures/esm-external-non-existing.js', + // './fixtures/cjs-external-non-existing.js', + // TODO? + // './fixtures/cyclic/entry', + // './fixtures/basic', + // './fixtures/simple.js?raw' + ], + }, }, }, }) @@ -176,7 +205,10 @@ describe('module runner initialization', async () => { } }) - it('importing external cjs library checks exports', async ({ runner }) => { + // if bundle throws an error, we should stopn waiting + it.skip('importing external cjs library checks exports', async ({ + runner, + }) => { await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) .rejects.toThrowErrorMatchingInlineSnapshot(` [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. @@ -423,134 +455,134 @@ describe('module runner initialization', async () => { }) }) -describe('optimize-deps', async () => { - const it = await createModuleRunnerTester({ - cacheDir: 'node_modules/.vite-test', - ssr: { - noExternal: true, - optimizeDeps: { - include: ['@vitejs/cjs-external'], - }, - }, - }) - - it('optimized dep as entry', async ({ runner }) => { - const mod = await runner.import('@vitejs/cjs-external') - expect(mod.default.hello()).toMatchInlineSnapshot(`"world"`) - }) -}) - -describe('resolveId absolute path entry', async () => { - const it = await createModuleRunnerTester({ - plugins: [ - { - name: 'test-resolevId', - enforce: 'pre', - resolveId(source) { - if ( - source === - posix.join(this.environment.config.root, 'fixtures/basic.js') - ) { - return '\0virtual:basic' - } - }, - load(id) { - if (id === '\0virtual:basic') { - return `export const name = "virtual:basic"` - } - }, - }, - ], - }) - - it('ssrLoadModule', async ({ server }) => { - const mod = await server.ssrLoadModule( - posix.join(server.config.root, 'fixtures/basic.js'), - ) - expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) - }) - - it('runner', async ({ server, runner }) => { - const mod = await runner.import( - posix.join(server.config.root, 'fixtures/basic.js'), - ) - expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) - }) -}) - -describe('virtual module hmr', async () => { - let state = 'init' - - const it = await createModuleRunnerTester({ - plugins: [ - { - name: 'test-resolevId', - enforce: 'pre', - resolveId(source) { - if (source === 'virtual:test') { - return '\0' + source - } - }, - load(id) { - if (id === '\0virtual:test') { - return `export default ${JSON.stringify(state)}` - } - }, - }, - ], - }) - - it('full reload', async ({ server, runner }) => { - const mod = await runner.import('virtual:test') - expect(mod.default).toBe('init') - state = 'reloaded' - server.environments.ssr.moduleGraph.invalidateAll() - server.environments.ssr.hot.send({ type: 'full-reload' }) - await vi.waitFor(() => { - const mod = runner.evaluatedModules.getModuleById('\0virtual:test') - expect(mod?.exports.default).toBe('reloaded') - }) - }) - - it("the external module's ID and file are resolved correctly", async ({ - server, - runner, - }) => { - await runner.import( - posix.join(server.config.root, 'fixtures/import-external.ts'), - ) - const moduleNode = runner.evaluatedModules.getModuleByUrl('tinyglobby')! - const meta = moduleNode.meta as ExternalFetchResult - if (process.platform === 'win32') { - expect(meta.externalize).toMatch(/^file:\/\/\/\w:\//) // file:///C:/ - expect(moduleNode.id).toMatch(/^\w:\//) // C:/ - expect(moduleNode.file).toMatch(/^\w:\//) // C:/ - } else { - expect(meta.externalize).toMatch(/^file:\/\/\//) // file:/// - expect(moduleNode.id).toMatch(/^\//) // / - expect(moduleNode.file).toMatch(/^\//) // / - } - }) -}) - -describe('invalid package', async () => { - const it = await createModuleRunnerTester({ - environments: { - ssr: { - resolve: { - noExternal: true, - }, - }, - }, - }) - - it('can catch resolve error on runtime', async ({ runner }) => { - const mod = await runner.import('./fixtures/invalid-package/test.js') - expect(await mod.test()).toMatchInlineSnapshot(` - { - "data": [Error: Failed to resolve entry for package "test-dep-invalid-exports". The package may have incorrect main/module/exports specified in its package.json.], - "ok": false, - } - `) - }) -}) +// describe('optimize-deps', async () => { +// const it = await createModuleRunnerTester({ +// cacheDir: 'node_modules/.vite-test', +// ssr: { +// noExternal: true, +// optimizeDeps: { +// include: ['@vitejs/cjs-external'], +// }, +// }, +// }) + +// it('optimized dep as entry', async ({ runner }) => { +// const mod = await runner.import('@vitejs/cjs-external') +// expect(mod.default.hello()).toMatchInlineSnapshot(`"world"`) +// }) +// }) + +// describe('resolveId absolute path entry', async () => { +// const it = await createModuleRunnerTester({ +// plugins: [ +// { +// name: 'test-resolevId', +// enforce: 'pre', +// resolveId(source) { +// if ( +// source === +// posix.join(this.environment.config.root, 'fixtures/basic.js') +// ) { +// return '\0virtual:basic' +// } +// }, +// load(id) { +// if (id === '\0virtual:basic') { +// return `export const name = "virtual:basic"` +// } +// }, +// }, +// ], +// }) + +// it('ssrLoadModule', async ({ server }) => { +// const mod = await server.ssrLoadModule( +// posix.join(server.config.root, 'fixtures/basic.js'), +// ) +// expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) +// }) + +// it('runner', async ({ server, runner }) => { +// const mod = await runner.import( +// posix.join(server.config.root, 'fixtures/basic.js'), +// ) +// expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) +// }) +// }) + +// describe('virtual module hmr', async () => { +// let state = 'init' + +// const it = await createModuleRunnerTester({ +// plugins: [ +// { +// name: 'test-resolevId', +// enforce: 'pre', +// resolveId(source) { +// if (source === 'virtual:test') { +// return '\0' + source +// } +// }, +// load(id) { +// if (id === '\0virtual:test') { +// return `export default ${JSON.stringify(state)}` +// } +// }, +// }, +// ], +// }) + +// it('full reload', async ({ server, runner }) => { +// const mod = await runner.import('virtual:test') +// expect(mod.default).toBe('init') +// state = 'reloaded' +// server.environments.ssr.moduleGraph.invalidateAll() +// server.environments.ssr.hot.send({ type: 'full-reload' }) +// await vi.waitFor(() => { +// const mod = runner.evaluatedModules.getModuleById('\0virtual:test') +// expect(mod?.exports.default).toBe('reloaded') +// }) +// }) + +// it("the external module's ID and file are resolved correctly", async ({ +// server, +// runner, +// }) => { +// await runner.import( +// posix.join(server.config.root, 'fixtures/import-external.ts'), +// ) +// const moduleNode = runner.evaluatedModules.getModuleByUrl('tinyglobby')! +// const meta = moduleNode.meta as ExternalFetchResult +// if (process.platform === 'win32') { +// expect(meta.externalize).toMatch(/^file:\/\/\/\w:\//) // file:///C:/ +// expect(moduleNode.id).toMatch(/^\w:\//) // C:/ +// expect(moduleNode.file).toMatch(/^\w:\//) // C:/ +// } else { +// expect(meta.externalize).toMatch(/^file:\/\/\//) // file:/// +// expect(moduleNode.id).toMatch(/^\//) // / +// expect(moduleNode.file).toMatch(/^\//) // / +// } +// }) +// }) + +// describe('invalid package', async () => { +// const it = await createModuleRunnerTester({ +// environments: { +// ssr: { +// resolve: { +// noExternal: true, +// }, +// }, +// }, +// }) + +// it('can catch resolve error on runtime', async ({ runner }) => { +// const mod = await runner.import('./fixtures/invalid-package/test.js') +// expect(await mod.test()).toMatchInlineSnapshot(` +// { +// "data": [Error: Failed to resolve entry for package "test-dep-invalid-exports". The package may have incorrect main/module/exports specified in its package.json.], +// "ok": false, +// } +// `) +// }) +// }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 5fc0f3e2a462ab..7b4480e086ebf1 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -1,42 +1,31 @@ import fs from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import type { TestAPI } from 'vitest' -import { afterEach, beforeEach, onTestFinished, test } from 'vitest' +import { test as base, onTestFinished } from 'vitest' import type { ModuleRunner } from 'vite/module-runner' // import type { ServerModuleRunnerOptions } from '../serverModuleRunner' import type { ViteDevServer } from '../../../server' import type { InlineConfig } from '../../../config' import { createServer } from '../../../server' // import { createServerModuleRunner } from '../serverModuleRunner' -import type { DevEnvironment } from '../../../server/environment' -import type { RunnableDevEnvironment } from '../../..' +// import type { DevEnvironment } from '../../../server/environment' +// import type { RunnableDevEnvironment } from '../../..' +import type { FullBundleRunnableDevEnvironment } from '../../../server/environments/fullBundleRunnableEnvironment' interface TestClient { + config: InlineConfig server: ViteDevServer runner: ModuleRunner - environment: DevEnvironment + environment: FullBundleRunnableDevEnvironment } -export async function createModuleRunnerTester( - config: InlineConfig = {}, - // runnerConfig: ServerModuleRunnerOptions = {}, -): Promise> { - function waitForWatcher(server: ViteDevServer) { - return new Promise((resolve) => { - if ((server.watcher as any)._readyEmitted) { - resolve() - } else { - server.watcher.once('ready', () => resolve()) - } - }) - } - - beforeEach(async (t) => { - // @ts-ignore - globalThis.__HMR__ = {} - - t.server = await createServer({ +export const runnerTest = base.extend({ + // eslint-disable-next-line no-empty-pattern + config: async ({}, use) => { + await use({}) + }, + server: async ({ config }, use) => { + const server = await createServer({ root: import.meta.dirname, logLevel: 'error', server: { @@ -82,27 +71,28 @@ export async function createModuleRunnerTester( ], ...config, }) - t.environment = t.server.environments.ssr - t.runner = (t.environment as RunnableDevEnvironment).runner - // createServerModuleRunner(t.environment, { - // hmr: { - // logger: false, - // }, - // // don't override by default so Vitest source maps are correct - // sourcemapInterceptor: false, - // ...runnerConfig, - // }) if (config.server?.watch) { - await waitForWatcher(t.server) + await waitForWatcher(server) } - }) + await use(server) + await server.close() + }, + environment: async ({ server }, use) => { + await use(server.environments.ssr as FullBundleRunnableDevEnvironment) + }, + runner: async ({ environment }, use) => { + await use(environment.runner) + }, +}) - afterEach(async (t) => { - await t.runner.close() - await t.server.close() +function waitForWatcher(server: ViteDevServer) { + return new Promise((resolve) => { + if ((server.watcher as any)._readyEmitted) { + resolve() + } else { + server.watcher.once('ready', () => resolve()) + } }) - - return test as TestAPI } type FixtureEditor = { From a15b57a995e7229519e677475593f8c7421ca076 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 13 Feb 2026 12:08:32 +0100 Subject: [PATCH 04/21] fix: move entry resolution to `fetchModule` --- .../environments/fullBundleEnvironment.ts | 7 +- .../fullBundleRunnableEnvironment.ts | 36 ---------- packages/vite/src/node/ssr/fetchModule.ts | 65 ++++++++++++++----- 3 files changed, 52 insertions(+), 56 deletions(-) diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index da7d51b37759d5..0c90f7dd7a9269 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -191,13 +191,16 @@ export class FullBundleDevEnvironment extends DevEnvironment { debug?.('INITIAL: run error', e) }, ) - this.waitForInitialBuildFinish().then(() => { + this._waitForInitialBuildFinish().then(() => { debug?.('INITIAL: build done') this.hot.send({ type: 'full-reload', path: '*' }) }) } - protected async waitForInitialBuildFinish(): Promise { + /** + * @internal + */ + public async _waitForInitialBuildFinish(): Promise { await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { await setTimeout(10) diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 55c55ea2df5673..34b1bcde3ea123 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,12 +1,9 @@ -import path, { resolve } from 'node:path' -import { fileURLToPath } from 'node:url' import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' import { type ResolvedConfig, createServerHotChannel, createServerModuleRunner, } from '../../index' -import { slash } from '../../../shared/utils' import { ssrRolldownRuntimeDefineMethod } from '../../../module-runner/constants' import { FullBundleDevEnvironment } from './fullBundleEnvironment' @@ -26,42 +23,9 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { return this._runner } this._runner = createServerModuleRunner(this) - // TODO: don't patch - const importModule = this._runner.import.bind(this._runner) - this._runner.import = async (url: string) => { - await this.waitForInitialBuildFinish() - const fileName = this.resolveEntryFilename(url) - if (!fileName) { - throw new Error( - `[vite] Entrypoint '${url}' was not defined in the config. Available entry points: \n- ${[...this.facadeToChunk.keys()].join('\n- ')}`, - ) - } - return importModule(fileName) - } return this._runner } - private resolveEntryFilename(url: string) { - // Already resolved by the user to be a url - if (this.facadeToChunk.has(url)) { - return this.facadeToChunk.get(url) - } - const moduleId = url.startsWith('file://') - ? // new URL(path) - fileURLToPath(url) - : // ./index.js - // NOTE: we don't try to find it if extension is not passed - // It will throw an error instead - slash(resolve(this.config.root, url)) - if (this.facadeToChunk.get(moduleId)) { - return this.facadeToChunk.get(moduleId) - } - if (url[0] === '/') { - const tryAbsouteUrl = path.join(this.config.root, url) - return this.facadeToChunk.get(tryAbsouteUrl) - } - } - protected override async getDevRuntimeImplementation(): Promise { // TODO: this should not be in this file return ` diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 3457bf14352ac3..e293d2421cb1d6 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,10 +1,10 @@ -import { pathToFileURL } from 'node:url' -import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import path, { resolve } from 'node:path' import type { FetchResult } from 'vite/module-runner' import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import { unwrapId } from '../../shared/utils' +import { slash, unwrapId } from '../../shared/utils' import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, @@ -85,36 +85,41 @@ export async function fetchModule( url = unwrapId(url) if (environment instanceof FullBundleDevEnvironment) { - let fileName: string = url - const importerDirectory = importer - ? path.posix.dirname(importer) - : environment.config.root - // Browser does this automatically when serving files, - // But for SSR we have to resolve paths ourselves. - if (url[0] === '.') { - const moduleId = path.posix.resolve(importerDirectory, url) - fileName = moduleId.slice(environment.config.root.length + 1) + await environment._waitForInitialBuildFinish() + + let fileName: string + + if (!importer) { + fileName = resolveEntryFilename(environment, url)! + + if (!fileName) { + throw new Error( + `[vite] Entrypoint '${url}' was not defined in the config. Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}`, + ) + } + } else if (url[0] === '.') { + fileName = path.posix.join(path.posix.dirname(importer), url) + } else { + fileName = url } + const memoryFile = environment.memoryFiles.get(fileName) // TODO: how to check caching? const code = memoryFile?.source if (code == null) { throw new Error( - `[vite] the module '${url}'${ + `[vite] the module '${url}' (chunk '${fileName}') ${ importer ? ` imported from '${importer}'` : '' } was not bundled. Is server established?`, ) } - const file = importer - ? path.posix.resolve(importerDirectory, url) - : path.posix.resolve(environment.config.root, url) // TODO: map const result: ViteFetchResult & { map?: undefined } = { code: code.toString(), url, - id: file, - file, + id: fileName, + file: null, // TODO invalidate: false, } @@ -202,3 +207,27 @@ function inlineSourceMap( return result } + +function resolveEntryFilename( + environment: FullBundleDevEnvironment, + url: string, +) { + // Already resolved by the user to be a url + if (environment.facadeToChunk.has(url)) { + return environment.facadeToChunk.get(url) + } + const moduleId = url.startsWith('file://') + ? // new URL(path) + fileURLToPath(url) + : // ./index.js + // NOTE: we don't try to find it if extension is not passed + // It will throw an error instead + slash(resolve(environment.config.root, url)) + if (environment.facadeToChunk.get(moduleId)) { + return environment.facadeToChunk.get(moduleId) + } + if (url[0] === '/') { + const tryAbsouteUrl = path.join(environment.config.root, url) + return environment.facadeToChunk.get(tryAbsouteUrl) + } +} From ebfbb4e4c1911cf6aaec0e37a536facd48f61212 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 13 Feb 2026 14:58:14 +0100 Subject: [PATCH 05/21] fix: resolve url as a virtual module, support dynamic (statically analysed) imports --- packages/vite/src/module-runner/constants.ts | 2 +- .../src/module-runner/createImportMeta.ts | 11 +- packages/vite/src/module-runner/runner.ts | 20 +-- packages/vite/src/module-runner/utils.ts | 1 + .../environments/fullBundleEnvironment.ts | 29 ++-- .../fullBundleRunnableEnvironment.ts | 12 +- packages/vite/src/node/ssr/fetchModule.ts | 22 ++- .../runtime/__tests__/server-runtime.spec.ts | 127 +++++++++++++----- vitest.config.ts | 1 + 9 files changed, 163 insertions(+), 62 deletions(-) diff --git a/packages/vite/src/module-runner/constants.ts b/packages/vite/src/module-runner/constants.ts index 236ce6ec2be87a..de263afce58df7 100644 --- a/packages/vite/src/module-runner/constants.ts +++ b/packages/vite/src/module-runner/constants.ts @@ -6,4 +6,4 @@ export const ssrExportAllKey = `__vite_ssr_exportAll__` export const ssrExportNameKey = `__vite_ssr_exportName__` export const ssrImportMetaKey = `__vite_ssr_import_meta__` export const ssrRolldownRuntimeKey = `__rolldown_runtime__` -export const ssrRolldownRuntimeDefineMethod = `__vite_ssr_define__` +export const ssrRolldownRuntimeDefineMethod = `__vite_ssr_defineRuntime__` diff --git a/packages/vite/src/module-runner/createImportMeta.ts b/packages/vite/src/module-runner/createImportMeta.ts index f007dadde8e343..9cd746620b4f02 100644 --- a/packages/vite/src/module-runner/createImportMeta.ts +++ b/packages/vite/src/module-runner/createImportMeta.ts @@ -14,12 +14,13 @@ const envProxy = new Proxy({} as any, { export function createDefaultImportMeta( modulePath: string, ): ModuleRunnerImportMeta { - const href = posixPathToFileHref(modulePath) - const filename = modulePath - const dirname = posixDirname(modulePath) + const isVirtual = modulePath.startsWith('data:application/javascript,') + const href = isVirtual ? modulePath : posixPathToFileHref(modulePath) + const filename = isVirtual ? undefined : modulePath + const dirname = isVirtual ? undefined : posixDirname(modulePath) return { - filename: isWindows ? toWindowsPath(filename) : filename, - dirname: isWindows ? toWindowsPath(dirname) : dirname, + filename: isWindows && filename ? toWindowsPath(filename) : filename, + dirname: isWindows && dirname ? toWindowsPath(dirname) : dirname, url: href, env: envProxy, resolve(_id: string, _parent?: string) { diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index aafffad1974bbd..c34adf13fa87bb 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -17,7 +17,7 @@ import type { ResolvedResult, SSRImportMetadata, } from './types' -import { posixDirname, posixPathToFileHref, posixResolve } from './utils' +import { posixDirname, posixJoin } from './utils' import { ssrDynamicImportKey, ssrExportAllKey, @@ -53,12 +53,15 @@ export class ModuleRunner { private rolldownDevRuntime?: DevRuntime // We need the proxy because the runtime MUST be ready before the first import is processed. - // Because `context['__rolldown_runtime__']` is passed down even before the modules are executed. + // Because `context['__rolldown_runtime__']` is passed down before the modules are executed as a function argument. private rolldownDevRuntimeProxy = new Proxy( {}, { get: (_, p, receiver) => { - // Special `__rolldown_runtime__.__vite_ssr_define__` method only for the module runner + // Special `__rolldown_runtime__.__vite_ssr_defineRuntime__` method only for the module runner, + // It's not available in the browser because it's a global there. We cannot have it as a global because + // - It's possible to have multiple runners (`ssrLoadModule` has its own compat runner); + // - We don't want to pollute Dev Server's global namespace. if (p === ssrRolldownRuntimeDefineMethod) { return (runtime: DevRuntime) => { this.rolldownDevRuntime = runtime @@ -115,7 +118,7 @@ export class ModuleRunner { */ public async import(url: string): Promise { const fetchedModule = await this.cachedModule(url) - return await this.cachedRequest(url, fetchedModule) + return await this.cachedRequest(fetchedModule.url, fetchedModule) } /** @@ -374,7 +377,7 @@ export class ModuleRunner { // it's possible to provide an object with toString() method inside import() dep = String(dep) if (dep[0] === '.') { - dep = posixResolve(posixDirname(url), dep) + dep = posixJoin(posixDirname(url), dep) } return request(dep, { isDynamicImport: true }) } @@ -401,9 +404,10 @@ export class ModuleRunner { const createImportMeta = this.options.createImportMeta ?? createDefaultImportMeta - const modulePath = cleanUrl(file || moduleId) + const modulePath = file + ? cleanUrl(file) + : `data:application/javascript,${code};` // disambiguate the `:/` on windows: see nodejs/node#31710 - const href = posixPathToFileHref(modulePath) const meta = await createImportMeta(modulePath) const exports = Object.create(null) Object.defineProperty(exports, Symbol.toStringTag, { @@ -447,7 +451,7 @@ export class ModuleRunner { [ssrRolldownRuntimeKey]: this.rolldownDevRuntimeProxy, } - this.debug?.('[module runner] executing', href) + this.debug?.('[module runner] executing', meta.href) await this.evaluator.runInlinedModule(context, code, mod) diff --git a/packages/vite/src/module-runner/utils.ts b/packages/vite/src/module-runner/utils.ts index 576991610b4d6b..15fb954ca284fe 100644 --- a/packages/vite/src/module-runner/utils.ts +++ b/packages/vite/src/module-runner/utils.ts @@ -34,6 +34,7 @@ function encodePathChars(filepath: string) { export const posixDirname: (path: string) => string = pathe.dirname export const posixResolve: (...paths: string[]) => string = pathe.resolve +export const posixJoin: (...paths: string[]) => string = pathe.join export function posixPathToFileHref(posixPath: string): string { let resolved = posixResolve(posixPath) diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index 0c90f7dd7a9269..6be2d75e73d8e3 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -7,6 +7,7 @@ import { } from 'rolldown/experimental' import colors from 'picocolors' import getEtag from 'etag' +import type { OutputOptions, RolldownOptions } from 'rolldown' import type { Update } from '#types/hmrPayload' import { ChunkMetadataMap, resolveRolldownOptions } from '../../build' import { getHmrImplementation } from '../../plugins/clientInjections' @@ -77,6 +78,8 @@ export class FullBundleDevEnvironment extends DevEnvironment { memoryFiles: MemoryFiles = new MemoryFiles() facadeToChunk: Map = new Map() + // private buildFinishPromise = promiseWithResolvers() + constructor( name: string, config: ResolvedConfig, @@ -201,6 +204,8 @@ export class FullBundleDevEnvironment extends DevEnvironment { * @internal */ public async _waitForInitialBuildFinish(): Promise { + // TODO: need a better way to handle errors from the outside + // maybe `await buildFinishPromise.promise` await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { await setTimeout(10) @@ -292,7 +297,7 @@ export class FullBundleDevEnvironment extends DevEnvironment { return await getHmrImplementation(this.getTopLevelConfig()) } - private async getRolldownOptions() { + protected async getRolldownOptions(): Promise { const chunkMetadataMap = new ChunkMetadataMap() const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) rolldownOptions.experimental ??= {} @@ -308,24 +313,26 @@ export class FullBundleDevEnvironment extends DevEnvironment { // set filenames to make output paths predictable so that `renderChunk` hook does not need to be used if (Array.isArray(rolldownOptions.output)) { for (const output of rolldownOptions.output) { - output.entryFileNames = 'assets/[name].js' - output.chunkFileNames = 'assets/[name]-[hash].js' - output.assetFileNames = 'assets/[name]-[hash][extname]' - output.minify = false - output.sourcemap = true + Object.assign(output, this.getOutputOptions()) } } else { rolldownOptions.output ??= {} - rolldownOptions.output.entryFileNames = 'assets/[name].js' - rolldownOptions.output.chunkFileNames = 'assets/[name]-[hash].js' - rolldownOptions.output.assetFileNames = 'assets/[name]-[hash][extname]' - rolldownOptions.output.minify = false - rolldownOptions.output.sourcemap = true + Object.assign(rolldownOptions.output, this.getOutputOptions()) } return rolldownOptions } + protected getOutputOptions(): OutputOptions { + return { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + minify: false, + sourcemap: true, + } + } + private handleHmrOutput( client: NormalizedHotChannelClient, files: string[], diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 34b1bcde3ea123..28c8a2cf345bf6 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,3 +1,4 @@ +import type { OutputOptions } from 'rolldown' import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' import { type ResolvedConfig, @@ -30,14 +31,14 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { // TODO: this should not be in this file return ` class ViteDevRuntime extends DevRuntime { - override createModuleHotContext(moduleId) { + createModuleHotContext(moduleId) { const ctx = __vite_ssr_import_meta__.hot // TODO: what is this? // ctx._internal = { updateStyle, removeStyle } return ctx } - override applyUpdates() { + applyUpdates() { // noop, handled in the HMR client } } @@ -65,6 +66,13 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { ` } + protected override getOutputOptions(): OutputOptions { + return { + ...super.getOutputOptions(), + sourcemap: 'inline', + } + } + override async close(): Promise { await super.close() if (this._runner) { diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index e293d2421cb1d6..54b75f5a511264 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -47,7 +47,13 @@ export async function fetchModule( // if there is no importer, the file is an entry point // entry points are always internalized - if (!isFileUrl && importer && url[0] !== '.' && url[0] !== '/') { + if ( + !isFileUrl && + importer && + url[0] !== '.' && + url[0] !== '/' && + !isChunkUrl(environment, url) + ) { const { isProduction, root } = environment.config const { externalConditions, dedupe, preserveSymlinks } = environment.config.resolve @@ -114,15 +120,16 @@ export async function fetchModule( ) } - // TODO: map - const result: ViteFetchResult & { map?: undefined } = { + const result: ViteFetchResult = { code: code.toString(), - url, + url: fileName, id: fileName, file: null, // TODO invalidate: false, } + // TODO: this should be done in rolldown, there is already a function for it + // output.format = 'module-runner' const ssrResult = await ssrTransform(result.code, null, url, result.code) if (!ssrResult) { throw new Error(`[vite] cannot apply ssr transform to '${url}'.`) @@ -208,6 +215,13 @@ function inlineSourceMap( return result } +function isChunkUrl(environment: DevEnvironment, url: string) { + return ( + environment instanceof FullBundleDevEnvironment && + environment.memoryFiles.has(url) + ) +} + function resolveEntryFilename( environment: FullBundleDevEnvironment, url: string, diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 5dba863f6a8d65..ecb1dd20d35c2a 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -47,20 +47,23 @@ describe.only('module runner initialization', async () => { './fixtures/native.js', './fixtures/installed.js', './fixtures/virtual.js', + './fixtures/cyclic/entry.js', + './fixtures/has-error.js', + './fixtures/basic.js', + './fixtures/simple.js?raw', + './fixtures/simple.js?url', + './fixtures/test.css?inline', + // TODO: this fails during bundle, not at runtime + // at the moment it HANGS the whole process // './fixtures/esm-external-non-existing.js', // './fixtures/cjs-external-non-existing.js', - // TODO? - // './fixtures/cyclic/entry', - // './fixtures/basic', - // './fixtures/simple.js?raw' ], }, }, }, }) - it('correctly runs ssr code', async ({ server }) => { - const runner = (server.environments.ssr as RunnableDevEnvironment).runner + it('correctly runs ssr code', async ({ runner }) => { const mod = await runner.import('./fixtures/simple.js') expect(mod.test).toEqual('I am initialized') @@ -75,7 +78,13 @@ describe.only('module runner initialization', async () => { expect(mod).toBe(mod3) }) - it.skip('can load virtual modules as an entry point', async ({ runner }) => { + it('can load virtual modules as an entry point', async ({ + runner, + skip, + config, + }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + const mod = await runner.import('virtual:test') expect(mod.msg).toBe('virtual') @@ -124,17 +133,29 @@ describe.only('module runner initialization', async () => { }) }) - it('assets are loaded correctly', async ({ runner }) => { + it('assets are loaded correctly', async ({ runner, config }) => { const assets = await runner.import('/fixtures/assets.js') - expect(assets).toMatchObject({ - mov: '/fixtures/assets/placeholder.mov', - txt: '/fixtures/assets/placeholder.txt', - png: '/fixtures/assets/placeholder.png', - webp: '/fixtures/assets/placeholder.webp', - }) + if (config.experimental?.ssrBundledDev) { + expect(assets).toMatchObject({ + mov: 'data:video/quicktime;base64,', + txt: 'data:text/plain;base64,', + png: 'data:image/png;base64,', + webp: 'data:image/webp;base64,', + }) + } else { + expect(assets).toMatchObject({ + mov: '/fixtures/assets/placeholder.mov', + txt: '/fixtures/assets/placeholder.txt', + png: '/fixtures/assets/placeholder.png', + webp: '/fixtures/assets/placeholder.webp', + }) + } }) - it('ids with Vite queries are loaded correctly', async ({ runner }) => { + it('ids with Vite queries are loaded correctly', async ({ + runner, + config, + }) => { const raw = await runner.import('/fixtures/simple.js?raw') expect(raw.default).toMatchInlineSnapshot(` "export const test = 'I am initialized' @@ -143,7 +164,11 @@ describe.only('module runner initialization', async () => { " `) const url = await runner.import('/fixtures/simple.js?url') - expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + if (config.experimental?.ssrBundledDev) { + expect(url.default).toMatch('__VITE_ASSET__') + } else { + expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) + } const inline = await runner.import('/fixtures/test.css?inline') expect(inline.default).toMatchInlineSnapshot(` ".test { @@ -155,11 +180,16 @@ describe.only('module runner initialization', async () => { it('modules with query strings are treated as different modules', async ({ runner, + config, }) => { const modSimple = await runner.import('/fixtures/simple.js') const modUrl = await runner.import('/fixtures/simple.js?url') expect(modSimple).not.toBe(modUrl) - expect(modUrl.default).toBe('/fixtures/simple.js') + if (config.experimental?.ssrBundledDev) { + expect(modUrl.default).toContain('__VITE_ASSET__') + } else { + expect(modUrl.default).toBe('/fixtures/simple.js') + } }) it('exports is not modifiable', async ({ runner }) => { @@ -192,7 +222,7 @@ describe.only('module runner initialization', async () => { const s = Symbol() try { await runner.import('/fixtures/has-error.js') - } catch (e) { + } catch (e: any) { expect(e[s]).toBeUndefined() e[s] = true expect(e[s]).toBe(true) @@ -200,15 +230,19 @@ describe.only('module runner initialization', async () => { try { await runner.import('/fixtures/has-error.js') - } catch (e) { + } catch (e: any) { expect(e[s]).toBe(true) } }) // if bundle throws an error, we should stopn waiting - it.skip('importing external cjs library checks exports', async ({ + it('importing external cjs library checks exports', async ({ runner, + skip, + config, }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) .rejects.toThrowErrorMatchingInlineSnapshot(` [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. @@ -240,7 +274,13 @@ describe.only('module runner initialization', async () => { }) }) - it("dynamic import doesn't produce duplicates", async ({ server }) => { + it("dynamic import doesn't produce duplicates", async ({ + server, + config, + skip, + }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + const runner = (server.environments.ssr as RunnableDevEnvironment).runner const mod = await runner.import('./fixtures/dynamic-import.js') const modules = await mod.initialize() @@ -275,8 +315,16 @@ describe.only('module runner initialization', async () => { expect(mod.existsSync).toBe(existsSync) }) - it('correctly resolves module url', async ({ runner, server }) => { - const { meta } = await runner.import('/fixtures/basic') + // files are virtual, so url is not defined + it('correctly resolves module url', async ({ + runner, + server, + config, + skip, + }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + + const { meta } = await runner.import('/fixtures/basic.js') const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() expect(meta.url).toBe(basicUrl) @@ -303,12 +351,17 @@ describe.only('module runner initialization', async () => { it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ runner, + skip, + config, }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + // entry.js ⇔ entry-cyclic.js // ⇓ // action.js - const mod = await runner.import('/fixtures/cyclic/entry') + const mod = await runner.import('./fixtures/cyclic/entry.js') await mod.setupCyclic() + // TODO(FBM): Importing dynamically is not supported yet const action = await mod.importAction('/fixtures/cyclic/action') expect(action).toBeDefined() }) @@ -337,18 +390,30 @@ describe.only('module runner initialization', async () => { }) }) - it(`cyclic invalid 1`, async ({ runner }) => { + it(`cyclic invalid 1`, async ({ runner, config }) => { // Node also fails but with a different message // $ node packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic2/test5/index.js // ReferenceError: Cannot access 'dep1' before initialization - await expect(() => - runner.import('/fixtures/cyclic2/test5/index.js'), - ).rejects.toMatchInlineSnapshot( - `[TypeError: Cannot read properties of undefined (reading 'ok')]`, - ) + if (config.experimental?.ssrBundledDev) { + await expect(() => + runner.import('/fixtures/cyclic2/test5/index.js'), + ).rejects.toMatchInlineSnapshot( + `[ReferenceError: Cannot access 'dep1' before initialization]`, + ) + } else { + await expect(() => + runner.import('/fixtures/cyclic2/test5/index.js'), + ).rejects.toMatchInlineSnapshot( + `[TypeError: Cannot read properties of undefined (reading 'ok')]`, + ) + } }) - it(`cyclic invalid 2`, async ({ runner }) => { + // rolldown doesn't support this + // - Cannot access 'dep1' before initialization + it(`cyclic invalid 2`, async ({ runner, skip, config }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + // It should be an error but currently `undefined` fallback. expect( await runner.import('/fixtures/cyclic2/test6/index.js'), diff --git a/vitest.config.ts b/vitest.config.ts index ad54877148376a..7e69b4e036d3f4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ const _dirname = path.dirname(url.fileURLToPath(import.meta.url)) export default defineConfig({ test: { + includeTaskLocation: true, include: ['**/__tests__/**/*.spec.[tj]s'], exclude: [ '**/node_modules/**', From 62878a7a8ea0c57fbddafb96b74c05b55b0bc425 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Fri, 13 Feb 2026 15:28:20 +0100 Subject: [PATCH 06/21] chore: cleanup --- .../environments/fullBundleEnvironment.ts | 3 -- .../__tests__/fixtures/dynamic-import.js | 9 +++++- .../runtime/__tests__/server-runtime.spec.ts | 31 ++++++++++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index 6be2d75e73d8e3..22cf970b85db6d 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -72,9 +72,6 @@ export class FullBundleDevEnvironment extends DevEnvironment { this.logger.info(colors.green(`page reload`), { timestamp: true }) }) - // TODO: is needed? - public isFullBundle = true - memoryFiles: MemoryFiles = new MemoryFiles() facadeToChunk: Map = new Map() diff --git a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js index 181d161ee7bb74..f0ba94eed3b989 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js +++ b/packages/vite/src/node/ssr/runtime/__tests__/fixtures/dynamic-import.js @@ -2,7 +2,14 @@ import path from 'node:path' import * as staticModule from './simple' import { pathToFileURL } from 'node:url' -export const initialize = async () => { +export const initialize = async (isFullBundle) => { + if (isFullBundle) { + return { + dynamicProcessed: await import('./simple'), + static: staticModule, + } + } + const nameRelative = './simple' const nameAbsolute = '/fixtures/simple' const nameAbsoluteExtension = '/fixtures/simple.js' diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index ecb1dd20d35c2a..3c525f1de74cfc 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' import { isWindows } from '../../../../shared/utils' // import type { ExternalFetchResult } from '../../../../shared/invokeMethods' -import type { RunnableDevEnvironment } from '../../../server/environments/runnableEnvironment' import { runnerTest } from './utils' const _URL = URL @@ -21,6 +20,13 @@ describe.only('module runner initialization', async () => { experimental: { ssrBundledDev: true, }, + environments: { + ssr: { + dev: { + // disable hmr? + }, + }, + }, build: { rolldownOptions: { input: [ @@ -260,7 +266,13 @@ describe.only('module runner initialization', async () => { }) }) - it('importing external esm library checks exports', async ({ runner }) => { + it('importing external esm library checks exports', async ({ + runner, + skip, + config, + }) => { + skip(!!config.experimental?.ssrBundledDev, 'FBM') + await expect(() => runner.import('/fixtures/esm-external-non-existing.js'), ).rejects.toThrowErrorMatchingInlineSnapshot( @@ -275,13 +287,13 @@ describe.only('module runner initialization', async () => { }) it("dynamic import doesn't produce duplicates", async ({ - server, config, skip, + runner, }) => { + // rolldown doesn't return the same reference and doesn't support non-processed dynamic imports skip(!!config.experimental?.ssrBundledDev, 'FBM') - const runner = (server.environments.ssr as RunnableDevEnvironment).runner const mod = await runner.import('./fixtures/dynamic-import.js') const modules = await mod.initialize() // toBe checks that objects are actually the same, not just structurally @@ -294,6 +306,17 @@ describe.only('module runner initialization', async () => { expect(modules.static).toBe(modules.dynamicFileUrl) }) + it('dynamic imports in FBM', async ({ config, skip, runner }) => { + skip(!config.experimental?.ssrBundledDev, 'FBM') + + const mod = await runner.import('./fixtures/dynamic-import.js') + const modules = await mod.initialize(true) + + expect(modules.static.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBe(modules.static.test) + }) + it('correctly imports a virtual module', async ({ runner }) => { const mod = await runner.import('/fixtures/virtual.js') expect(mod.msg0).toBe('virtual0') From 6ec769079d00bb617c0dd922c70849be0194536e Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 18 Feb 2026 18:56:59 +0100 Subject: [PATCH 07/21] chore: add `_waitForInitialBuildSuccess` --- .../server/environments/fullBundleEnvironment.ts | 14 +++++++++++--- packages/vite/src/node/ssr/fetchModule.ts | 13 ++++++++++--- .../ssr/runtime/__tests__/server-runtime.spec.ts | 8 ++------ .../vite/src/node/ssr/runtime/__tests__/utils.ts | 11 ++++++----- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index 22cf970b85db6d..ed2bc184a516f4 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -200,9 +200,17 @@ export class FullBundleDevEnvironment extends DevEnvironment { /** * @internal */ - public async _waitForInitialBuildFinish(): Promise { - // TODO: need a better way to handle errors from the outside - // maybe `await buildFinishPromise.promise` + public async _waitForInitialBuildSuccess(): Promise { + await this.devEngine.ensureCurrentBuildFinish() + const bundleState = await this.devEngine.getBundleState() + if (bundleState.lastFullBuildFailed) { + throw new Error( + `The last full bundle mode build has failed. See logs for more information.`, + ) + } + } + + private async _waitForInitialBuildFinish(): Promise { await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { await setTimeout(10) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 54b75f5a511264..4dc8409ddbae71 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -91,7 +91,7 @@ export async function fetchModule( url = unwrapId(url) if (environment instanceof FullBundleDevEnvironment) { - await environment._waitForInitialBuildFinish() + await environment._waitForInitialBuildSuccess() let fileName: string @@ -99,8 +99,12 @@ export async function fetchModule( fileName = resolveEntryFilename(environment, url)! if (!fileName) { + const entrypoints = [...environment.facadeToChunk.keys()] throw new Error( - `[vite] Entrypoint '${url}' was not defined in the config. Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}`, + `[vite] Entrypoint '${url}' was not defined in the config. ` + + entrypoints.length + ? `Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}` + : `The build did not produce any chunks. Did it finish successfully? See the logs for more information.`, ) } } else if (url[0] === '.') { @@ -122,10 +126,13 @@ export async function fetchModule( const result: ViteFetchResult = { code: code.toString(), + // To make sure dynamic imports resolve assets correctly. + // (Dynamic import resolves relative urls with importer url) url: fileName, id: fileName, + // We don't keep assets on the file system. file: null, - // TODO + // TODO: how to know the file was invalidated? invalidate: false, } // TODO: this should be done in rolldown, there is already a function for it diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 3c525f1de74cfc..4c5aa34a9a976d 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -20,12 +20,8 @@ describe.only('module runner initialization', async () => { experimental: { ssrBundledDev: true, }, - environments: { - ssr: { - dev: { - // disable hmr? - }, - }, + server: { + hmr: false, }, build: { rolldownOptions: { diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 7b4480e086ebf1..0895b1af4895bd 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -28,11 +28,6 @@ export const runnerTest = base.extend({ const server = await createServer({ root: import.meta.dirname, logLevel: 'error', - server: { - middlewareMode: true, - watch: null, - ws: false, - }, ssr: { external: ['@vitejs/cjs-external', '@vitejs/esm-external'], }, @@ -70,6 +65,12 @@ export const runnerTest = base.extend({ ...(config.plugins ?? []), ], ...config, + server: { + middlewareMode: true, + watch: null, + ws: false, + ...config.server, + }, }) if (config.server?.watch) { await waitForWatcher(server) From 08de7c93d0ed022339d3685d9f488dd582d67a6c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 24 Feb 2026 16:05:56 +0100 Subject: [PATCH 08/21] chore: increase the limit --- packages/vite/rolldown.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index a99b66e54a829f..5e26173dc7b722 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -147,7 +147,7 @@ const moduleRunnerConfig = defineConfig({ '@vitejs/devtools/cli-commands', ...Object.keys(pkg.dependencies), ], - plugins: [bundleSizeLimit(54), enableSourceMapsInWatchModePlugin()], + plugins: [bundleSizeLimit(55), enableSourceMapsInWatchModePlugin()], output: { ...sharedNodeOptions.output, minify: { From c7a70dae280e4c2b39c9201ecdc097264bee675a Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 24 Feb 2026 16:27:38 +0100 Subject: [PATCH 09/21] test: fix tests --- .../runtime/__tests__/server-no-hmr.spec.ts | 14 +- .../runtime/__tests__/server-runtime.spec.ts | 285 +++++++++--------- .../__tests__/server-source-maps.spec.ts | 22 +- .../src/node/ssr/runtime/__tests__/utils.ts | 31 +- 4 files changed, 183 insertions(+), 169 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts index d4cf03c756c565..06faca338c932f 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-no-hmr.spec.ts @@ -1,12 +1,14 @@ import { describe, expect } from 'vitest' -import { createModuleRunnerTester } from './utils' +import { runnerTest as it } from './utils' describe('module runner hmr works as expected', async () => { - const it = await createModuleRunnerTester({ - server: { - // override watch options because it's disabled by default - watch: {}, - hmr: false, + it.scoped({ + config: { + server: { + // override watch options because it's disabled by default + watch: {}, + hmr: false, + }, }, }) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 4c5aa34a9a976d..b6f23952c3373c 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -3,26 +3,20 @@ import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' import { isWindows } from '../../../../shared/utils' -// import type { ExternalFetchResult } from '../../../../shared/invokeMethods' -import { runnerTest } from './utils' +import type { ExternalFetchResult } from '../../../../shared/invokeMethods' +import { runnerTest as it } from './utils' const _URL = URL -const it = runnerTest - -describe.only('module runner initialization', async () => { +describe('module runner initialization', async () => { it.scoped({ config: { - configFile: false, resolve: { external: ['tinyglobby'], }, experimental: { ssrBundledDev: true, }, - server: { - hmr: false, - }, build: { rolldownOptions: { input: [ @@ -539,134 +533,145 @@ describe.only('module runner initialization', async () => { }) }) -// describe('optimize-deps', async () => { -// const it = await createModuleRunnerTester({ -// cacheDir: 'node_modules/.vite-test', -// ssr: { -// noExternal: true, -// optimizeDeps: { -// include: ['@vitejs/cjs-external'], -// }, -// }, -// }) - -// it('optimized dep as entry', async ({ runner }) => { -// const mod = await runner.import('@vitejs/cjs-external') -// expect(mod.default.hello()).toMatchInlineSnapshot(`"world"`) -// }) -// }) - -// describe('resolveId absolute path entry', async () => { -// const it = await createModuleRunnerTester({ -// plugins: [ -// { -// name: 'test-resolevId', -// enforce: 'pre', -// resolveId(source) { -// if ( -// source === -// posix.join(this.environment.config.root, 'fixtures/basic.js') -// ) { -// return '\0virtual:basic' -// } -// }, -// load(id) { -// if (id === '\0virtual:basic') { -// return `export const name = "virtual:basic"` -// } -// }, -// }, -// ], -// }) - -// it('ssrLoadModule', async ({ server }) => { -// const mod = await server.ssrLoadModule( -// posix.join(server.config.root, 'fixtures/basic.js'), -// ) -// expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) -// }) - -// it('runner', async ({ server, runner }) => { -// const mod = await runner.import( -// posix.join(server.config.root, 'fixtures/basic.js'), -// ) -// expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) -// }) -// }) - -// describe('virtual module hmr', async () => { -// let state = 'init' - -// const it = await createModuleRunnerTester({ -// plugins: [ -// { -// name: 'test-resolevId', -// enforce: 'pre', -// resolveId(source) { -// if (source === 'virtual:test') { -// return '\0' + source -// } -// }, -// load(id) { -// if (id === '\0virtual:test') { -// return `export default ${JSON.stringify(state)}` -// } -// }, -// }, -// ], -// }) - -// it('full reload', async ({ server, runner }) => { -// const mod = await runner.import('virtual:test') -// expect(mod.default).toBe('init') -// state = 'reloaded' -// server.environments.ssr.moduleGraph.invalidateAll() -// server.environments.ssr.hot.send({ type: 'full-reload' }) -// await vi.waitFor(() => { -// const mod = runner.evaluatedModules.getModuleById('\0virtual:test') -// expect(mod?.exports.default).toBe('reloaded') -// }) -// }) - -// it("the external module's ID and file are resolved correctly", async ({ -// server, -// runner, -// }) => { -// await runner.import( -// posix.join(server.config.root, 'fixtures/import-external.ts'), -// ) -// const moduleNode = runner.evaluatedModules.getModuleByUrl('tinyglobby')! -// const meta = moduleNode.meta as ExternalFetchResult -// if (process.platform === 'win32') { -// expect(meta.externalize).toMatch(/^file:\/\/\/\w:\//) // file:///C:/ -// expect(moduleNode.id).toMatch(/^\w:\//) // C:/ -// expect(moduleNode.file).toMatch(/^\w:\//) // C:/ -// } else { -// expect(meta.externalize).toMatch(/^file:\/\/\//) // file:/// -// expect(moduleNode.id).toMatch(/^\//) // / -// expect(moduleNode.file).toMatch(/^\//) // / -// } -// }) -// }) - -// describe('invalid package', async () => { -// const it = await createModuleRunnerTester({ -// environments: { -// ssr: { -// resolve: { -// noExternal: true, -// }, -// }, -// }, -// }) - -// it('can catch resolve error on runtime', async ({ runner }) => { -// const mod = await runner.import('./fixtures/invalid-package/test.js') -// expect(await mod.test()).toMatchInlineSnapshot(` -// { -// "data": [Error: Failed to resolve entry for package "test-dep-invalid-exports". The package may have incorrect main/module/exports specified in its package.json.], -// "ok": false, -// } -// `) -// }) -// }) +describe('optimize-deps', async () => { + it.scoped({ + config: { + cacheDir: 'node_modules/.vite-test', + ssr: { + noExternal: true, + optimizeDeps: { + include: ['@vitejs/cjs-external'], + }, + }, + }, + }) + + it('optimized dep as entry', async ({ runner }) => { + const mod = await runner.import('@vitejs/cjs-external') + expect(mod.default.hello()).toMatchInlineSnapshot(`"world"`) + }) +}) + +describe('resolveId absolute path entry', async () => { + it.scoped({ + config: { + plugins: [ + { + name: 'test-resolevId', + enforce: 'pre', + resolveId(source) { + if ( + source === + posix.join(this.environment.config.root, 'fixtures/basic.js') + ) { + return '\0virtual:basic' + } + }, + load(id) { + if (id === '\0virtual:basic') { + return `export const name = "virtual:basic"` + } + }, + }, + ], + }, + }) + + it('ssrLoadModule', async ({ server }) => { + const mod = await server.ssrLoadModule( + posix.join(server.config.root, 'fixtures/basic.js'), + ) + expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) + }) + + it('runner', async ({ server, runner }) => { + const mod = await runner.import( + posix.join(server.config.root, 'fixtures/basic.js'), + ) + expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) + }) +}) + +describe('virtual module hmr', async () => { + let state = 'init' + + it.scoped({ + config: { + server: { + hmr: true, + }, + plugins: [ + { + name: 'test-resolevId', + enforce: 'pre', + resolveId(source) { + if (source === 'virtual:test') { + return '\0' + source + } + }, + load(id) { + if (id === '\0virtual:test') { + return `export default ${JSON.stringify(state)}` + } + }, + }, + ], + }, + }) + + it('full reload', async ({ environment, runner }) => { + const mod = await runner.import('virtual:test') + expect(mod.default).toBe('init') + state = 'reloaded' + environment.moduleGraph.invalidateAll() + environment.hot.send({ type: 'full-reload' }) + await vi.waitFor(() => { + const mod = runner.evaluatedModules.getModuleById('\0virtual:test') + expect(mod?.exports.default).toBe('reloaded') + }) + }) + + it("the external module's ID and file are resolved correctly", async ({ + server, + runner, + }) => { + await runner.import( + posix.join(server.config.root, 'fixtures/import-external.ts'), + ) + const moduleNode = runner.evaluatedModules.getModuleByUrl('tinyglobby')! + const meta = moduleNode.meta as ExternalFetchResult + if (process.platform === 'win32') { + expect(meta.externalize).toMatch(/^file:\/\/\/\w:\//) // file:///C:/ + expect(moduleNode.id).toMatch(/^\w:\//) // C:/ + expect(moduleNode.file).toMatch(/^\w:\//) // C:/ + } else { + expect(meta.externalize).toMatch(/^file:\/\/\//) // file:/// + expect(moduleNode.id).toMatch(/^\//) // / + expect(moduleNode.file).toMatch(/^\//) // / + } + }) +}) + +describe('invalid package', async () => { + it.scoped({ + config: { + environments: { + ssr: { + resolve: { + noExternal: true, + }, + }, + }, + }, + }) + + it('can catch resolve error on runtime', async ({ runner }) => { + const mod = await runner.import('./fixtures/invalid-package/test.js') + expect(await mod.test()).toMatchInlineSnapshot(` + { + "data": [Error: Failed to resolve entry for package "test-dep-invalid-exports". The package may have incorrect main/module/exports specified in its package.json.], + "ok": false, + } + `) + }) +}) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts index 6caeea61be17b1..3cd95c7649cee1 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-source-maps.spec.ts @@ -4,25 +4,20 @@ import { describe, expect } from 'vitest' import type { ViteDevServer } from '../../..' import type { ModuleRunnerContext } from '../../../../module-runner' import { ESModulesEvaluator } from '../../../../module-runner' -import { - createFixtureEditor, - createModuleRunnerTester, - resolvePath, -} from './utils' +import { createFixtureEditor, runnerTest as it, resolvePath } from './utils' describe('module runner initialization', async () => { - const it = await createModuleRunnerTester( - {}, - { + it.scoped({ + runnerOptions: { sourcemapInterceptor: 'prepareStackTrace', }, - ) + }) const getError = async (cb: () => void): Promise => { try { await cb() expect.unreachable() - } catch (err) { + } catch (err: any) { return err } } @@ -157,13 +152,12 @@ describe('module runner with node:vm executor', async () => { } } - const it = await createModuleRunnerTester( - {}, - { + it.scoped({ + runnerOptions: { sourcemapInterceptor: 'prepareStackTrace', evaluator: new Evaluator(), }, - ) + }) it('should not crash when error stacktrace contains negative column', async ({ runner, diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 0895b1af4895bd..4d6ff42300e266 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -3,29 +3,35 @@ import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { test as base, onTestFinished } from 'vitest' import type { ModuleRunner } from 'vite/module-runner' -// import type { ServerModuleRunnerOptions } from '../serverModuleRunner' import type { ViteDevServer } from '../../../server' import type { InlineConfig } from '../../../config' import { createServer } from '../../../server' -// import { createServerModuleRunner } from '../serverModuleRunner' -// import type { DevEnvironment } from '../../../server/environment' -// import type { RunnableDevEnvironment } from '../../..' -import type { FullBundleRunnableDevEnvironment } from '../../../server/environments/fullBundleRunnableEnvironment' +import type { RunnableDevEnvironment } from '../../../server/environments/runnableEnvironment' +import { + type ServerModuleRunnerOptions, + createServerModuleRunner, +} from '../serverModuleRunner' interface TestClient { config: InlineConfig server: ViteDevServer runner: ModuleRunner - environment: FullBundleRunnableDevEnvironment + runnerOptions: ServerModuleRunnerOptions | undefined + environment: RunnableDevEnvironment } export const runnerTest = base.extend({ + // eslint-disable-next-line no-empty-pattern + runnerOptions: async ({}, use) => { + await use(undefined) + }, // eslint-disable-next-line no-empty-pattern config: async ({}, use) => { await use({}) }, server: async ({ config }, use) => { const server = await createServer({ + configFile: false, root: import.meta.dirname, logLevel: 'error', ssr: { @@ -69,6 +75,7 @@ export const runnerTest = base.extend({ middlewareMode: true, watch: null, ws: false, + hmr: false, ...config.server, }, }) @@ -79,10 +86,16 @@ export const runnerTest = base.extend({ await server.close() }, environment: async ({ server }, use) => { - await use(server.environments.ssr as FullBundleRunnableDevEnvironment) + await use(server.environments.ssr as RunnableDevEnvironment) }, - runner: async ({ environment }, use) => { - await use(environment.runner) + runner: async ({ environment, runnerOptions }, use) => { + if (runnerOptions) { + const runner = createServerModuleRunner(environment, runnerOptions) + await use(runner) + await runner.close() + } else { + await use(environment.runner) + } }, }) From ca267cb57767104efa60d6455f3b109b5a9ae663 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 24 Feb 2026 16:33:39 +0100 Subject: [PATCH 10/21] refactor: use `fullReload` as a fixture --- .../runtime/__tests__/server-runtime.spec.ts | 125 +++++++++--------- .../src/node/ssr/runtime/__tests__/utils.ts | 24 +++- 2 files changed, 76 insertions(+), 73 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index b6f23952c3373c..ccddc6dfe292ab 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -10,52 +10,45 @@ const _URL = URL describe('module runner initialization', async () => { it.scoped({ + fullBundle: [ + './fixtures/dynamic-import.js', + './fixtures/simple.js', + './fixtures/test.css', + './fixtures/test.module.css', + './fixtures/assets.js', + './fixtures/top-level-object.js', + './fixtures/cyclic2/test9/index.js', + './fixtures/live-binding/test4/index.js', + './fixtures/live-binding/test3/index.js', + './fixtures/live-binding/test2/index.js', + './fixtures/live-binding/test1/index.js', + './fixtures/execution-order-re-export/index.js', + './fixtures/cyclic2/test7/Ion.js', + './fixtures/cyclic2/test6/index.js', + './fixtures/cyclic2/test5/index.js', + './fixtures/cyclic2/test4/index.js', + './fixtures/cyclic2/test3/index.js', + './fixtures/cyclic2/test2/index.js', + './fixtures/cyclic2/test1/index.js', + './fixtures/no-this/importer.js', + './fixtures/native.js', + './fixtures/installed.js', + './fixtures/virtual.js', + './fixtures/cyclic/entry.js', + './fixtures/has-error.js', + './fixtures/basic.js', + './fixtures/simple.js?raw', + './fixtures/simple.js?url', + './fixtures/test.css?inline', + // TODO: this fails during bundle, not at runtime + // at the moment it HANGS the whole process + // './fixtures/esm-external-non-existing.js', + // './fixtures/cjs-external-non-existing.js', + ], config: { resolve: { external: ['tinyglobby'], }, - experimental: { - ssrBundledDev: true, - }, - build: { - rolldownOptions: { - input: [ - './fixtures/dynamic-import.js', - './fixtures/simple.js', - './fixtures/test.css', - './fixtures/test.module.css', - './fixtures/assets.js', - './fixtures/top-level-object.js', - './fixtures/cyclic2/test9/index.js', - './fixtures/live-binding/test4/index.js', - './fixtures/live-binding/test3/index.js', - './fixtures/live-binding/test2/index.js', - './fixtures/live-binding/test1/index.js', - './fixtures/execution-order-re-export/index.js', - './fixtures/cyclic2/test7/Ion.js', - './fixtures/cyclic2/test6/index.js', - './fixtures/cyclic2/test5/index.js', - './fixtures/cyclic2/test4/index.js', - './fixtures/cyclic2/test3/index.js', - './fixtures/cyclic2/test2/index.js', - './fixtures/cyclic2/test1/index.js', - './fixtures/no-this/importer.js', - './fixtures/native.js', - './fixtures/installed.js', - './fixtures/virtual.js', - './fixtures/cyclic/entry.js', - './fixtures/has-error.js', - './fixtures/basic.js', - './fixtures/simple.js?raw', - './fixtures/simple.js?url', - './fixtures/test.css?inline', - // TODO: this fails during bundle, not at runtime - // at the moment it HANGS the whole process - // './fixtures/esm-external-non-existing.js', - // './fixtures/cjs-external-non-existing.js', - ], - }, - }, }, }) @@ -77,9 +70,9 @@ describe('module runner initialization', async () => { it('can load virtual modules as an entry point', async ({ runner, skip, - config, + fullBundle, }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') const mod = await runner.import('virtual:test') expect(mod.msg).toBe('virtual') @@ -129,9 +122,9 @@ describe('module runner initialization', async () => { }) }) - it('assets are loaded correctly', async ({ runner, config }) => { + it('assets are loaded correctly', async ({ runner, fullBundle }) => { const assets = await runner.import('/fixtures/assets.js') - if (config.experimental?.ssrBundledDev) { + if (fullBundle.length) { expect(assets).toMatchObject({ mov: 'data:video/quicktime;base64,', txt: 'data:text/plain;base64,', @@ -150,7 +143,7 @@ describe('module runner initialization', async () => { it('ids with Vite queries are loaded correctly', async ({ runner, - config, + fullBundle, }) => { const raw = await runner.import('/fixtures/simple.js?raw') expect(raw.default).toMatchInlineSnapshot(` @@ -160,7 +153,7 @@ describe('module runner initialization', async () => { " `) const url = await runner.import('/fixtures/simple.js?url') - if (config.experimental?.ssrBundledDev) { + if (fullBundle.length) { expect(url.default).toMatch('__VITE_ASSET__') } else { expect(url.default).toMatchInlineSnapshot(`"/fixtures/simple.js"`) @@ -176,12 +169,12 @@ describe('module runner initialization', async () => { it('modules with query strings are treated as different modules', async ({ runner, - config, + fullBundle, }) => { const modSimple = await runner.import('/fixtures/simple.js') const modUrl = await runner.import('/fixtures/simple.js?url') expect(modSimple).not.toBe(modUrl) - if (config.experimental?.ssrBundledDev) { + if (fullBundle.length) { expect(modUrl.default).toContain('__VITE_ASSET__') } else { expect(modUrl.default).toBe('/fixtures/simple.js') @@ -235,9 +228,9 @@ describe('module runner initialization', async () => { it('importing external cjs library checks exports', async ({ runner, skip, - config, + fullBundle, }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) .rejects.toThrowErrorMatchingInlineSnapshot(` @@ -259,9 +252,9 @@ describe('module runner initialization', async () => { it('importing external esm library checks exports', async ({ runner, skip, - config, + fullBundle, }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') await expect(() => runner.import('/fixtures/esm-external-non-existing.js'), @@ -277,12 +270,12 @@ describe('module runner initialization', async () => { }) it("dynamic import doesn't produce duplicates", async ({ - config, + fullBundle, skip, runner, }) => { // rolldown doesn't return the same reference and doesn't support non-processed dynamic imports - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') const mod = await runner.import('./fixtures/dynamic-import.js') const modules = await mod.initialize() @@ -296,8 +289,8 @@ describe('module runner initialization', async () => { expect(modules.static).toBe(modules.dynamicFileUrl) }) - it('dynamic imports in FBM', async ({ config, skip, runner }) => { - skip(!config.experimental?.ssrBundledDev, 'FBM') + it('dynamic imports in FBM', async ({ fullBundle, skip, runner }) => { + skip(!fullBundle.length, 'FBM') const mod = await runner.import('./fixtures/dynamic-import.js') const modules = await mod.initialize(true) @@ -332,10 +325,10 @@ describe('module runner initialization', async () => { it('correctly resolves module url', async ({ runner, server, - config, + fullBundle, skip, }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') const { meta } = await runner.import('/fixtures/basic.js') const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() @@ -365,9 +358,9 @@ describe('module runner initialization', async () => { it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ runner, skip, - config, + fullBundle, }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + skip(!!fullBundle.length, 'FBM') // entry.js ⇔ entry-cyclic.js // ⇓ @@ -403,11 +396,11 @@ describe('module runner initialization', async () => { }) }) - it(`cyclic invalid 1`, async ({ runner, config }) => { + it(`cyclic invalid 1`, async ({ runner, fullBundle }) => { // Node also fails but with a different message // $ node packages/vite/src/node/ssr/runtime/__tests__/fixtures/cyclic2/test5/index.js // ReferenceError: Cannot access 'dep1' before initialization - if (config.experimental?.ssrBundledDev) { + if (fullBundle.length) { await expect(() => runner.import('/fixtures/cyclic2/test5/index.js'), ).rejects.toMatchInlineSnapshot( @@ -424,8 +417,8 @@ describe('module runner initialization', async () => { // rolldown doesn't support this // - Cannot access 'dep1' before initialization - it(`cyclic invalid 2`, async ({ runner, skip, config }) => { - skip(!!config.experimental?.ssrBundledDev, 'FBM') + it(`cyclic invalid 2`, async ({ runner, skip, fullBundle }) => { + skip(!!fullBundle.length, 'FBM') // It should be an error but currently `undefined` fallback. expect( diff --git a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts index 4d6ff42300e266..35a73d37ed0c61 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/utils.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/utils.ts @@ -13,6 +13,7 @@ import { } from '../serverModuleRunner' interface TestClient { + fullBundle: string[] config: InlineConfig server: ViteDevServer runner: ModuleRunner @@ -22,14 +23,12 @@ interface TestClient { export const runnerTest = base.extend({ // eslint-disable-next-line no-empty-pattern - runnerOptions: async ({}, use) => { - await use(undefined) - }, + fullBundle: ({}, use) => use([]), // eslint-disable-next-line no-empty-pattern - config: async ({}, use) => { - await use({}) - }, - server: async ({ config }, use) => { + runnerOptions: ({}, use) => use(undefined), + // eslint-disable-next-line no-empty-pattern + config: ({}, use) => use({}), + server: async ({ config, fullBundle }, use) => { const server = await createServer({ configFile: false, root: import.meta.dirname, @@ -37,6 +36,17 @@ export const runnerTest = base.extend({ ssr: { external: ['@vitejs/cjs-external', '@vitejs/esm-external'], }, + experimental: { + ssrBundledDev: fullBundle.length > 0, + ...config.experimental, + }, + build: { + rolldownOptions: { + input: fullBundle, + ...config.build?.rolldownOptions, + }, + ...config.build, + }, optimizeDeps: { disabled: true, noDiscovery: true, From eb52e5f4c8749cc826bdd2cd629a83ec00db38ca Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 24 Feb 2026 16:51:12 +0100 Subject: [PATCH 11/21] fix: provide a file --- packages/vite/src/node/ssr/fetchModule.ts | 16 ++++++++++++++-- .../ssr/runtime/__tests__/server-hmr.spec.ts | 12 +++++++----- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 4dc8409ddbae71..0ff2b7cb0273ea 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -93,6 +93,11 @@ export async function fetchModule( if (environment instanceof FullBundleDevEnvironment) { await environment._waitForInitialBuildSuccess() + const outDir = resolve( + environment.config.root, + environment.config.build.outDir, + ) + let fileName: string if (!importer) { @@ -108,6 +113,12 @@ export async function fetchModule( ) } } else if (url[0] === '.') { + // Importer is reported as a full path on the file system. + // This happens because we provide the `file` attribute. + if (importer.startsWith(outDir)) { + importer = importer.slice(outDir.length + 1) + } + fileName = path.posix.join(path.posix.dirname(importer), url) } else { fileName = url @@ -130,8 +141,9 @@ export async function fetchModule( // (Dynamic import resolves relative urls with importer url) url: fileName, id: fileName, - // We don't keep assets on the file system. - file: null, + // The potential position on the file system. + // We don't actually keep it there, it's virtual. + file: resolve(outDir, fileName), // TODO: how to know the file was invalidated? invalidate: false, } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts index 997df1f12095b7..f7910922da7ac6 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts @@ -1,13 +1,15 @@ import { describe, expect } from 'vitest' -import { createModuleRunnerTester } from './utils' +import { runnerTest as it } from './utils' describe( 'module runner hmr works as expected', async () => { - const it = await createModuleRunnerTester({ - server: { - // override watch options because it's disabled by default - watch: {}, + it.scoped({ + config: { + server: { + // override watch options because it's disabled by default + watch: {}, + }, }, }) From 81c0c97be807833705059446cad1478d8438ae03 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Tue, 24 Feb 2026 16:52:51 +0100 Subject: [PATCH 12/21] test: fix test --- packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts index f7910922da7ac6..a7862e5c0ef214 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-hmr.spec.ts @@ -9,6 +9,7 @@ describe( server: { // override watch options because it's disabled by default watch: {}, + hmr: true, }, }, }) From d6d6b736b43f7ffef03f62780411253da6b0afff Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 13:24:14 +0100 Subject: [PATCH 13/21] chore: cleanup tests --- packages/vite/src/node/ssr/fetchModule.ts | 8 +- .../runtime/__tests__/server-runtime.spec.ts | 327 ++++++++---------- 2 files changed, 152 insertions(+), 183 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 0ff2b7cb0273ea..b2405e8a7d1546 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -1,5 +1,5 @@ import { fileURLToPath, pathToFileURL } from 'node:url' -import path, { resolve } from 'node:path' +import path from 'node:path' import type { FetchResult } from 'vite/module-runner' import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' @@ -93,7 +93,7 @@ export async function fetchModule( if (environment instanceof FullBundleDevEnvironment) { await environment._waitForInitialBuildSuccess() - const outDir = resolve( + const outDir = path.resolve( environment.config.root, environment.config.build.outDir, ) @@ -143,7 +143,7 @@ export async function fetchModule( id: fileName, // The potential position on the file system. // We don't actually keep it there, it's virtual. - file: resolve(outDir, fileName), + file: slash(path.resolve(outDir, fileName)), // TODO: how to know the file was invalidated? invalidate: false, } @@ -255,7 +255,7 @@ function resolveEntryFilename( : // ./index.js // NOTE: we don't try to find it if extension is not passed // It will throw an error instead - slash(resolve(environment.config.root, url)) + slash(path.resolve(environment.config.root, url)) if (environment.facadeToChunk.get(moduleId)) { return environment.facadeToChunk.get(moduleId) } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index ccddc6dfe292ab..0fe2dbdbd73938 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -8,8 +8,8 @@ import { runnerTest as it } from './utils' const _URL = URL -describe('module runner initialization', async () => { - it.scoped({ +describe.for([ + { fullBundle: [ './fixtures/dynamic-import.js', './fixtures/simple.js', @@ -40,11 +40,17 @@ describe('module runner initialization', async () => { './fixtures/simple.js?raw', './fixtures/simple.js?url', './fixtures/test.css?inline', + // TODO: this fails during bundle, not at runtime - // at the moment it HANGS the whole process // './fixtures/esm-external-non-existing.js', // './fixtures/cjs-external-non-existing.js', ], + title: 'full bundle mode', + }, + { fullBundle: [], title: 'dev mode' }, +])('module runner initialization ($title)', async ({ fullBundle }) => { + it.scoped({ + fullBundle, config: { resolve: { external: ['tinyglobby'], @@ -67,49 +73,6 @@ describe('module runner initialization', async () => { expect(mod).toBe(mod3) }) - it('can load virtual modules as an entry point', async ({ - runner, - skip, - fullBundle, - }) => { - skip(!!fullBundle.length, 'FBM') - - const mod = await runner.import('virtual:test') - expect(mod.msg).toBe('virtual') - - // already resolved id works similar to `transformRequest` - expect(await runner.import(`\0virtual:normal`)).toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // escaped virtual module id works - expect(await runner.import(`/@id/__x00__virtual:normal`)) - .toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // timestamp query works - expect(await runner.import(`virtual:normal?t=${Date.now()}`)) - .toMatchInlineSnapshot(` - { - "default": "ok", - } - `) - - // other arbitrary queries don't work - await expect(() => - runner.import('virtual:normal?abcd=1234'), - ).rejects.toMatchObject({ - message: expect.stringContaining( - 'Failed to load url virtual:normal?abcd=1234', - ), - }) - }) - it('css is loaded correctly', async ({ runner }) => { const css = await runner.import('/fixtures/test.css') expect(css.default).toBe(undefined) @@ -224,71 +187,6 @@ describe('module runner initialization', async () => { } }) - // if bundle throws an error, we should stopn waiting - it('importing external cjs library checks exports', async ({ - runner, - skip, - fullBundle, - }) => { - skip(!!fullBundle.length, 'FBM') - - await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) - .rejects.toThrowErrorMatchingInlineSnapshot(` - [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. - CommonJS modules can always be imported via the default export, for example using: - - import pkg from '@vitejs/cjs-external'; - const {nonExisting} = pkg; - ] - `) - // subsequent imports of the same external package should not throw if imports are correct - await expect( - runner.import('/fixtures/cjs-external-existing.js'), - ).resolves.toMatchObject({ - result: 'world', - }) - }) - - it('importing external esm library checks exports', async ({ - runner, - skip, - fullBundle, - }) => { - skip(!!fullBundle.length, 'FBM') - - await expect(() => - runner.import('/fixtures/esm-external-non-existing.js'), - ).rejects.toThrowErrorMatchingInlineSnapshot( - `[SyntaxError: [vite] The requested module '@vitejs/esm-external' does not provide an export named 'nonExisting']`, - ) - // subsequent imports of the same external package should not throw if imports are correct - await expect( - runner.import('/fixtures/esm-external-existing.js'), - ).resolves.toMatchObject({ - result: 'world', - }) - }) - - it("dynamic import doesn't produce duplicates", async ({ - fullBundle, - skip, - runner, - }) => { - // rolldown doesn't return the same reference and doesn't support non-processed dynamic imports - skip(!!fullBundle.length, 'FBM') - - const mod = await runner.import('./fixtures/dynamic-import.js') - const modules = await mod.initialize() - // toBe checks that objects are actually the same, not just structurally - // using toEqual here would be a mistake because it check the structural difference - expect(modules.static).toBe(modules.dynamicProcessed) - expect(modules.static).toBe(modules.dynamicRelative) - expect(modules.static).toBe(modules.dynamicAbsolute) - expect(modules.static).toBe(modules.dynamicAbsoluteExtension) - expect(modules.static).toBe(modules.dynamicAbsoluteFull) - expect(modules.static).toBe(modules.dynamicFileUrl) - }) - it('dynamic imports in FBM', async ({ fullBundle, skip, runner }) => { skip(!fullBundle.length, 'FBM') @@ -321,57 +219,6 @@ describe('module runner initialization', async () => { expect(mod.existsSync).toBe(existsSync) }) - // files are virtual, so url is not defined - it('correctly resolves module url', async ({ - runner, - server, - fullBundle, - skip, - }) => { - skip(!!fullBundle.length, 'FBM') - - const { meta } = await runner.import('/fixtures/basic.js') - const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() - expect(meta.url).toBe(basicUrl) - - const filename = meta.filename! - const dirname = meta.dirname! - - if (isWindows) { - const cwd = process.cwd() - const drive = `${cwd[0].toUpperCase()}:\\` - const root = server.config.root.replace(/\\/g, '/') - - expect(filename.startsWith(drive)).toBe(true) - expect(dirname.startsWith(drive)).toBe(true) - - expect(filename).toBe(win32.join(root, '.\\fixtures\\basic.js')) - expect(dirname).toBe(win32.join(root, '.\\fixtures')) - } else { - const root = server.config.root - - expect(posix.join(root, './fixtures/basic.js')).toBe(filename) - expect(posix.join(root, './fixtures')).toBe(dirname) - } - }) - - it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ - runner, - skip, - fullBundle, - }) => { - skip(!!fullBundle.length, 'FBM') - - // entry.js ⇔ entry-cyclic.js - // ⇓ - // action.js - const mod = await runner.import('./fixtures/cyclic/entry.js') - await mod.setupCyclic() - // TODO(FBM): Importing dynamically is not supported yet - const action = await mod.importAction('/fixtures/cyclic/action') - expect(action).toBeDefined() - }) - it('this of the exported function should be undefined', async ({ runner, }) => { @@ -415,23 +262,6 @@ describe('module runner initialization', async () => { } }) - // rolldown doesn't support this - // - Cannot access 'dep1' before initialization - it(`cyclic invalid 2`, async ({ runner, skip, fullBundle }) => { - skip(!!fullBundle.length, 'FBM') - - // It should be an error but currently `undefined` fallback. - expect( - await runner.import('/fixtures/cyclic2/test6/index.js'), - ).toMatchInlineSnapshot( - ` - { - "dep1": "dep1: dep2: undefined", - } - `, - ) - }) - it(`cyclic with mixed import and re-export`, async ({ runner }) => { const mod = await runner.import('/fixtures/cyclic2/test7/Ion.js') expect(mod).toMatchInlineSnapshot(` @@ -526,6 +356,145 @@ describe('module runner initialization', async () => { }) }) +describe('not supported by bundle mode', () => { + // if bundle throws an error, we should stopn waiting + it('importing external cjs library checks exports', async ({ runner }) => { + await expect(() => runner.import('/fixtures/cjs-external-non-existing.js')) + .rejects.toThrowErrorMatchingInlineSnapshot(` + [SyntaxError: [vite] Named export 'nonExisting' not found. The requested module '@vitejs/cjs-external' is a CommonJS module, which may not support all module.exports as named exports. + CommonJS modules can always be imported via the default export, for example using: + + import pkg from '@vitejs/cjs-external'; + const {nonExisting} = pkg; + ] + `) + // subsequent imports of the same external package should not throw if imports are correct + await expect( + runner.import('/fixtures/cjs-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it('importing external esm library checks exports', async ({ runner }) => { + await expect(() => + runner.import('/fixtures/esm-external-non-existing.js'), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `[SyntaxError: [vite] The requested module '@vitejs/esm-external' does not provide an export named 'nonExisting']`, + ) + // subsequent imports of the same external package should not throw if imports are correct + await expect( + runner.import('/fixtures/esm-external-existing.js'), + ).resolves.toMatchObject({ + result: 'world', + }) + }) + + it("dynamic import doesn't produce duplicates", async ({ runner }) => { + const mod = await runner.import('/fixtures/dynamic-import.js') + const modules = await mod.initialize() + // toBe checks that objects are actually the same, not just structurally + // using toEqual here would be a mistake because it check the structural difference + expect(modules.static).toBe(modules.dynamicProcessed) + expect(modules.static).toBe(modules.dynamicRelative) + expect(modules.static).toBe(modules.dynamicAbsolute) + expect(modules.static).toBe(modules.dynamicAbsoluteExtension) + expect(modules.static).toBe(modules.dynamicAbsoluteFull) + expect(modules.static).toBe(modules.dynamicFileUrl) + }) + + it('can load virtual modules as an entry point', async ({ runner }) => { + const mod = await runner.import('virtual:test') + expect(mod.msg).toBe('virtual') + + // already resolved id works similar to `transformRequest` + expect(await runner.import(`\0virtual:normal`)).toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // escaped virtual module id works + expect(await runner.import(`/@id/__x00__virtual:normal`)) + .toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // timestamp query works + expect(await runner.import(`virtual:normal?t=${Date.now()}`)) + .toMatchInlineSnapshot(` + { + "default": "ok", + } + `) + + // other arbitrary queries don't work + await expect(() => + runner.import('virtual:normal?abcd=1234'), + ).rejects.toMatchObject({ + message: expect.stringContaining( + 'Failed to load url virtual:normal?abcd=1234', + ), + }) + }) + + // files are virtual, so url is not defined + it('correctly resolves module url', async ({ runner, server }) => { + const { meta } = await runner.import('/fixtures/basic.js') + const basicUrl = new _URL('./fixtures/basic.js', import.meta.url).toString() + expect(meta.url).toBe(basicUrl) + + const filename = meta.filename! + const dirname = meta.dirname! + + if (isWindows) { + const cwd = process.cwd() + const drive = `${cwd[0].toUpperCase()}:\\` + const root = server.config.root.replace(/\\/g, '/') + + expect(filename.startsWith(drive)).toBe(true) + expect(dirname.startsWith(drive)).toBe(true) + + expect(filename).toBe(win32.join(root, '.\\fixtures\\basic.js')) + expect(dirname).toBe(win32.join(root, '.\\fixtures')) + } else { + const root = server.config.root + + expect(posix.join(root, './fixtures/basic.js')).toBe(filename) + expect(posix.join(root, './fixtures')).toBe(dirname) + } + }) + + it(`no maximum call stack error ModuleRunner.isCircularImport`, async ({ + runner, + }) => { + // entry.js ⇔ entry-cyclic.js + // ⇓ + // action.js + const mod = await runner.import('./fixtures/cyclic/entry.js') + await mod.setupCyclic() + const action = await mod.importAction('/fixtures/cyclic/action') + expect(action).toBeDefined() + }) + + // rolldown doesn't support this + // - Cannot access 'dep1' before initialization + it(`cyclic invalid 2`, async ({ runner }) => { + // It should be an error but currently `undefined` fallback. + expect( + await runner.import('/fixtures/cyclic2/test6/index.js'), + ).toMatchInlineSnapshot( + ` + { + "dep1": "dep1: dep2: undefined", + } + `, + ) + }) +}) + describe('optimize-deps', async () => { it.scoped({ config: { From 3a517a8e3f225360f1f2ac3b17008939c3f0aaf7 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 13:43:29 +0100 Subject: [PATCH 14/21] chore: tests --- packages/vite/src/node/index.ts | 4 ++++ .../environments/fullBundleRunnableEnvironment.ts | 8 ++++++++ packages/vite/src/node/ssr/fetchModule.ts | 6 +++--- .../ssr/runtime/__tests__/server-runtime.spec.ts | 13 +++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index b35e209fa6ad91..3a2085b7c7bb47 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -49,6 +49,10 @@ export { type RunnableDevEnvironment, type RunnableDevEnvironmentContext, } from './server/environments/runnableEnvironment' +export { + isFullBundleRunnableDevEnvironment, + type FullBundleRunnableDevEnvironment, +} from './server/environments/fullBundleRunnableEnvironment' export { createFetchableDevEnvironment, isFetchableDevEnvironment, diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 28c8a2cf345bf6..d473ec124af170 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -8,6 +8,7 @@ import { import { ssrRolldownRuntimeDefineMethod } from '../../../module-runner/constants' import { FullBundleDevEnvironment } from './fullBundleEnvironment' +/** @experimental */ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { private _runner: ModuleRunner | undefined @@ -80,3 +81,10 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { } } } + +/** @experimental */ +export function isFullBundleRunnableDevEnvironment( + environment: unknown, +): environment is FullBundleRunnableDevEnvironment { + return environment instanceof FullBundleRunnableDevEnvironment +} diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index b2405e8a7d1546..48d9be8b53f278 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -107,9 +107,9 @@ export async function fetchModule( const entrypoints = [...environment.facadeToChunk.keys()] throw new Error( `[vite] Entrypoint '${url}' was not defined in the config. ` + - entrypoints.length - ? `Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}` - : `The build did not produce any chunks. Did it finish successfully? See the logs for more information.`, + (entrypoints.length + ? `Available entry points: \n- ${[...environment.facadeToChunk.keys()].join('\n- ')}` + : `The build did not produce any chunks. Did it finish successfully? See the logs for more information.`), ) } } else if (url[0] === '.') { diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 0fe2dbdbd73938..7c4b19d6086df7 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -552,6 +552,19 @@ describe('resolveId absolute path entry', async () => { ) expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) }) + + describe('in full bundle mode', async () => { + it.scoped({ + fullBundle: [posix.join(import.meta.dirname, 'fixtures/basic.js')], + }) + + it('runner', async ({ runner }) => { + // Unlike with dev mode, the ID is specified in the build options, + // And then we HAVE to use the resolved ID here to get the chunk name. + const mod = await runner.import('\0virtual:basic') + expect(mod.name).toMatchInlineSnapshot(`"virtual:basic"`) + }) + }) }) describe('virtual module hmr', async () => { From d7a452acd656bd3a386ffec629745822f5cf8b9c Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 13:56:52 +0100 Subject: [PATCH 15/21] chore: use posix to resolve --- packages/vite/src/node/ssr/fetchModule.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 48d9be8b53f278..6d3ed67fbc0fa0 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -93,7 +93,7 @@ export async function fetchModule( if (environment instanceof FullBundleDevEnvironment) { await environment._waitForInitialBuildSuccess() - const outDir = path.resolve( + const outDir = path.posix.resolve( environment.config.root, environment.config.build.outDir, ) @@ -143,12 +143,13 @@ export async function fetchModule( id: fileName, // The potential position on the file system. // We don't actually keep it there, it's virtual. - file: slash(path.resolve(outDir, fileName)), + file: slash(path.posix.resolve(outDir, fileName)), // TODO: how to know the file was invalidated? invalidate: false, } // TODO: this should be done in rolldown, there is already a function for it // output.format = 'module-runner' + // See https://github.com/rolldown/rolldown/issues/8376 const ssrResult = await ssrTransform(result.code, null, url, result.code) if (!ssrResult) { throw new Error(`[vite] cannot apply ssr transform to '${url}'.`) @@ -255,12 +256,12 @@ function resolveEntryFilename( : // ./index.js // NOTE: we don't try to find it if extension is not passed // It will throw an error instead - slash(path.resolve(environment.config.root, url)) + slash(path.posix.resolve(environment.config.root, url)) if (environment.facadeToChunk.get(moduleId)) { return environment.facadeToChunk.get(moduleId) } if (url[0] === '/') { - const tryAbsouteUrl = path.join(environment.config.root, url) + const tryAbsouteUrl = path.posix.join(environment.config.root, url) return environment.facadeToChunk.get(tryAbsouteUrl) } } From 073737cc9007a7920bb0145737e1b48759a84cf9 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 14:36:30 +0100 Subject: [PATCH 16/21] fix: normalize windows paths --- packages/vite/src/node/ssr/fetchModule.ts | 20 +++++++++++-------- .../runtime/__tests__/server-runtime.spec.ts | 6 +++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index 6d3ed67fbc0fa0..a51555f403493f 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -3,8 +3,13 @@ import path from 'node:path' import type { FetchResult } from 'vite/module-runner' import type { TransformResult } from '..' import { tryNodeResolve } from '../plugins/resolve' -import { isBuiltin, isExternalUrl, isFilePathESM } from '../utils' -import { slash, unwrapId } from '../../shared/utils' +import { + isBuiltin, + isExternalUrl, + isFilePathESM, + normalizePath, +} from '../utils' +import { unwrapId } from '../../shared/utils' import { MODULE_RUNNER_SOURCEMAPPING_SOURCE, SOURCEMAPPING_URL, @@ -93,9 +98,8 @@ export async function fetchModule( if (environment instanceof FullBundleDevEnvironment) { await environment._waitForInitialBuildSuccess() - const outDir = path.posix.resolve( - environment.config.root, - environment.config.build.outDir, + const outDir = normalizePath( + path.resolve(environment.config.root, environment.config.build.outDir), ) let fileName: string @@ -143,7 +147,7 @@ export async function fetchModule( id: fileName, // The potential position on the file system. // We don't actually keep it there, it's virtual. - file: slash(path.posix.resolve(outDir, fileName)), + file: normalizePath(path.resolve(outDir, fileName)), // TODO: how to know the file was invalidated? invalidate: false, } @@ -252,11 +256,11 @@ function resolveEntryFilename( } const moduleId = url.startsWith('file://') ? // new URL(path) - fileURLToPath(url) + normalizePath(fileURLToPath(url)) : // ./index.js // NOTE: we don't try to find it if extension is not passed // It will throw an error instead - slash(path.posix.resolve(environment.config.root, url)) + normalizePath(path.resolve(environment.config.root, url)) if (environment.facadeToChunk.get(moduleId)) { return environment.facadeToChunk.get(moduleId) } diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 7c4b19d6086df7..0fa67cec7d0278 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -2,7 +2,7 @@ import { existsSync, readdirSync } from 'node:fs' import { posix, win32 } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, vi } from 'vitest' -import { isWindows } from '../../../../shared/utils' +import { isWindows, slash } from '../../../../shared/utils' import type { ExternalFetchResult } from '../../../../shared/invokeMethods' import { runnerTest as it } from './utils' @@ -555,10 +555,10 @@ describe('resolveId absolute path entry', async () => { describe('in full bundle mode', async () => { it.scoped({ - fullBundle: [posix.join(import.meta.dirname, 'fixtures/basic.js')], + fullBundle: [posix.join(slash(import.meta.dirname), 'fixtures/basic.js')], }) - it('runner', async ({ runner }) => { + it.only('runner', async ({ runner }) => { // Unlike with dev mode, the ID is specified in the build options, // And then we HAVE to use the resolved ID here to get the chunk name. const mod = await runner.import('\0virtual:basic') From cb586d9184e6d6e52a4c3cae5348190a8cb6f4c3 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 14:37:27 +0100 Subject: [PATCH 17/21] chore: cleanup --- packages/vite/src/node/ssr/fetchModule.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/vite/src/node/ssr/fetchModule.ts b/packages/vite/src/node/ssr/fetchModule.ts index a51555f403493f..ed45e299fa9c48 100644 --- a/packages/vite/src/node/ssr/fetchModule.ts +++ b/packages/vite/src/node/ssr/fetchModule.ts @@ -254,13 +254,15 @@ function resolveEntryFilename( if (environment.facadeToChunk.has(url)) { return environment.facadeToChunk.get(url) } - const moduleId = url.startsWith('file://') - ? // new URL(path) - normalizePath(fileURLToPath(url)) - : // ./index.js - // NOTE: we don't try to find it if extension is not passed - // It will throw an error instead - normalizePath(path.resolve(environment.config.root, url)) + const moduleId = normalizePath( + url.startsWith('file://') + ? // new URL(path) + fileURLToPath(url) + : // ./index.js + // NOTE: we don't try to find it if extension is not passed + // It will throw an error instead + path.resolve(environment.config.root, url), + ) if (environment.facadeToChunk.get(moduleId)) { return environment.facadeToChunk.get(moduleId) } From 1120a86b78bae9a2b270d9a422d012407cec2fdf Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 14:40:43 +0100 Subject: [PATCH 18/21] chore: remove only --- .../vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 0fa67cec7d0278..4e4c8eaa649b56 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -558,7 +558,7 @@ describe('resolveId absolute path entry', async () => { fullBundle: [posix.join(slash(import.meta.dirname), 'fixtures/basic.js')], }) - it.only('runner', async ({ runner }) => { + it('runner', async ({ runner }) => { // Unlike with dev mode, the ID is specified in the build options, // And then we HAVE to use the resolved ID here to get the chunk name. const mod = await runner.import('\0virtual:basic') From c0993e9d6dcb235ac3e265744460acb84ef8a471 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 15:55:56 +0100 Subject: [PATCH 19/21] fix: hmr --- packages/vite/src/module-runner/constants.ts | 3 ++ packages/vite/src/module-runner/index.ts | 3 ++ packages/vite/src/module-runner/runner.ts | 36 +++++++++++++++---- .../environments/fullBundleEnvironment.ts | 12 ++----- .../fullBundleRunnableEnvironment.ts | 33 +++++++++-------- 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/packages/vite/src/module-runner/constants.ts b/packages/vite/src/module-runner/constants.ts index de263afce58df7..c00dfb370d5a99 100644 --- a/packages/vite/src/module-runner/constants.ts +++ b/packages/vite/src/module-runner/constants.ts @@ -5,5 +5,8 @@ export const ssrDynamicImportKey = `__vite_ssr_dynamic_import__` export const ssrExportAllKey = `__vite_ssr_exportAll__` export const ssrExportNameKey = `__vite_ssr_exportName__` export const ssrImportMetaKey = `__vite_ssr_import_meta__` + export const ssrRolldownRuntimeKey = `__rolldown_runtime__` export const ssrRolldownRuntimeDefineMethod = `__vite_ssr_defineRuntime__` +export const ssrRolldownRuntimeCreateHotContextMethod = `__vite_ssr_createHotContext__` +export const ssrRolldownRuntimeTransport = `__vite_ssr_transport__` diff --git a/packages/vite/src/module-runner/index.ts b/packages/vite/src/module-runner/index.ts index 537e28bce9815a..9bf3494de1e7d7 100644 --- a/packages/vite/src/module-runner/index.ts +++ b/packages/vite/src/module-runner/index.ts @@ -38,5 +38,8 @@ export { ssrImportMetaKey, ssrModuleExportsKey, ssrRolldownRuntimeKey, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeCreateHotContextMethod, + ssrRolldownRuntimeTransport, } from './constants' export type { InterceptorOptions } from './sourcemap/interceptor' diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 28d90d7e47dbed..2bf357ef5e5796 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -1,5 +1,4 @@ import type { DevRuntime } from 'rolldown/experimental/runtime-types' -import type { ViteHotContext } from '#types/hot' import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' import { cleanUrl, isPrimitive } from '../shared/utils' import { analyzeImportedModDifference } from '../shared/ssrTransform' @@ -25,8 +24,10 @@ import { ssrImportKey, ssrImportMetaKey, ssrModuleExportsKey, + ssrRolldownRuntimeCreateHotContextMethod, ssrRolldownRuntimeDefineMethod, ssrRolldownRuntimeKey, + ssrRolldownRuntimeTransport, } from './constants' import { hmrLogger, silentConsole } from './hmrLogger' import { createHMRHandlerForRunner } from './hmrHandler' @@ -68,6 +69,16 @@ export class ModuleRunner { } } + if (p === ssrRolldownRuntimeCreateHotContextMethod) { + return this.closed + ? () => {} + : (url: string) => this.ensureModuleHotContext(url) + } + + if (p === ssrRolldownRuntimeTransport) { + return this.closed ? undefined : this.transport + } + if (!this.rolldownDevRuntime) { throw new Error(`__rolldown_runtime__ was not initialized.`) } @@ -423,20 +434,19 @@ export class ModuleRunner { mod.exports = exports - let hotContext: ViteHotContext | undefined if (this.hmrClient) { Object.defineProperty(meta, 'hot', { enumerable: true, get: () => { if (!this.hmrClient) { - throw new Error(`[module runner] HMR client was closed.`) + return } this.debug?.('[module runner] creating hmr context for', mod.url) - hotContext ||= new HMRContext(this.hmrClient, mod.url) - return hotContext + this.ensureModuleHotContext(mod.url) + return this.moduleHotContexts.get(mod.url) }, set: (value) => { - hotContext = value + this.moduleHotContexts.set(mod.url, value) }, }) } @@ -462,6 +472,20 @@ export class ModuleRunner { return exports } + + private moduleHotContexts = new Map() + + private ensureModuleHotContext(url: string) { + if (!this.hmrClient) { + return + } + + if (!this.moduleHotContexts.has(url)) { + const hotContext = new HMRContext(this.hmrClient, url) + this.moduleHotContexts.set(url, hotContext) + } + return this.moduleHotContexts.get(url) + } } function exportAll(exports: any, sourceModule: any) { diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index ed2bc184a516f4..06572451e625a0 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -75,19 +75,11 @@ export class FullBundleDevEnvironment extends DevEnvironment { memoryFiles: MemoryFiles = new MemoryFiles() facadeToChunk: Map = new Map() - // private buildFinishPromise = promiseWithResolvers() - constructor( name: string, config: ResolvedConfig, context: DevEnvironmentContext, ) { - // if (name !== 'client') { - // throw new Error( - // 'currently full bundle mode is only available for client environment', - // ) - // } - super(name, config, { ...context, disableDepsOptimizer: true }) } @@ -191,7 +183,7 @@ export class FullBundleDevEnvironment extends DevEnvironment { debug?.('INITIAL: run error', e) }, ) - this._waitForInitialBuildFinish().then(() => { + this.waitForInitialBuildFinish().then(() => { debug?.('INITIAL: build done') this.hot.send({ type: 'full-reload', path: '*' }) }) @@ -210,7 +202,7 @@ export class FullBundleDevEnvironment extends DevEnvironment { } } - private async _waitForInitialBuildFinish(): Promise { + private async waitForInitialBuildFinish(): Promise { await this.devEngine.ensureCurrentBuildFinish() while (this.memoryFiles.size === 0) { await setTimeout(10) diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index d473ec124af170..4924b876bdcce1 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,11 +1,19 @@ import type { OutputOptions } from 'rolldown' -import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' +import { + type ModuleRunner, + ssrImportMetaKey, + ssrRolldownRuntimeKey, +} from 'vite/module-runner' import { type ResolvedConfig, createServerHotChannel, createServerModuleRunner, } from '../../index' -import { ssrRolldownRuntimeDefineMethod } from '../../../module-runner/constants' +import { + ssrRolldownRuntimeCreateHotContextMethod, + ssrRolldownRuntimeDefineMethod, + ssrRolldownRuntimeTransport, +} from '../../../module-runner/constants' import { FullBundleDevEnvironment } from './fullBundleEnvironment' /** @experimental */ @@ -15,7 +23,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { constructor(name: string, config: ResolvedConfig) { // Since this is not yet exposed, we create hot channel here super(name, config, { - hot: true, + hot: config.server.hmr !== false, transport: createServerHotChannel(), }) } @@ -29,14 +37,10 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { } protected override async getDevRuntimeImplementation(): Promise { - // TODO: this should not be in this file return ` class ViteDevRuntime extends DevRuntime { createModuleHotContext(moduleId) { - const ctx = __vite_ssr_import_meta__.hot - // TODO: what is this? - // ctx._internal = { updateStyle, removeStyle } - return ctx + return ${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeCreateHotContextMethod}(moduleId) } applyUpdates() { @@ -48,13 +52,12 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { send(message) { switch (message.type) { case 'hmr:module-registered': { - // TODO - // transport.send({ - // type: 'custom', - // event: 'vite:module-loaded', - // // clone array as the runtime reuses the array instance - // data: { modules: message.modules.slice() }, - // }) + ${ssrImportMetaKey}.${ssrRolldownRuntimeTransport}?.send({ + type: 'custom', + event: 'vite:module-loaded', + // clone array as the runtime reuses the array instance + data: { modules: message.modules.slice() }, + }) break } default: From f28cec624c031af3c41c723aac39ba81d5956263 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 16:41:23 +0100 Subject: [PATCH 20/21] refactor: introduce createFullBundleRunnableDevEnvironment and move the runner code to minimise copypaste --- packages/vite/src/node/config.ts | 4 +- packages/vite/src/node/index.ts | 1 + .../fullBundleRunnableEnvironment.ts | 61 ++++++++++++------- .../environments/runnableEnvironment.ts | 43 +++++-------- .../node/ssr/runtime/serverModuleRunner.ts | 30 +++++++++ 5 files changed, 87 insertions(+), 52 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index a254337108e2ac..d29778503dde24 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -131,7 +131,7 @@ import { } from './server/pluginContainer' import { nodeResolveWithVite } from './nodeResolve' import { FullBundleDevEnvironment } from './server/environments/fullBundleEnvironment' -import { FullBundleRunnableDevEnvironment } from './server/environments/fullBundleRunnableEnvironment' +import { createFullBundleRunnableDevEnvironment } from './server/environments/fullBundleRunnableEnvironment' const debug = createDebugger('vite:config', { depth: 10 }) const promisifiedRealpath = promisify(fs.realpath) @@ -260,7 +260,7 @@ function defaultCreateClientDevEnvironment( function defaultCreateSSRDevEnvironment(name: string, config: ResolvedConfig) { if (config.experimental.ssrBundledDev) { - return new FullBundleRunnableDevEnvironment(name, config) + return createFullBundleRunnableDevEnvironment(name, config) } return createRunnableDevEnvironment(name, config) } diff --git a/packages/vite/src/node/index.ts b/packages/vite/src/node/index.ts index 3a2085b7c7bb47..9a325fb1121107 100644 --- a/packages/vite/src/node/index.ts +++ b/packages/vite/src/node/index.ts @@ -50,6 +50,7 @@ export { type RunnableDevEnvironmentContext, } from './server/environments/runnableEnvironment' export { + createFullBundleRunnableDevEnvironment, isFullBundleRunnableDevEnvironment, type FullBundleRunnableDevEnvironment, } from './server/environments/fullBundleRunnableEnvironment' diff --git a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts index 4924b876bdcce1..744e2ccc228d30 100644 --- a/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleRunnableEnvironment.ts @@ -1,39 +1,37 @@ import type { OutputOptions } from 'rolldown' +import { type ModuleRunner, ssrRolldownRuntimeKey } from 'vite/module-runner' import { - type ModuleRunner, - ssrImportMetaKey, - ssrRolldownRuntimeKey, -} from 'vite/module-runner' -import { + type DevEnvironmentContext, type ResolvedConfig, + type RunnableDevEnvironmentContext, createServerHotChannel, - createServerModuleRunner, } from '../../index' import { ssrRolldownRuntimeCreateHotContextMethod, ssrRolldownRuntimeDefineMethod, ssrRolldownRuntimeTransport, } from '../../../module-runner/constants' +import { + type ServerModuleRunnerFactory, + defineServerModuleRunnerFactory, +} from '../../ssr/runtime/serverModuleRunner' import { FullBundleDevEnvironment } from './fullBundleEnvironment' /** @experimental */ -export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { - private _runner: ModuleRunner | undefined +class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { + private _runner: ServerModuleRunnerFactory - constructor(name: string, config: ResolvedConfig) { - // Since this is not yet exposed, we create hot channel here - super(name, config, { - hot: config.server.hmr !== false, - transport: createServerHotChannel(), - }) + constructor( + name: string, + config: ResolvedConfig, + context: RunnableDevEnvironmentContext, + ) { + super(name, config, context as DevEnvironmentContext) + this._runner = defineServerModuleRunnerFactory(this, context) } get runner(): ModuleRunner { - if (this._runner) { - return this._runner - } - this._runner = createServerModuleRunner(this) - return this._runner + return this._runner.create() } protected override async getDevRuntimeImplementation(): Promise { @@ -52,7 +50,7 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { send(message) { switch (message.type) { case 'hmr:module-registered': { - ${ssrImportMetaKey}.${ssrRolldownRuntimeTransport}?.send({ + ${ssrRolldownRuntimeKey}.${ssrRolldownRuntimeTransport}?.send({ type: 'custom', event: 'vite:module-loaded', // clone array as the runtime reuses the array instance @@ -79,12 +77,31 @@ export class FullBundleRunnableDevEnvironment extends FullBundleDevEnvironment { override async close(): Promise { await super.close() - if (this._runner) { - await this._runner.close() + const runner = this._runner.get() + if (runner) { + await runner.close() } } } +export type { FullBundleRunnableDevEnvironment } + +/** @experimental */ +export function createFullBundleRunnableDevEnvironment( + name: string, + config: ResolvedConfig, + context: RunnableDevEnvironmentContext = {}, +): FullBundleDevEnvironment { + if (context.transport == null) { + context.transport = createServerHotChannel() + } + if (context.hot == null) { + context.hot = true + } + + return new FullBundleRunnableDevEnvironment(name, config, context) +} + /** @experimental */ export function isFullBundleRunnableDevEnvironment( environment: unknown, diff --git a/packages/vite/src/node/server/environments/runnableEnvironment.ts b/packages/vite/src/node/server/environments/runnableEnvironment.ts index f75539e71c5c2b..819d1e2cbb3d05 100644 --- a/packages/vite/src/node/server/environments/runnableEnvironment.ts +++ b/packages/vite/src/node/server/environments/runnableEnvironment.ts @@ -2,8 +2,11 @@ import type { ModuleRunner } from 'vite/module-runner' import type { ResolvedConfig } from '../../config' import type { DevEnvironmentContext } from '../environment' import { DevEnvironment } from '../environment' -import type { ServerModuleRunnerOptions } from '../../ssr/runtime/serverModuleRunner' -import { createServerModuleRunner } from '../../ssr/runtime/serverModuleRunner' +import type { + ServerModuleRunnerFactory, + ServerModuleRunnerOptions, +} from '../../ssr/runtime/serverModuleRunner' +import { defineServerModuleRunnerFactory } from '../../ssr/runtime/serverModuleRunner' import { createServerHotChannel } from '../hmr' import type { Environment } from '../../environment' @@ -22,14 +25,10 @@ export function createRunnableDevEnvironment( return new RunnableDevEnvironment(name, config, context) } -export interface RunnableDevEnvironmentContext extends Omit< - DevEnvironmentContext, - 'hot' -> { - runner?: ( - environment: RunnableDevEnvironment, - options?: ServerModuleRunnerOptions, - ) => ModuleRunner +export interface RunnableDevEnvironmentContext< + E extends DevEnvironment = RunnableDevEnvironment, +> extends Omit { + runner?: (environment: E, options?: ServerModuleRunnerOptions) => ModuleRunner runnerOptions?: ServerModuleRunnerOptions hot?: boolean } @@ -41,14 +40,7 @@ export function isRunnableDevEnvironment( } class RunnableDevEnvironment extends DevEnvironment { - private _runner: ModuleRunner | undefined - private _runnerFactory: - | (( - environment: RunnableDevEnvironment, - options?: ServerModuleRunnerOptions, - ) => ModuleRunner) - | undefined - private _runnerOptions: ServerModuleRunnerOptions | undefined + private _runner: ServerModuleRunnerFactory constructor( name: string, @@ -56,23 +48,18 @@ class RunnableDevEnvironment extends DevEnvironment { context: RunnableDevEnvironmentContext, ) { super(name, config, context as DevEnvironmentContext) - this._runnerFactory = context.runner - this._runnerOptions = context.runnerOptions + this._runner = defineServerModuleRunnerFactory(this, context) } get runner(): ModuleRunner { - if (this._runner) { - return this._runner - } - const factory = this._runnerFactory || createServerModuleRunner - this._runner = factory(this, this._runnerOptions) - return this._runner + return this._runner.create() } override async close(): Promise { await super.close() - if (this._runner) { - await this._runner.close() + const runner = this._runner.get() + if (runner) { + await runner.close() } } } diff --git a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts index 71a3610f4b6c0e..ef39a1bcf165b8 100644 --- a/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts +++ b/packages/vite/src/node/ssr/runtime/serverModuleRunner.ts @@ -145,3 +145,33 @@ export function createServerModuleRunner( options.evaluator, ) } + +export interface ServerModuleRunnerFactoryOptions { + runner?: (environment: E, options?: ServerModuleRunnerOptions) => ModuleRunner + runnerOptions?: ServerModuleRunnerOptions +} + +export interface ServerModuleRunnerFactory { + create(): ModuleRunner + get(): ModuleRunner | undefined +} + +export function defineServerModuleRunnerFactory( + environment: E, + options: ServerModuleRunnerFactoryOptions, +): ServerModuleRunnerFactory { + let runner: ModuleRunner | undefined + return { + create() { + if (runner) { + return runner + } + const factory = options.runner || createServerModuleRunner + runner = factory(environment, options.runnerOptions) + return runner + }, + get() { + return runner + }, + } +} From 867f528946444dc5665f321edc6a702b7ea0cd59 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Wed, 25 Feb 2026 16:41:59 +0100 Subject: [PATCH 21/21] chore: cleanup --- .../runtime/__tests__/server-runtime.spec.ts | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts index 4e4c8eaa649b56..a32cabf059449e 100644 --- a/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts +++ b/packages/vite/src/node/ssr/runtime/__tests__/server-runtime.spec.ts @@ -187,17 +187,6 @@ describe.for([ } }) - it('dynamic imports in FBM', async ({ fullBundle, skip, runner }) => { - skip(!fullBundle.length, 'FBM') - - const mod = await runner.import('./fixtures/dynamic-import.js') - const modules = await mod.initialize(true) - - expect(modules.static.test).toBeTypeOf('string') - expect(modules.dynamicProcessed.test).toBeTypeOf('string') - expect(modules.dynamicProcessed.test).toBe(modules.static.test) - }) - it('correctly imports a virtual module', async ({ runner }) => { const mod = await runner.import('/fixtures/virtual.js') expect(mod.msg0).toBe('virtual0') @@ -440,6 +429,15 @@ describe('not supported by bundle mode', () => { }) }) + it('dynamic imports in FBM', async ({ runner }) => { + const mod = await runner.import('./fixtures/dynamic-import.js') + const modules = await mod.initialize(true) + + expect(modules.static.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBeTypeOf('string') + expect(modules.dynamicProcessed.test).toBe(modules.static.test) + }) + // files are virtual, so url is not defined it('correctly resolves module url', async ({ runner, server }) => { const { meta } = await runner.import('/fixtures/basic.js')