From c2173d404199da61dcaa66d9277f2ba9893d2c6c Mon Sep 17 00:00:00 2001 From: Hana Date: Thu, 24 Sep 2026 17:24:05 +0800 Subject: [PATCH 1/3] fix(bundled-dev): serve the rolldown runtime from the installed rolldown --- packages/vite/rolldown.config.ts | 3 +- packages/vite/src/node/constants.ts | 2 + .../vite/src/node/plugins/clientInjections.ts | 53 ++++++++++++++++++- packages/vite/src/node/server/bundledDev.ts | 44 +++++++++------ 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index 5fede87774cbc8..52a0f0526c60b9 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -44,7 +44,8 @@ const bundledDevClientConfig = defineConfig({ transform: { target: 'es2020', }, - external: ['@vite/env'], + // the runtime is served from the installed rolldown at dev time (`getRolldownDevRuntimeFiles`) + external: ['@vite/env', 'rolldown/experimental/runtime'], output: { dir: path.resolve(dirname, 'dist'), entryFileNames: 'client/bundledDevClient.mjs', diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index dd208e13d366b3..751deab5e64bcb 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -131,6 +131,8 @@ export const BUNDLED_DEV_CLIENT_ENTRY: string = resolve( ) /** URL filename the bundled-dev server serves the vite client under */ export const BUNDLED_DEV_CLIENT_FILENAME: string = 'bundledDevClient.mjs' +/** URL directory the bundled-dev server serves rolldown's dev runtime files under */ +export const BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR: string = '@rolldown' export const ENV_ENTRY: string = resolve( VITE_PACKAGE_DIR, 'dist/client/env.mjs', diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 782d28ad178f57..206cc1fccc523a 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -1,8 +1,14 @@ import fs from 'node:fs' +import { createRequire } from 'node:module' import path from 'node:path' import { cleanUrl } from '../../shared/utils' import type { ResolvedConfig } from '../config' -import { CLIENT_ENTRY, ENV_ENTRY, BUNDLED_DEV_CLIENT_ENTRY } from '../constants' +import { + BUNDLED_DEV_CLIENT_ENTRY, + BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR, + CLIENT_ENTRY, + ENV_ENTRY, +} from '../constants' import { perEnvironmentState } from '../environment' import type { Plugin } from '../plugin' import { isObject, normalizePath, resolveHostname } from '../utils' @@ -138,6 +144,10 @@ async function createClientConfigValueReplacer( .replace(`__SERVER_FORWARD_CONSOLE__`, serverForwardConsoleReplacement) } +const _require = createRequire(/** #__KEEP__ */ import.meta.url) + +const ROLLDOWN_DEV_RUNTIME_ENTRY = 'rolldown/experimental/runtime' + export async function getHmrImplementation( config: ResolvedConfig, ): Promise { @@ -145,7 +155,46 @@ export async function getHmrImplementation( const replacer = await createClientConfigValueReplacer(config) return ( replacer(content) - // the rolldown runtime cannot import a module + // `/@vite/env` is not served in bundled dev .replace(/import\s*['"]@vite\/env['"]/, '') + // absolute under `base`: a relative specifier would resolve against a sub-page's URL + .replace( + new RegExp(`(from\\s*['"])${ROLLDOWN_DEV_RUNTIME_ENTRY}(['"])`), + (_, before, after) => + `${before}${path.posix.join( + config.base, + BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR, + path.basename(rolldownDevRuntimeEntryPath()), + )}${after}`, + ) ) } + +function rolldownDevRuntimeEntryPath(): string { + return _require.resolve(ROLLDOWN_DEV_RUNTIME_ENTRY) +} + +/** + * The dev runtime must match the rolldown that generates the bundle, so it is read from the + * installed package at serve time instead of being bundled into the client. The entry imports + * its helper file with a relative path, so both are served under the same directory. + */ +export function getRolldownDevRuntimeFiles(): Map { + const entry = rolldownDevRuntimeEntryPath() + const dir = path.dirname(entry) + const files = new Map() + for (const name of fs.readdirSync(dir)) { + if (name.startsWith('experimental-runtime') && name.endsWith('.mjs')) { + files.set( + `${BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR}/${name}`, + fs.readFileSync(path.join(dir, name), 'utf-8'), + ) + } + } + if ( + !files.has(`${BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR}/${path.basename(entry)}`) + ) { + throw new Error(`rolldown dev runtime entry ${entry} was not found`) + } + return files +} diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 3519f188c1044f..e5415d62fadc5d 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -9,7 +9,10 @@ import { } from 'rolldown/experimental' import { ChunkMetadataMap, resolveRolldownOptions } from '../build' import { BUNDLED_DEV_CLIENT_FILENAME } from '../constants' -import { getHmrImplementation } from '../plugins/clientInjections' +import { + getHmrImplementation, + getRolldownDevRuntimeFiles, +} from '../plugins/clientInjections' import { createDebugger, formatAndTruncateFileList } from '../utils' import { convertToDevWatchOptions } from '../watch' import type { DevEnvironment } from './environment' @@ -60,7 +63,8 @@ export class MemoryFiles { export class BundledDev { private _devEngine!: DevEngine - private viteRuntime?: string + /** the vite client and the rolldown runtime; set before the first build so `hasBuildOutput` can count them */ + private staticFiles = new Map() private initialBuildCompleted = false private _closed = false private clients = new Clients() @@ -107,17 +111,14 @@ export class BundledDev { private pendingPayloadFilenames = new Set() get hasBuildOutput(): boolean { - return ( - this.memoryFiles.size > 1 || - (this.memoryFiles.size === 1 && - !this.memoryFiles.has(BUNDLED_DEV_CLIENT_FILENAME)) - ) + return this.memoryFiles.size > this.staticFiles.size } async listen(): Promise { this._closed = false debug?.('INITIAL: setup bundle options') const rolldownOptions = await this.getRolldownOptions() + await this.storeStaticFiles() // NOTE: only single outputOptions is supported here if ( Array.isArray(rolldownOptions.output) && @@ -262,10 +263,6 @@ export class BundledDev { debug?.('INITIAL: run error', e) }, ) - this.viteRuntime = await getHmrImplementation( - this.environment.getTopLevelConfig(), - ) - this.storeOutputFiles([]) this.waitForInitialBuildFinish().then(() => { if (this._closed) return debug?.('INITIAL: build done') @@ -373,14 +370,27 @@ export class BundledDev { this.initialBuildCompleted = false } + private async storeStaticFiles(): Promise { + const sources = new Map([ + [ + BUNDLED_DEV_CLIENT_FILENAME, + await getHmrImplementation(this.environment.getTopLevelConfig()), + ], + ...getRolldownDevRuntimeFiles(), + ]) + this.staticFiles.clear() + for (const [fileName, source] of sources) { + const file = { + source, + etag: getEtag(Buffer.from(source), { weak: true }), + } + this.staticFiles.set(fileName, file) + this.memoryFiles.set(fileName, file) + } + } + private storeOutputFiles(output: RolldownOutput['output'][number][]): void { // NOTE: don't clear memoryFiles here as incremental build reuses the files - if (this.viteRuntime) { - this.memoryFiles.set(BUNDLED_DEV_CLIENT_FILENAME, { - source: this.viteRuntime, - etag: getEtag(Buffer.from(this.viteRuntime), { weak: true }), - }) - } for (const outputFile of output) { this.memoryFiles.set(outputFile.fileName, () => { const source = From e8829abbf3e02012e13b1e8e4f194318a21e2889 Mon Sep 17 00:00:00 2001 From: Hana Date: Thu, 24 Sep 2026 17:49:01 +0800 Subject: [PATCH 2/3] test(bundled-dev): cover the served rolldown runtime --- .../plugins/clientInjections.spec.ts | 18 ++++++++++++++++++ .../__tests__/hmr-full-bundle-mode.spec.ts | 19 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts diff --git a/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts b/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts new file mode 100644 index 00000000000000..172d3cba589fdf --- /dev/null +++ b/packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts @@ -0,0 +1,18 @@ +import path from 'node:path' +import { expect, test } from 'vitest' +import { getRolldownDevRuntimeFiles } from '../../plugins/clientInjections' + +// The runtime entry imports its helper file with a relative path. Catch a rolldown layout +// change here, instead of as a 404 in the browser. +test('serves every file the rolldown dev runtime imports', () => { + const files = getRolldownDevRuntimeFiles() + let checked = 0 + for (const [urlPath, source] of files) { + for (const match of source.matchAll(/\bfrom\s*['"](\.\.?\/[^'"]+)['"]/g)) { + const imported = path.posix.join(path.posix.dirname(urlPath), match[1]) + expect(files.has(imported), `${urlPath} imports ${match[1]}`).toBe(true) + checked++ + } + } + expect(checked).toBeGreaterThan(0) +}) diff --git a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts index 27c09900dbd7fe..45dcaee5172327 100644 --- a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts +++ b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts @@ -36,6 +36,25 @@ if (isBuild) { await expect.poll(() => page.textContent('.worker-url')).toBe('worker-url') }) + // The runtime must be the installed rolldown's, served by the dev server + test('loads the rolldown runtime from the server', async () => { + const runtimeUrl = new URL( + '/@rolldown/experimental-runtime.mjs', + page.url(), + ) + const client = await page.request.get( + new URL('/bundledDevClient.mjs', page.url()).href, + ) + expect(await client.text()).toContain(`from "${runtimeUrl.pathname}"`) + const runtime = await page.request.get(runtimeUrl.href) + expect(runtime.status()).toBe(200) + expect(await runtime.text()).toContain('class DevRuntime') + const loaded: string[] = await page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => entry.name), + ) + expect(loaded).toContain(runtimeUrl.href) + }) + // BUNDLED -> GENERATE_HMR_PATCH -> BUNDLING -> BUNDLE_ERROR -> BUNDLING -> BUNDLED test('handle bundle error', async () => { editFile('main.js', (code) => From 75cb6c3785802639ae410dda72833bf2b176fa45 Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 25 Sep 2026 15:00:22 +0800 Subject: [PATCH 3/3] refactor(bundled-dev): resolve the rolldown runtime with import.meta.resolve --- packages/vite/src/node/plugins/clientInjections.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 206cc1fccc523a..e55e1e4a778052 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -1,6 +1,6 @@ import fs from 'node:fs' -import { createRequire } from 'node:module' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { cleanUrl } from '../../shared/utils' import type { ResolvedConfig } from '../config' import { @@ -144,8 +144,6 @@ async function createClientConfigValueReplacer( .replace(`__SERVER_FORWARD_CONSOLE__`, serverForwardConsoleReplacement) } -const _require = createRequire(/** #__KEEP__ */ import.meta.url) - const ROLLDOWN_DEV_RUNTIME_ENTRY = 'rolldown/experimental/runtime' export async function getHmrImplementation( @@ -171,7 +169,7 @@ export async function getHmrImplementation( } function rolldownDevRuntimeEntryPath(): string { - return _require.resolve(ROLLDOWN_DEV_RUNTIME_ENTRY) + return fileURLToPath(import.meta.resolve(ROLLDOWN_DEV_RUNTIME_ENTRY)) } /**