Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/vite/rolldown.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 18 additions & 0 deletions packages/vite/src/node/__tests__/plugins/clientInjections.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
2 changes: 2 additions & 0 deletions packages/vite/src/node/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
51 changes: 49 additions & 2 deletions packages/vite/src/node/plugins/clientInjections.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
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'
Expand Down Expand Up @@ -138,14 +144,55 @@ async function createClientConfigValueReplacer(
.replace(`__SERVER_FORWARD_CONSOLE__`, serverForwardConsoleReplacement)
}

const ROLLDOWN_DEV_RUNTIME_ENTRY = 'rolldown/experimental/runtime'

export async function getHmrImplementation(
config: ResolvedConfig,
): Promise<string> {
const content = fs.readFileSync(normalizedBundledDevClientEntry, 'utf-8')
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 fileURLToPath(import.meta.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<string, string> {
const entry = rolldownDevRuntimeEntryPath()
const dir = path.dirname(entry)
const files = new Map<string, string>()
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'),
)
}
}
Comment on lines +184 to +191

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non blocking] Relying on the file name feels difficult to ensure. Maybe we should be bundling to a single file. That would also reduce the number of requests needed.

@h-a-n-a h-a-n-a Sep 25, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed that we should bundle it in Rolldown. Based on that, Vite side should only be relying on a single runtime file exported by Rolldown. However, this should be released in minor Vite and minor Rolldown, otherwise it will again break the runtime, causing some runtime modules are not served and thus imported correctly, given the runtime was bundled at Vite build time.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, let's do it in a minor.

if (
!files.has(`${BUNDLED_DEV_ROLLDOWN_RUNTIME_DIR}/${path.basename(entry)}`)
) {
throw new Error(`rolldown dev runtime entry ${entry} was not found`)
}
return files
}
44 changes: 27 additions & 17 deletions packages/vite/src/node/server/bundledDev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, MemoryFile>()
private initialBuildCompleted = false
private _closed = false
private clients = new Clients()
Expand Down Expand Up @@ -107,17 +111,14 @@ export class BundledDev {
private pendingPayloadFilenames = new Set<string>()

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<void> {
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) &&
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -373,14 +370,27 @@ export class BundledDev {
this.initialBuildCompleted = false
}

private async storeStaticFiles(): Promise<void> {
const sources = new Map<string, string>([
[
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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading