diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts
index 6121cbe..348dbaa 100644
--- a/packages/vite-dev-server/rolldown.config.ts
+++ b/packages/vite-dev-server/rolldown.config.ts
@@ -426,6 +426,7 @@ const webWorkerBuilderConfig = defineConfig({
createEagerWorkerTransformerImportsPlugin(),
createHostViteHelperIsolationPlugin(),
createBuilderGlobalsGuardPlugin(),
+ createPreloadHelperGuardPlugin(),
...createTransformerPlugins({
copyAssets: false,
guardAggregate: 'node/web-worker-builder.js',
@@ -582,6 +583,8 @@ function createEagerWorkerTransformerImportsPlugin(): Plugin {
'./asset',
'./clientInjections',
'./define',
+ './modulePreloadPolyfill',
+ './assetImportMetaUrl',
'./forwardConsole',
]],
// buildEnvironment() loads rolldown lazily, while the static graph imports it as well
@@ -738,6 +741,54 @@ function createBuilderGlobalsGuardPlugin(): Plugin {
}
}
+function createPreloadHelperGuardPlugin(): Plugin {
+ // `getPreloadCode()` of the build inlines `preload()` into the output with `toString()`, with the
+ // helper that it calls. The host build of an app may minify the builder, which renames the helper,
+ // also where `preload()` calls it. So the preload code must declare the helper under its runtime
+ // name (`.name`), and `preload()` must not call other functions of the bundle.
+ return {
+ name: 'guard-preload-helper',
+ generateBundle(_options, bundle) {
+ const entry = Object.values(bundle).find(
+ output => output.type === 'chunk' && output.fileName === 'node/web-worker-builder.js'
+ )
+ if (!entry || entry.type !== 'chunk') {
+ this.error('[guard-preload-helper] Could not find node/web-worker-builder.js entry')
+ }
+ const { code } = entry
+ const helper = /const seen = \{\};const \$\{(\w+)\.name\} = \$\{\1\.toString\(\)\};/.exec(code)?.[1]
+ if (!helper) {
+ this.error('[guard-preload-helper] The preload code must declare isCssPreloadUrl under its runtime name')
+ }
+ const preload = /const preloadMethodCode = (\w+)\.toString\(\)/.exec(code)?.[1]
+ const start = preload ? code.indexOf(`\nfunction ${preload}(`) : -1
+ const end = start < 0 ? -1 : code.indexOf('\n}\n', start)
+ if (start < 0 || end < 0) {
+ this.error('[guard-preload-helper] Could not find preload()')
+ }
+ const source = code.slice(start, end)
+ const calls = new Set(
+ Array.from(source.matchAll(/(? match[1])
+ )
+ if (!calls.has(helper)) {
+ this.error(`[guard-preload-helper] preload() does not call ${helper}()`)
+ }
+ for (const name of calls) {
+ if (
+ name !== helper &&
+ name !== preload &&
+ code.includes(`\nfunction ${name}(`) &&
+ !source.includes(`function ${name}(`)
+ ) {
+ this.error(
+ `[guard-preload-helper] preload() calls ${name}(), which the preload code does not declare`
+ )
+ }
+ }
+ },
+ }
+}
+
function createRuntimeNodeEnvGuardPlugin(): Plugin {
// `resolveConfig()` decides `isProduction` from `process.env.NODE_ENV` at runtime, so that the dev
// Web Worker and the build Worker can share the code. Stop the build when the bundle fixes it.
diff --git a/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/index.html b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/index.html
new file mode 100644
index 0000000..e69de29
diff --git a/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg
new file mode 100644
index 0000000..dc1ced5
--- /dev/null
+++ b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/vite-dev-server/src/node/build.test.ts b/packages/vite-dev-server/src/node/build.test.ts
index 936c5c5..17fc787 100644
--- a/packages/vite-dev-server/src/node/build.test.ts
+++ b/packages/vite-dev-server/src/node/build.test.ts
@@ -1,4 +1,5 @@
import fs from 'node:fs'
+import fsp from 'node:fs/promises'
import os from 'node:os'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -6,6 +7,7 @@ import { stripVTControlCharacters } from 'node:util'
import colors from 'picocolors'
import type {
LogLevel,
+ OutputAsset,
OutputChunk,
OutputOptions,
RolldownOptions,
@@ -16,12 +18,18 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vite
// NOTE(kazupon): vite-dev-server loads rolldown from the browser build (`@vrowzer/rolldown`).
// The unit tests run in Node, so they build with the Node build of the same rolldown version.
+// Vitest gives the original module to the second of two concurrent dynamic imports of a mocked
+// module, so the tests run the builds one after another.
vi.mock('@vrowzer/rolldown', () => import('rolldown'))
vi.mock('@vrowzer/rolldown/experimental', () => import('rolldown/experimental'))
vi.mock('@vrowzer/rolldown/parseAst', () => import('rolldown/parseAst'))
vi.mock('@vrowzer/rolldown/utils', () => import('rolldown/utils'))
-import type { LibraryFormats, LibraryOptions } from './build'
+import type {
+ BuildEnvironmentOptions,
+ LibraryFormats,
+ LibraryOptions,
+} from './build'
import {
BuildEnvironment,
ChunkMetadataMap,
@@ -35,10 +43,10 @@ import {
import { resolveConfig } from './config'
import type { Logger } from './logger'
import { createLogger } from './logger'
+import { injectQuery } from './utils'
// Ported from upstream Vite (`packages/vite/src/node/__tests__/build.spec.ts`).
// NOTE(kazupon): not ported yet:
-// - the builds of HTML, CSS and assets (they need `buildHtmlPlugin` and the other build plugins)
// - the SSR builds, `sharedConfigBuild`, `chunkImportMap`, the watch mode and the manifest
// - `config.tsconfig` (Vite 8.3), which the resolved config does not have yet
@@ -56,6 +64,111 @@ afterAll(() => {
})
describe('build', () => {
+ test('file hash should change when css changes for dynamic entries', async () => {
+ const buildProject = async (cssColor: string) => {
+ return (await build({
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ build: {
+ write: false,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (
+ id === 'entry.js' ||
+ id === 'subentry.js' ||
+ id === 'foo.css'
+ ) {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `window.addEventListener('click', () => { import('subentry.js') });`
+ }
+ if (id === '\0subentry.js') {
+ return `import 'foo.css'`
+ }
+ if (id === '\0foo.css') {
+ return `.foo { color: ${cssColor} }`
+ }
+ },
+ },
+ ],
+ })) as RolldownOutput
+ }
+ // NOTE(kazupon): one after another, as the note of the mocks says
+ // const result = await Promise.all([
+ // buildProject('red'),
+ // buildProject('blue'),
+ // ])
+ const result = [await buildProject('red'), await buildProject('blue')]
+ expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(`
+ {
+ "changed": [
+ "index",
+ "_subentry.css",
+ ],
+ "unchanged": [
+ "undefined",
+ ],
+ }
+ `)
+ assertOutputHashContentChange(result[0], result[1])
+ })
+
+ test('file hash should change when renderBuiltUrl changes', async () => {
+ const createRenderBuiltUrl = (base: string) => (filename: string) =>
+ `${base}/${filename}`
+ const renderBuiltUrlA = createRenderBuiltUrl('/cdn-a')
+ const renderBuiltUrlB = createRenderBuiltUrl('/cdn-b')
+
+ expect(renderBuiltUrlA.toString()).toBe(renderBuiltUrlB.toString())
+
+ // NOTE(kazupon): one after another, as the note of the mocks says
+ // const result = await Promise.all([
+ // buildProjectWithRenderBuiltUrl(renderBuiltUrlA),
+ // buildProjectWithRenderBuiltUrl(renderBuiltUrlB),
+ // ])
+ const result = [await buildProjectWithRenderBuiltUrl(renderBuiltUrlA), await buildProjectWithRenderBuiltUrl(renderBuiltUrlB)]
+
+ expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(`
+ {
+ "changed": [
+ "index",
+ ],
+ "unchanged": [
+ "_subentry",
+ "asset.txt",
+ "undefined",
+ ],
+ }
+ `)
+ assertOutputHashContentChange(result[0], result[1])
+ })
+
+ test('renderBuiltUrl receives asset postfixes in JS and CSS', async () => {
+ const result = await buildProjectWithRenderBuiltUrl(
+ (filename) => injectQuery(filename, 'dpl=id'),
+ true,
+ )
+ const entry = result.output.find(
+ (output): output is OutputChunk =>
+ output.type === 'chunk' && output.isEntry,
+ )
+ const css = result.output.find(
+ (output): output is OutputAsset =>
+ output.type === 'asset' && output.fileName.endsWith('.css'),
+ )
+
+ expect(entry?.code).toContain('?dpl=id&marker=value')
+ expect(entry?.code).toContain('?dpl=id&marker=other')
+ expect(css?.source.toString()).toContain('?dpl=id&marker=value')
+ expect(css?.source.toString()).toContain('?dpl=id&marker=other')
+ })
+
test('top-level input is used as the default build entry', async () => {
const result = (await build({
root: resolve(dirname, 'packages/build-project'),
@@ -115,6 +228,134 @@ describe('build', () => {
expect(chunk?.code).toContain('from-virtual-top-level-input')
})
+ test('file hash should change when pure css chunk changes', async () => {
+ const buildProject = async (cssColor: string) => {
+ return (await build({
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ build: {
+ write: false,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (
+ id === 'entry.js' ||
+ id === 'foo.js' ||
+ id === 'bar.js' ||
+ id === 'baz.js' ||
+ id === 'foo.css' ||
+ id === 'bar.css' ||
+ id === 'baz.css'
+ ) {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `
+ window.addEventListener('click', () => { import('foo.js') });
+ window.addEventListener('click', () => { import('bar.js') });`
+ }
+ if (id === '\0foo.js') {return `import 'foo.css'; import 'baz.js'`}
+ if (id === '\0bar.js') {return `import 'bar.css'; import 'baz.js'`}
+ if (id === '\0baz.js') {return `import 'baz.css'`}
+ if (id === '\0foo.css') {return `.foo { color: red }`}
+ if (id === '\0bar.css') {return `.foo { color: green }`}
+ if (id === '\0baz.css') {return `.foo { color: ${cssColor} }`}
+ },
+ },
+ ],
+ })) as RolldownOutput
+ }
+ // NOTE(kazupon): one after another, as the note of the mocks says
+ // const result = await Promise.all([
+ // buildProject('yellow'),
+ // buildProject('blue'),
+ // ])
+ const result = [await buildProject('yellow'), await buildProject('blue')]
+ expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(`
+ {
+ "changed": [
+ "index",
+ "_bar",
+ "_foo",
+ "_baz.css",
+ ],
+ "unchanged": [
+ "_bar.css",
+ "_foo.css",
+ "undefined",
+ ],
+ }
+ `)
+ assertOutputHashContentChange(result[0], result[1])
+ })
+
+ test.for([
+ [true, true],
+ [true, false],
+ [false, true],
+ [false, false],
+ ['auto', true],
+ ['auto', false],
+ ] as const)(
+ 'large json object files should have tree-shaking (json.stringify: %s, json.namedExports: %s)',
+ async ([stringify, namedExports]) => {
+ const esBundle = (await build({
+ mode: 'development',
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ json: { stringify, namedExports },
+ build: {
+ minify: false,
+ modulePreload: { polyfill: false },
+ write: false,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (
+ id === 'entry.js' ||
+ id === 'object.json' ||
+ id === 'array.json'
+ ) {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `
+ import object from 'object.json';
+ import array from 'array.json';
+ console.log();
+ `
+ }
+ if (id === '\0object.json') {
+ return `
+ {"value": {"${stringify}_${namedExports}":"JSON_OBJ${'_'.repeat(10_000)}"}}
+ `
+ }
+ if (id === '\0array.json') {
+ return `
+ ["${stringify}_${namedExports}","JSON_ARR${'_'.repeat(10_000)}"]
+ `
+ }
+ },
+ },
+ ],
+ })) as RolldownOutput
+
+ const foo = esBundle.output.find(
+ (chunk) => chunk.type === 'chunk' && chunk.isEntry,
+ ) as OutputChunk
+ expect(foo.code).not.contains('JSON_ARR')
+ expect(foo.code).not.contains('JSON_OBJ')
+ },
+ )
+
test('external modules should not be hoisted in library build', async () => {
const [esBundle] = (await build({
logLevel: 'silent',
@@ -757,9 +998,6 @@ describe('onRollupLog', () => {
rolldownOptions: {
...options,
logLevel: 'debug',
- // NOTE(kazupon): build the JS entry directly, because the HTML entry (`index.html`) needs
- // `buildHtmlPlugin`, which is not ported yet
- input: 'entry.js',
},
},
customLogger: logger,
@@ -895,6 +1133,29 @@ describe('onRollupLog', () => {
})
// vrowzer: builds resolve packages with the native resolve plugin (`viteResolvePlugin`), as upstream does
+test('copies public directory after building same environment with write false first', async (ctx) => {
+ const root = resolve(dirname, 'fixtures/public-dir-write-false')
+ ctx.onTestFinished(() =>
+ fsp.rm(resolve(root, 'dist'), { recursive: true, force: true }),
+ )
+
+ const builder = await createBuilder({
+ root,
+ configFile: false,
+ logLevel: 'silent',
+ })
+
+ builder.environments.client.config.build.write = false
+ await builder.build(builder.environments.client)
+
+ builder.environments.client.config.build.write = true
+ await builder.build(builder.environments.client)
+
+ await expect(
+ fsp.readFile(resolve(root, 'dist/favicon.svg'), 'utf-8'),
+ ).resolves.toBe('')
+})
+
describe('package resolution in library builds', () => {
let root: string
@@ -967,3 +1228,288 @@ describe('package resolution in library builds', () => {
expect(chunk.code).not.toMatch(/exports-node|exports-default|browser-main|module-main/)
})
})
+
+describe('new URL(..., import.meta.url) in builds', () => {
+ let root: string
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true })
+ })
+
+ test('emits the files that the URLs point to', async () => {
+ // NOTE(kazupon): the temporary directory of macOS is behind a symbolic link (`/var` to
+ // `/private/var`). Resolve it, so that the HTML entry is inside the root.
+ root = fs.realpathSync(fs.mkdtempSync(join(os.tmpdir(), 'vrowzer-build-url-')))
+ const image = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4])
+ const files: Record = {
+ 'index.html': ``,
+ 'main.js': [
+ `export const image = new URL('./image.png', import.meta.url).href`,
+ `export const publicFile = new URL('/robots.txt', import.meta.url).href`,
+ `export const missing = new URL('./missing.png', import.meta.url).href`,
+ ].join('\n'),
+ 'image.png': image,
+ 'public/robots.txt': 'User-agent: *',
+ }
+ for (const [file, content] of Object.entries(files)) {
+ const filePath = join(root, file)
+ fs.mkdirSync(resolve(filePath, '..'), { recursive: true })
+ fs.writeFileSync(filePath, content)
+ }
+
+ const { output } = (await build({
+ root,
+ logLevel: 'silent',
+ build: {
+ write: false,
+ minify: false,
+ assetsInlineLimit: 0,
+ },
+ })) as RolldownOutput
+
+ const entry = output.find(
+ (o): o is OutputChunk => o.type === 'chunk' && o.isEntry,
+ )!
+ const asset = output.find(
+ (o): o is OutputAsset => o.type === 'asset' && o.fileName.endsWith('.png'),
+ )!
+ expect(asset.fileName).toMatch(/^assets\/image-[\w-]+\.png$/)
+ expect([...(asset.source as Uint8Array)]).toEqual([...image])
+ expect(entry.code).toContain(
+ `new URL("/${asset.fileName}", "" + import.meta.url)`,
+ )
+ expect(entry.code).toContain(`new URL("/robots.txt", "" + import.meta.url)`)
+ // a file that does not exist is left for the runtime
+ expect(entry.code).toContain(`new URL("./missing.png", "" + import.meta.url)`)
+ })
+})
+
+describe('HTML entries', () => {
+ const buildHtmlProject = async (
+ modulePreload?: BuildEnvironmentOptions['modulePreload'],
+ ) =>
+ (await build({
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ build: {
+ write: false,
+ minify: false,
+ modulePreload,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (id === 'entry.js' || id === 'style.css') {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `import 'style.css'\nconsole.log('from-html-entry')`
+ }
+ if (id === '\0style.css') {
+ return `h1 { color: red }`
+ }
+ },
+ },
+ ],
+ })) as RolldownOutput
+
+ const findOutputs = (output: RolldownOutput['output']) => ({
+ html: output.find(
+ (o): o is OutputAsset => o.type === 'asset' && o.fileName === 'index.html',
+ ),
+ entry: output.find(
+ (o): o is OutputChunk => o.type === 'chunk' && o.isEntry,
+ ),
+ css: output.find(
+ (o): o is OutputAsset => o.type === 'asset' && o.fileName.endsWith('.css'),
+ ),
+ })
+
+ test('rewrites the scripts and the styles of the HTML, and injects the module preload polyfill', async () => {
+ const { html, entry, css } = findOutputs((await buildHtmlProject()).output)
+
+ expect(html?.source).toContain(
+ ``,
+ )
+ expect(html?.source).toContain(
+ ``,
+ )
+ expect(html?.source).toContain('Hello world
')
+ expect(html?.source).not.toContain('src="entry.js"')
+ expect(entry?.code).toContain('from-html-entry')
+ expect(entry?.code).toContain('relList.supports("modulepreload")')
+ expect(css?.source).toMatch(/color:\s*red/)
+ })
+
+ test('preloads the CSS and the imports of the lazy chunks', async () => {
+ const { output } = (await build({
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ build: {
+ write: false,
+ minify: false,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (
+ id === 'entry.js' ||
+ id === 'lazy.js' ||
+ id === 'other.js' ||
+ id === 'shared.js' ||
+ id === 'lazy.css'
+ ) {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `window.addEventListener('click', () => { import('lazy.js'); import('other.js') })`
+ }
+ if (id === '\0lazy.js') {
+ return `import { shared } from 'shared.js'\nimport 'lazy.css'\nexport default shared + 'lazy'`
+ }
+ if (id === '\0other.js') {
+ return `import { shared } from 'shared.js'\nexport default shared + 'other'`
+ }
+ if (id === '\0shared.js') {
+ return `export const shared = 'shared'`
+ }
+ if (id === '\0lazy.css') {
+ return `.lazy { color: red }`
+ }
+ },
+ },
+ ],
+ })) as RolldownOutput
+
+ const fileNameOf = (name: string) =>
+ output.find((o) => o.type === 'chunk' && o.name === name)!.fileName
+ const entry = output.find(
+ (o): o is OutputChunk => o.type === 'chunk' && o.isEntry,
+ )!
+ const css = output.find(
+ (o) => o.type === 'asset' && o.fileName.endsWith('.css'),
+ )!
+ const deps = JSON.parse(/m\.f=(\[[^\]]*\])/.exec(entry.code)![1]) as string[]
+ const preloads = Array.from(
+ entry.code.matchAll(
+ /__vitePreload\(\(\) => import\("\.\/([^"]+)"\), __vite__mapDeps\(\[([\d,]+)\]\)\)/g,
+ ),
+ ([, file, indexes]) => [file, indexes.split(',').map((i) => deps[Number(i)])],
+ )
+ expect(Object.fromEntries(preloads)).toEqual({
+ [basename(fileNameOf('_lazy'))]: [
+ fileNameOf('_lazy'),
+ fileNameOf('_shared'),
+ css.fileName,
+ ],
+ [basename(fileNameOf('_other'))]: [
+ fileNameOf('_other'),
+ fileNameOf('_shared'),
+ ],
+ })
+ // the preload helper comes with the function that it calls
+ expect(entry.code).toMatch(/\bisCssPreloadUrl = function isCssPreloadUrl\(/)
+ })
+
+ test('does not inject the module preload polyfill when it is disabled', async () => {
+ const { entry } = findOutputs(
+ (await buildHtmlProject({ polyfill: false })).output,
+ )
+
+ expect(entry?.code).toContain('from-html-entry')
+ expect(entry?.code).not.toContain('modulepreload')
+ })
+})
+
+async function buildProjectWithRenderBuiltUrl(
+ renderBuiltUrl: (filename: string) => string,
+ includePostfixes = false,
+) {
+ return (await build({
+ root: resolve(dirname, 'packages/build-project'),
+ logLevel: 'silent',
+ build: {
+ write: false,
+ assetsInlineLimit: 0,
+ },
+ experimental: {
+ renderBuiltUrl,
+ },
+ plugins: [
+ {
+ name: 'test',
+ resolveId(id) {
+ if (id === 'entry.js' || id === 'subentry.js' || id === 'style.css') {
+ return '\0' + id
+ }
+ },
+ load(id) {
+ if (id === '\0entry.js') {
+ return `
+ import assetUrl from '/asset.txt?url${includePostfixes ? '&marker=value' : ''}'
+ ${includePostfixes ? `import otherAssetUrl from '/asset.txt?url&marker=other'` : ''}
+ ${includePostfixes ? `import 'style.css'` : ''}
+ console.log(assetUrl${includePostfixes ? `, otherAssetUrl` : ''})
+ window.addEventListener('click', () => { import('subentry.js') })
+ `
+ }
+ if (id === '\0subentry.js') {
+ return `export default 'subentry'`
+ }
+ if (id === '\0style.css') {
+ return `
+ .asset-a { background: url('/asset.txt?marker=value') }
+ .asset-b { background: url('/asset.txt?marker=other') }
+ `
+ }
+ },
+ },
+ ],
+ })) as RolldownOutput
+}
+
+/**
+ * for each chunks in output1, if there's a chunk in output2 with the same fileName,
+ * ensure that the chunk code is the same. if not, the chunk hash should have changed.
+ */
+function assertOutputHashContentChange(
+ output1: RolldownOutput,
+ output2: RolldownOutput,
+) {
+ for (const chunk of output1.output) {
+ if (chunk.type === 'chunk') {
+ const chunk2 = output2.output.find(
+ (c) => c.type === 'chunk' && c.fileName === chunk.fileName,
+ ) as OutputChunk | undefined
+ if (chunk2) {
+ expect(
+ chunk.code,
+ `the ${chunk.fileName} chunk has the same hash but different contents between builds`,
+ ).toEqual(chunk2.code)
+ }
+ }
+ }
+}
+
+function getOutputHashChanges(
+ output1: RolldownOutput,
+ output2: RolldownOutput,
+) {
+ const map1 = Object.fromEntries(
+ output1.output.map((o) => [o.name, o.fileName]),
+ )
+ const map2 = Object.fromEntries(
+ output2.output.map((o) => [o.name, o.fileName]),
+ )
+ const names = Object.keys(map1).filter(Boolean)
+ return {
+ changed: names.filter((name) => map1[name] !== map2[name]),
+ unchanged: names.filter((name) => map1[name] === map2[name]),
+ }
+}
diff --git a/packages/vite-dev-server/src/node/build.ts b/packages/vite-dev-server/src/node/build.ts
index 396d8e2..3b3fcd8 100644
--- a/packages/vite-dev-server/src/node/build.ts
+++ b/packages/vite-dev-server/src/node/build.ts
@@ -60,8 +60,7 @@ import type { MinimalPluginContextWithoutEnvironment, Plugin } from './plugin'
import { getHookHandler } from './plugins'
// NOTE(kazupon): comment out because the browser build does not support the esbuild minifier
// import { buildEsbuildPlugin } from './plugins/esbuild'
-// NOTE(kazupon): comment out because the preload helpers are not ported yet
-// import { buildImportAnalysisPlugin } from './plugins/importAnalysisBuild'
+import { buildImportAnalysisPlugin } from './plugins/importAnalysisBuild'
// NOTE(kazupon): import only the types, because the browser build does not support license files and terser yet
import type { LicenseOptions } from './plugins/license'
// import { type LicenseOptions, licensePlugin } from './plugins/license'
@@ -541,9 +540,7 @@ export function resolveBuildPlugins(config: ResolvedConfig): {
// ...(config.isWorker ? [webWorkerPostPlugin(config)] : []),
],
post: [
- // NOTE(kazupon): comment out because the preload helpers of `vite:build-import-analysis` are not ported yet.
- // Library builds do not use them.
- // ...(isBuild ? buildImportAnalysisPlugin(config) : []),
+ ...(isBuild ? buildImportAnalysisPlugin(config) : []),
// NOTE(kazupon): comment out because the browser build does not support the esbuild and terser minifiers
// ...(isBuild && config.build.minify === 'esbuild'
// ? [buildEsbuildPlugin()]
diff --git a/packages/vite-dev-server/src/node/builderUtils.test.ts b/packages/vite-dev-server/src/node/builderUtils.test.ts
index 1220e58..8469cd6 100644
--- a/packages/vite-dev-server/src/node/builderUtils.test.ts
+++ b/packages/vite-dev-server/src/node/builderUtils.test.ts
@@ -31,6 +31,7 @@ function buildOptions(options: Partial = {}): ResolvedBuil
manifest: false,
ssrManifest: false,
license: false,
+ rolldownOptions: {},
...options,
} as ResolvedBuildOptions
}
@@ -54,13 +55,62 @@ describe('validateBuildOptions', () => {
expect(validateBuildOptions(buildOptions({ minify: false }))).toEqual([])
})
- test('rejects HTML app builds', () => {
- expect(validateBuildOptions(buildOptions({ lib: false }))).toEqual([
+ test('accepts an app build with one HTML entry', () => {
+ expect(validateBuildOptions(buildOptions({ lib: false }))).toEqual([])
+ expect(
+ validateBuildOptions(
+ buildOptions({ lib: false, rolldownOptions: { input: '/nested/index.html' } }),
+ ),
+ ).toEqual([])
+ expect(validateBuildOptions(buildOptions({ lib: false }), '/app.htm')).toEqual([])
+ })
+
+ test.each([
+ [['/a.html', '/b.html']],
+ [{ a: '/a.html', b: '/b.html' }],
+ ])('rejects multiple entries of an app (%o)', (input) => {
+ expect(
+ validateBuildOptions(buildOptions({ lib: false, rolldownOptions: { input } })),
+ ).toEqual([
+ expect.objectContaining({
+ code: UNSUPPORTED_OPTION,
+ message: expect.stringContaining(
+ 'build.rolldownOptions.input: only a single HTML entry is supported',
+ ),
+ }),
+ ])
+ expect(validateBuildOptions(buildOptions({ lib: false }), input)).toEqual([
+ expect.objectContaining({
+ message: expect.stringContaining('input: only a single HTML entry is supported'),
+ }),
+ ])
+ })
+
+ test('rejects an app entry that is not HTML', () => {
+ expect(
+ validateBuildOptions(
+ buildOptions({ lib: false, rolldownOptions: { input: '/src/main.ts' } }),
+ ),
+ ).toEqual([
expect.objectContaining({
code: UNSUPPORTED_OPTION,
- message: expect.stringContaining('build.lib: HTML app builds are not supported yet'),
+ message: expect.stringContaining(
+ 'build.rolldownOptions.input: the entry of an app must be an HTML file, but got "/src/main.ts"',
+ ),
}),
])
+ expect(
+ validateBuildOptions(buildOptions({ lib: false }), '/src/main.ts')[0].message,
+ ).toContain('[vrowzer] input: the entry of an app must be an HTML file')
+ })
+
+ test('takes build.rolldownOptions.input over the top-level input', () => {
+ expect(
+ validateBuildOptions(
+ buildOptions({ lib: false, rolldownOptions: { input: '/index.html' } }),
+ ['/a.html', '/b.html'],
+ ),
+ ).toEqual([])
})
test('rejects library formats other than es', () => {
@@ -160,14 +210,19 @@ describe('createBuildOptionsPlugin', () => {
const plugin = createBuildOptionsPlugin(state)
const configResolved = plugin.configResolved as (config: ResolvedConfig) => void
const supported = { build: buildOptions() } as ResolvedConfig
- const unsupported = { build: buildOptions({ lib: false }) } as ResolvedConfig
+ const unsupported = {
+ build: buildOptions({ lib: false }),
+ input: ['/a.html', '/b.html'],
+ } as ResolvedConfig
configResolved(supported)
expect(state.config).toBe(supported)
const error = catchError(() => configResolved(unsupported))
expect(error).toBeInstanceOf(BuildProjectError)
- expect((error as BuildProjectError).message).toContain('build.lib')
+ expect((error as BuildProjectError).message).toContain(
+ 'input: only a single HTML entry is supported',
+ )
})
})
diff --git a/packages/vite-dev-server/src/node/builderUtils.ts b/packages/vite-dev-server/src/node/builderUtils.ts
index 0fef669..eafce6d 100644
--- a/packages/vite-dev-server/src/node/builderUtils.ts
+++ b/packages/vite-dev-server/src/node/builderUtils.ts
@@ -13,7 +13,7 @@
*/
import path from 'node:path'
-import type { RolldownOutput, RolldownWatcher } from 'rolldown'
+import type { InputOption, RolldownOutput, RolldownWatcher } from 'rolldown'
import { withTrailingSlash } from '../shared/utils'
import type { ResolvedBuildOptions } from './build'
import type { ResolvedConfig } from './config'
@@ -68,6 +68,9 @@ export function stripAnsi(text: string): string {
return text.replace(ansiPattern, '')
}
+// The same as the HTML requests of the HTML plugin, which this module does not import
+const htmlEntryRE = /\.(?:html|htm)$/
+
function unsupported(option: string, message: string): BuildProjectLog {
return { code: UNSUPPORTED_OPTION, message: `[vrowzer] ${option}: ${message}` }
}
@@ -76,14 +79,29 @@ function unsupported(option: string, message: string): BuildProjectLog {
* Check the resolved build options against what the browser build supports.
*
* @param options - The resolved build options
+ * @param input - The top-level `input` of the resolved config
* @returns The problems. An empty array when the options are supported.
*/
-export function validateBuildOptions(options: ResolvedBuildOptions): BuildProjectLog[] {
+export function validateBuildOptions(
+ options: ResolvedBuildOptions,
+ input?: InputOption
+): BuildProjectLog[] {
const problems: BuildProjectLog[] = []
if (!options.lib) {
- problems.push(
- unsupported('build.lib', 'HTML app builds are not supported yet. Set build.lib to build a library.')
- )
+ // An app builds one HTML entry. Like `resolveRolldownOptions()`, take `build.rolldownOptions.input`,
+ // then the top-level `input`, and `/index.html` without them.
+ const option = options.rolldownOptions.input ? 'build.rolldownOptions.input' : 'input'
+ const entry = options.rolldownOptions.input || input
+ if (entry !== undefined && typeof entry !== 'string') {
+ problems.push(unsupported(option, 'only a single HTML entry is supported.'))
+ } else if (entry !== undefined && !htmlEntryRE.test(entry)) {
+ problems.push(
+ unsupported(
+ option,
+ `the entry of an app must be an HTML file, but got "${entry}". Set build.lib to build a library from it.`
+ )
+ )
+ }
} else {
const formats = options.lib.formats ?? ['es']
const others = formats.filter(format => format !== 'es')
@@ -152,7 +170,7 @@ export function createBuildOptionsPlugin(state: { config?: ResolvedConfig }): Pl
},
configResolved(config) {
state.config = config
- const problems = validateBuildOptions(config.build)
+ const problems = validateBuildOptions(config.build, config.input)
if (problems.length > 0) {
throw new BuildProjectError(problems)
}
diff --git a/packages/vite-dev-server/src/node/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts
index a54cacf..b6712dc 100644
--- a/packages/vite-dev-server/src/node/config.test.ts
+++ b/packages/vite-dev-server/src/node/config.test.ts
@@ -10,6 +10,9 @@ vi.mock('@vrowzer/rolldown', () => ({
}))
vi.mock('@vrowzer/rolldown/experimental', () => ({
+ viteBuildImportAnalysisPlugin: vi.fn<() => { name: string }>(() => ({
+ name: 'builtin:vite-build-import-analysis',
+ })),
viteJsonPlugin: vi.fn<() => { name: string }>(() => ({
name: 'vite:json',
})),
@@ -19,6 +22,9 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({
viteLoadFallbackPlugin: vi.fn<() => { name: string }>(() => ({
name: 'builtin:vite-load-fallback',
})),
+ viteModulePreloadPolyfillPlugin: vi.fn<() => { name: string }>(() => ({
+ name: 'builtin:vite-module-preload-polyfill',
+ })),
viteReporterPlugin: vi.fn<() => { name: string }>(() => ({
name: 'builtin:vite-reporter',
})),
@@ -630,13 +636,38 @@ describe('definePlugin JavaScript pre-check', () => {
expect(config.plugins.map(plugin => plugin.name)).toEqual(
expect.arrayContaining([
'vite:define',
+ 'vite:modulepreload-polyfill',
+ 'vite:build-html',
+ 'vite:asset-import-meta-url',
'vite:prepare-out-dir',
'vite:rollup-options-plugins',
+ 'vite:build-import-analysis',
+ 'native:import-analysis-build',
'native:reporter',
'builtin:vite-load-fallback',
]),
)
})
+
+ test('keeps the build-only plugins out of the dev pipeline', async () => {
+ const config = await resolveConfig(createInlineConfig(), 'serve')
+
+ const names = config.plugins.map(plugin => plugin.name)
+ expect(names).not.toContain('vite:build-html')
+ expect(names).not.toContain('vite:modulepreload-polyfill')
+ expect(names).not.toContain('vite:asset-import-meta-url')
+ })
+
+ test('does not register the module preload polyfill when it is disabled', async () => {
+ const config = await resolveConfig(
+ createInlineConfig({ build: { modulePreload: { polyfill: false } } }),
+ 'build',
+ )
+
+ const names = config.plugins.map(plugin => plugin.name)
+ expect(names).toContain('vite:build-html')
+ expect(names).not.toContain('vite:modulepreload-polyfill')
+ })
})
describe('resolveConfig applyToEnvironment warnings', () => {
diff --git a/packages/vite-dev-server/src/node/plugins/asset.test.ts b/packages/vite-dev-server/src/node/plugins/asset.test.ts
index 3f694f3..3c26a62 100644
--- a/packages/vite-dev-server/src/node/plugins/asset.test.ts
+++ b/packages/vite-dev-server/src/node/plugins/asset.test.ts
@@ -9,9 +9,11 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vite-plus/test'
vi.mock('@vrowzer/rolldown', () => ({ rolldown: vi.fn<(...args: unknown[]) => unknown>() }))
vi.mock('@vrowzer/rolldown/experimental', () => ({
+ viteBuildImportAnalysisPlugin: () => ({ name: 'builtin:vite-build-import-analysis' }),
viteJsonPlugin: () => ({ name: 'vite:json' }),
viteTransformPlugin: () => ({ name: 'native:transform' }),
viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }),
+ viteModulePreloadPolyfillPlugin: () => ({ name: 'builtin:vite-module-preload-polyfill' }),
viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }),
viteResolvePlugin: () => ({ name: 'builtin:vite-resolve' }),
}))
diff --git a/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.test.ts b/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.test.ts
new file mode 100644
index 0000000..d9566e4
--- /dev/null
+++ b/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.test.ts
@@ -0,0 +1,85 @@
+import { parseAst } from 'rolldown/parseAst'
+import { afterAll, describe, expect, test, vi } from 'vite-plus/test'
+
+// NOTE(kazupon): assetImportMetaUrl.ts imports the build code, which loads rolldown from the browser
+// build (`@vrowzer/rolldown`). The unit tests run in Node, so they use the Node build of the same
+// rolldown version.
+vi.mock('@vrowzer/rolldown', () => import('rolldown'))
+vi.mock('@vrowzer/rolldown/experimental', () => import('rolldown/experimental'))
+vi.mock('@vrowzer/rolldown/parseAst', () => import('rolldown/parseAst'))
+vi.mock('@vrowzer/rolldown/utils', () => import('rolldown/utils'))
+
+import { PartialEnvironment } from '../baseEnvironment'
+import { resolveConfig } from '../config'
+import { assetImportMetaUrlPlugin } from './assetImportMetaUrl'
+
+// Ported from upstream Vite (`packages/vite/src/node/__tests__/plugins/assetImportMetaUrl.spec.ts`).
+// NOTE(kazupon): `parseAst` comes from rolldown, as the fork does not depend on rollup
+
+// NOTE(kazupon): the `describe` below resolves the config while the tests are collected, before
+// `beforeAll()`, so stub the flag here
+vi.stubGlobal('__VROWZER_SERVICE_WORKER__', false)
+
+afterAll(() => {
+ vi.unstubAllGlobals()
+})
+
+async function createAssetImportMetaurlPluginTransform() {
+ const config = await resolveConfig({ configFile: false }, 'serve')
+ const instance = assetImportMetaUrlPlugin(config)
+ const environment = new PartialEnvironment('client', config)
+
+ return async (code: string) => {
+ // @ts-expect-error transform.handler should exist
+ const result = await instance.transform.handler.call(
+ { environment, parse: parseAst },
+ code,
+ 'foo.ts',
+ )
+ return result?.code || result
+ }
+}
+
+describe('assetImportMetaUrlPlugin', async () => {
+ const transform = await createAssetImportMetaurlPluginTransform()
+
+ test('variable between /', async () => {
+ expect(
+ await transform('new URL(`./foo/${dir}/index.js`, import.meta.url)'),
+ ).toMatchInlineSnapshot(
+ `"new URL((import.meta.glob("./foo/*/index.js", {"eager":true,"import":"default","query":"?url"}))[\`./foo/\${dir}/index.js\`], import.meta.url)"`,
+ )
+ })
+
+ test('variable before non-/', async () => {
+ expect(
+ await transform('new URL(`./foo/${dir}.js`, import.meta.url)'),
+ ).toMatchInlineSnapshot(
+ `"new URL((import.meta.glob("./foo/*.js", {"eager":true,"import":"default","query":"?url"}))[\`./foo/\${dir}.js\`], import.meta.url)"`,
+ )
+ })
+
+ test('two variables', async () => {
+ expect(
+ await transform('new URL(`./foo/${dir}${file}.js`, import.meta.url)'),
+ ).toMatchInlineSnapshot(
+ `"new URL((import.meta.glob("./foo/*.js", {"eager":true,"import":"default","query":"?url"}))[\`./foo/\${dir}\${file}.js\`], import.meta.url)"`,
+ )
+ })
+
+ test('two variables between /', async () => {
+ expect(
+ await transform(
+ 'new URL(`./foo/${dir}${dir2}/index.js`, import.meta.url)',
+ ),
+ ).toMatchInlineSnapshot(
+ `"new URL((import.meta.glob("./foo/*/index.js", {"eager":true,"import":"default","query":"?url"}))[\`./foo/\${dir}\${dir2}/index.js\`], import.meta.url)"`,
+ )
+ })
+
+ test('ignore starting with a variable', async () => {
+ expect(
+ await transform('new URL(`${file}.js`, import.meta.url)'),
+ ).toMatchInlineSnapshot(`"new URL(\`\${file}.js\`, import.meta.url)"`)
+ })
+})
diff --git a/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.ts b/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.ts
new file mode 100644
index 0000000..384cfc4
--- /dev/null
+++ b/packages/vite-dev-server/src/node/plugins/assetImportMetaUrl.ts
@@ -0,0 +1,212 @@
+import path from 'node:path'
+import MagicString from 'magic-string'
+import { exactRegex } from 'rolldown/filter'
+import { stripLiteral } from 'strip-literal'
+import { slash } from '../../shared/utils'
+import type { ResolvedConfig } from '../config'
+import { CLIENT_ENTRY } from '../constants'
+import { createBackCompatIdResolver } from '../idResolver'
+import type { ResolveIdFn } from '../idResolver'
+import type { Plugin } from '../plugin'
+import {
+ injectQuery,
+ isDataUrl,
+ isParentDirectory,
+ transformStableResult,
+ tryStatSync,
+} from '../utils'
+import { fileToUrl } from './asset'
+import { hasViteIgnoreRE } from './importAnalysis'
+import { preloadHelperId } from './importAnalysisBuild'
+import type { InternalResolveOptions } from './resolve'
+import { tryFsResolve } from './resolve'
+
+/**
+ * Convert `new URL('./foo.png', import.meta.url)` to its resolved built URL
+ *
+ * Supports template string with dynamic segments:
+ * ```
+ * new URL(`./dir/${name}.png`, import.meta.url)
+ * // transformed to
+ * import.meta.glob('./dir/**.png', { eager: true, import: 'default' })[`./dir/${name}.png`]
+ * ```
+ */
+export const assetImportMetaUrlRE: RegExp =
+ /\bnew\s+URL\s*\(\s*('[^']+'|"[^"]+"|`[^`]+`)\s*,\s*import\.meta\.url\s*(?:,\s*)?\)/dg
+
+export function assetImportMetaUrlPlugin(config: ResolvedConfig): Plugin {
+ const { publicDir } = config
+ let assetResolver: ResolveIdFn
+
+ const fsResolveOptions: InternalResolveOptions = {
+ ...config.resolve,
+ root: config.root,
+ isProduction: config.isProduction,
+ isBuild: config.command === 'build',
+ packageCache: config.packageCache,
+ asSrc: true,
+ }
+
+ return {
+ name: 'vite:asset-import-meta-url',
+
+ applyToEnvironment(environment) {
+ return environment.config.consumer === 'client'
+ },
+
+ transform: {
+ filter: {
+ id: {
+ exclude: [exactRegex(preloadHelperId), exactRegex(CLIENT_ENTRY)],
+ },
+ code: assetImportMetaUrlRE,
+ },
+ async handler(code, id) {
+ let s: MagicString | undefined
+ const re = new RegExp(assetImportMetaUrlRE)
+ const cleanString = stripLiteral(code)
+
+ let match: RegExpExecArray | null
+ while ((match = re.exec(cleanString))) {
+ const [[startIndex, endIndex], [urlStart, urlEnd]] =
+ match.indices as Array<[number, number]>
+ if (hasViteIgnoreRE.test(code.slice(startIndex, urlStart))) {continue}
+
+ const rawUrl = code.slice(urlStart, urlEnd)
+
+ if (!s) {s = new MagicString(code)}
+
+ // potential dynamic template string
+ if (rawUrl[0] === '`' && rawUrl.includes('${')) {
+ const queryDelimiterIndex = getQueryDelimiterIndex(rawUrl)
+ const hasQueryDelimiter = queryDelimiterIndex !== -1
+ const pureUrl = hasQueryDelimiter
+ ? rawUrl.slice(0, queryDelimiterIndex) + '`'
+ : rawUrl
+ const queryString = hasQueryDelimiter
+ ? rawUrl.slice(queryDelimiterIndex, -1)
+ : ''
+ const ast = this.parse(pureUrl)
+ const templateLiteral = (ast as any).body[0].expression
+ if (templateLiteral.expressions.length) {
+ const pattern = buildGlobPattern(templateLiteral)
+ if (pattern[0] === '*') {
+ // don't transform for patterns like this
+ // because users won't intend to do that in most cases
+ continue
+ }
+
+ const globOptions = {
+ eager: true,
+ import: 'default',
+ // A hack to allow 'as' & 'query' exist at the same time
+ query: injectQuery(queryString, 'url'),
+ }
+ s.update(
+ startIndex,
+ endIndex,
+ `new URL((import.meta.glob(${JSON.stringify(
+ pattern,
+ )}, ${JSON.stringify(
+ globOptions,
+ )}))[${pureUrl}], import.meta.url)`,
+ )
+ continue
+ }
+ }
+
+ const url = rawUrl.slice(1, -1)
+ if (isDataUrl(url)) {
+ continue
+ }
+ let file: string | undefined
+ if (url[0] === '.') {
+ file = slash(path.resolve(path.dirname(id), url))
+ file = tryFsResolve(file, fsResolveOptions) ?? file
+ } else {
+ assetResolver ??= createBackCompatIdResolver(config, {
+ extensions: [],
+ mainFields: [],
+ tryIndex: false,
+ preferRelative: true,
+ })
+ file = await assetResolver(this.environment, url, id)
+ file ??=
+ url[0] === '/'
+ ? slash(path.join(publicDir, url))
+ : slash(path.resolve(path.dirname(id), url))
+ }
+
+ // Get final asset URL. If the file does not exist,
+ // we fall back to the initial URL and let it resolve in runtime
+ let builtUrlExpr: string | undefined
+ if (file) {
+ try {
+ if (publicDir && isParentDirectory(publicDir, file)) {
+ const publicPath = '/' + path.posix.relative(publicDir, file)
+ builtUrlExpr = await fileToUrl(this, publicPath, 'js')
+ } else {
+ builtUrlExpr = await fileToUrl(this, file, 'js')
+ // during dev, builtUrl may point to a directory or a non-existing file
+ if (tryStatSync(file)?.isFile()) {
+ this.addWatchFile(file)
+ }
+ }
+ } catch {
+ // do nothing, we'll log a warning after this
+ }
+ }
+ if (!builtUrlExpr) {
+ const rawExp = code.slice(startIndex, endIndex)
+ config.logger.warnOnce(
+ `\n${rawExp} doesn't exist at build time, it will remain unchanged to be resolved at runtime. ` +
+ `If this is intended, you can use the /* @vite-ignore */ comment to suppress this warning.`,
+ )
+ builtUrlExpr = JSON.stringify(url)
+ }
+ s.update(
+ startIndex,
+ endIndex,
+ // NOTE: add `'' +` to opt-out rolldown's transform: https://github.com/rolldown/rolldown/issues/2745
+ `new URL(${builtUrlExpr}, '' + import.meta.url)`,
+ )
+ }
+ if (s) {
+ return transformStableResult(s, id, config)
+ }
+ },
+ },
+ }
+}
+
+function buildGlobPattern(ast: any) {
+ let pattern = ''
+ let lastIsGlob = false
+ for (let i = 0; i < ast.quasis.length; i++) {
+ const str = ast.quasis[i].value.raw
+ if (str) {
+ pattern += str
+ lastIsGlob = false
+ }
+
+ if (ast.expressions[i] && !lastIsGlob) {
+ pattern += '*'
+ lastIsGlob = true
+ }
+ }
+ return pattern
+}
+
+function getQueryDelimiterIndex(rawUrl: string): number {
+ let bracketsStack = 0
+ for (let i = 0; i < rawUrl.length; i++) {
+ if (rawUrl[i] === '{') {
+ bracketsStack++
+ } else if (rawUrl[i] === '}') {
+ bracketsStack--
+ } else if (rawUrl[i] === '?' && bracketsStack === 0) {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/packages/vite-dev-server/src/node/plugins/clientInjections.test.ts b/packages/vite-dev-server/src/node/plugins/clientInjections.test.ts
index 5f2d635..2c98a24 100644
--- a/packages/vite-dev-server/src/node/plugins/clientInjections.test.ts
+++ b/packages/vite-dev-server/src/node/plugins/clientInjections.test.ts
@@ -1,8 +1,16 @@
-import { describe, expect, test } from 'vite-plus/test'
+import { describe, expect, test, vi } from 'vite-plus/test'
import type { ResolvedConfig } from '../config'
import { CLIENT_ENTRY } from '../constants'
import { clientInjectionsPlugin } from './clientInjections'
+// NOTE(kazupon): clientInjections.ts loads html.ts through define.ts. html.ts imports the build code,
+// which loads rolldown from the browser build (`@vrowzer/rolldown`). The unit tests run in Node, so
+// they use the Node build of the same rolldown version.
+vi.mock('@vrowzer/rolldown', () => import('rolldown'))
+vi.mock('@vrowzer/rolldown/experimental', () => import('rolldown/experimental'))
+vi.mock('@vrowzer/rolldown/parseAst', () => import('rolldown/parseAst'))
+vi.mock('@vrowzer/rolldown/utils', () => import('rolldown/utils'))
+
describe('clientInjectionsPlugin', () => {
test('reads connection options from server.ws and overlay from server.hmr', async () => {
const config = {
diff --git a/packages/vite-dev-server/src/node/plugins/css.test.ts b/packages/vite-dev-server/src/node/plugins/css.test.ts
index d4c5ff3..a22c6be 100644
--- a/packages/vite-dev-server/src/node/plugins/css.test.ts
+++ b/packages/vite-dev-server/src/node/plugins/css.test.ts
@@ -5,9 +5,11 @@ vi.mock('@vrowzer/rolldown', () => ({
rolldown: vi.fn<(...args: unknown[]) => unknown>(),
}))
vi.mock('@vrowzer/rolldown/experimental', () => ({
+ viteBuildImportAnalysisPlugin: () => ({ name: 'builtin:vite-build-import-analysis' }),
viteJsonPlugin: () => ({ name: 'vite:json' }),
viteTransformPlugin: () => ({ name: 'native:transform' }),
viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }),
+ viteModulePreloadPolyfillPlugin: () => ({ name: 'builtin:vite-module-preload-polyfill' }),
viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }),
viteResolvePlugin: () => ({ name: 'builtin:vite-resolve' }),
}))
diff --git a/packages/vite-dev-server/src/node/plugins/html.test.ts b/packages/vite-dev-server/src/node/plugins/html.test.ts
index e5f14bb..df79390 100644
--- a/packages/vite-dev-server/src/node/plugins/html.test.ts
+++ b/packages/vite-dev-server/src/node/plugins/html.test.ts
@@ -1,6 +1,15 @@
-import { describe, expect, test } from 'vite-plus/test'
+import type { OutputBundle, OutputChunk } from 'rolldown'
+import { describe, expect, test, vi } from 'vite-plus/test'
import type { MinimalPluginContextWithoutEnvironment } from '../plugin'
-import { postImportMapHook } from './html'
+import { getCssFilesForChunk, postImportMapHook } from './html'
+
+// NOTE(kazupon): html.ts imports the build code, which loads rolldown from the browser build
+// (`@vrowzer/rolldown`). The unit tests run in Node, so they use the Node build of the same rolldown
+// version.
+vi.mock('@vrowzer/rolldown', () => import('rolldown'))
+vi.mock('@vrowzer/rolldown/experimental', () => import('rolldown/experimental'))
+vi.mock('@vrowzer/rolldown/parseAst', () => import('rolldown/parseAst'))
+vi.mock('@vrowzer/rolldown/utils', () => import('rolldown/utils'))
describe('postImportMapHook', () => {
test('moves an import map before a non-self-closing modulepreload link', async () => {
@@ -43,3 +52,243 @@ describe('postImportMapHook', () => {
expect(modulePreloadIndex).toBeLessThan(moduleScriptIndex)
})
})
+
+// Ported from upstream Vite (`packages/vite/src/node/__tests__/html.spec.ts`).
+function createChunk(
+ fileName: string,
+ imports: string[],
+ importedCss: string[],
+): OutputChunk {
+ return {
+ type: 'chunk',
+ fileName,
+ imports,
+ viteMetadata: { importedCss: new Set(importedCss) },
+ } as unknown as OutputChunk
+}
+
+function createBundle(...chunks: OutputChunk[]): OutputBundle {
+ const bundle: Record = {}
+ for (const chunk of chunks) {
+ bundle[chunk.fileName] = chunk
+ }
+ return bundle as unknown as OutputBundle
+}
+
+describe('getCssFilesForChunk', () => {
+ test('single chunk with own CSS', () => {
+ const chunk = createChunk('entry.js', [], ['style.css'])
+ const bundle = createBundle(chunk)
+ const cache = new Map()
+ expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([
+ 'style.css',
+ ])
+ })
+
+ test('chunk with no CSS returns empty array', () => {
+ const chunk = createChunk('entry.js', [], [])
+ const bundle = createBundle(chunk)
+ const cache = new Map()
+ expect(getCssFilesForChunk(chunk, bundle, cache)).toStrictEqual([])
+ })
+
+ test('imported chunk CSS comes before own CSS', () => {
+ const dep = createChunk('dep.js', [], ['dep.css'])
+ const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
+ const bundle = createBundle(entry, dep)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual([
+ 'dep.css',
+ 'entry.css',
+ ])
+ })
+
+ test('deep import chain preserves order', () => {
+ const c = createChunk('c.js', [], ['c.css'])
+ const b = createChunk('b.js', ['c.js'], ['b.css'])
+ const a = createChunk('a.js', ['b.js'], ['a.css'])
+ const bundle = createBundle(a, b, c)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'c.css',
+ 'b.css',
+ 'a.css',
+ ])
+ })
+
+ test('cache is populated and used on second call', () => {
+ const dep = createChunk('dep.js', [], ['dep.css'])
+ const entry = createChunk('entry.js', ['dep.js'], ['entry.css'])
+ const bundle = createBundle(entry, dep)
+ const cache = new Map()
+
+ const result = getCssFilesForChunk(entry, bundle, cache)
+ expect(result).toStrictEqual(['dep.css', 'entry.css'])
+ expect(cache.has(dep)).toBe(true)
+ expect(cache.has(entry)).toBe(true)
+
+ expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual(result)
+ })
+
+ test('shared dependency CSS is output for each entry point', () => {
+ const shared = createChunk('shared.js', [], ['shared.css'])
+ const entryA = createChunk('a.js', ['shared.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['shared.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, shared)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'b.css',
+ ])
+ })
+
+ test('diamond dependency deduplicates CSS and preserves order', () => {
+ // A
+ // / \
+ // B C
+ // \ /
+ // D
+ const d = createChunk('d.js', [], ['d.css'])
+ const b = createChunk('b.js', ['d.js'], ['b.css'])
+ const c = createChunk('c.js', ['d.js'], ['c.css'])
+ const a = createChunk('a.js', ['b.js', 'c.js'], ['a.css'])
+ const bundle = createBundle(a, b, c, d)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'd.css',
+ 'b.css',
+ 'c.css',
+ 'a.css',
+ ])
+ })
+
+ test('multiple shared dependencies with different CSS', () => {
+ const shared1 = createChunk('shared1.js', [], ['shared1.css'])
+ const shared2 = createChunk('shared2.js', [], ['shared2.css'])
+ const entryA = createChunk('a.js', ['shared1.js', 'shared2.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['shared2.js', 'shared1.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, shared1, shared2)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'shared1.css',
+ 'shared2.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared2.css',
+ 'shared1.css',
+ 'b.css',
+ ])
+ })
+
+ test('cache from one entry does not corrupt results for another with overlapping subgraph', () => {
+ // entryA entryB
+ // / \ |
+ // mid1 mid2 mid2
+ // | | |
+ // leaf shared shared
+ const shared = createChunk('shared.js', [], ['shared.css'])
+ const leaf = createChunk('leaf.js', [], ['leaf.css'])
+ const mid1 = createChunk('mid1.js', ['leaf.js'], ['mid1.css'])
+ const mid2 = createChunk('mid2.js', ['shared.js'], ['mid2.css'])
+ const entryA = createChunk('a.js', ['mid1.js', 'mid2.js'], ['a.css'])
+ const entryB = createChunk('b.js', ['mid2.js'], ['b.css'])
+ const bundle = createBundle(entryA, entryB, mid1, mid2, leaf, shared)
+ const cache = new Map()
+ expect(getCssFilesForChunk(entryA, bundle, cache)).toStrictEqual([
+ 'leaf.css',
+ 'mid1.css',
+ 'shared.css',
+ 'mid2.css',
+ 'a.css',
+ ])
+ expect(getCssFilesForChunk(entryB, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'mid2.css',
+ 'b.css',
+ ])
+ })
+
+ test('cached chunk does not lose CSS that was already in seenCss during first entry (#21298)', () => {
+ // entry → chunk1 (chunk1.css, chunk-shared.css)
+ // → chunk2 (chunk2.css, chunk-shared.css)
+ // entry2 → chunk2
+ const chunk1 = createChunk(
+ 'chunk1.js',
+ [],
+ ['chunk1.css', 'chunk-shared.css'],
+ )
+ const chunk2 = createChunk(
+ 'chunk2.js',
+ [],
+ ['chunk2.css', 'chunk-shared.css'],
+ )
+ const entry = createChunk(
+ 'entry.js',
+ ['chunk1.js', 'chunk2.js'],
+ ['entry.css'],
+ )
+ const entry2 = createChunk('entry2.js', ['chunk2.js'], ['entry2.css'])
+ const bundle = createBundle(entry, entry2, chunk1, chunk2)
+ const cache = new Map()
+
+ expect(getCssFilesForChunk(entry, bundle, cache)).toStrictEqual([
+ 'chunk1.css',
+ 'chunk-shared.css',
+ 'chunk2.css',
+ 'entry.css',
+ ])
+ expect(getCssFilesForChunk(entry2, bundle, cache)).toStrictEqual([
+ 'chunk2.css',
+ 'chunk-shared.css',
+ 'entry2.css',
+ ])
+ })
+
+ test('dirty leaf chunk CSS is not lost through cached parent (#21298 edge case)', () => {
+ // entry1 → other (shared.css)
+ // → mid → leaf (shared.css, leaf.css)
+ // entry2 → mid → leaf (shared.css, leaf.css)
+ const leaf = createChunk('leaf.js', [], ['shared.css', 'leaf.css'])
+ const mid = createChunk('mid.js', ['leaf.js'], ['mid.css'])
+ const other = createChunk('other.js', [], ['shared.css'])
+ const entry1 = createChunk(
+ 'entry1.js',
+ ['other.js', 'mid.js'],
+ ['entry1.css'],
+ )
+ const entry2 = createChunk('entry2.js', ['mid.js'], ['entry2.css'])
+ const bundle = createBundle(entry1, entry2, other, mid, leaf)
+ const cache = new Map()
+
+ expect(getCssFilesForChunk(entry1, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'leaf.css',
+ 'mid.css',
+ 'entry1.css',
+ ])
+ // entry2 must still get shared.css via leaf, even though mid's cache
+ // was built while shared.css was already seen
+ expect(getCssFilesForChunk(entry2, bundle, cache)).toStrictEqual([
+ 'shared.css',
+ 'leaf.css',
+ 'mid.css',
+ 'entry2.css',
+ ])
+ })
+
+ test('circular imports do not cause infinite loop', () => {
+ const a = createChunk('a.js', ['b.js'], ['a.css'])
+ const b = createChunk('b.js', ['a.js'], ['b.css'])
+ const bundle = createBundle(a, b)
+ const cache = new Map()
+ expect(getCssFilesForChunk(a, bundle, cache)).toStrictEqual([
+ 'b.css',
+ 'a.css',
+ ])
+ })
+})
diff --git a/packages/vite-dev-server/src/node/plugins/html.ts b/packages/vite-dev-server/src/node/plugins/html.ts
index a7facac..2ad297e 100644
--- a/packages/vite-dev-server/src/node/plugins/html.ts
+++ b/packages/vite-dev-server/src/node/plugins/html.ts
@@ -9,6 +9,7 @@ import type {
} from 'parse5'
import colors from 'picocolors'
import type {
+ OutputAsset,
OutputBundle,
OutputChunk,
RollupError,
@@ -16,7 +17,31 @@ import type {
} from 'rolldown'
import { stripLiteral } from 'strip-literal'
import { cleanUrl } from '../../shared/utils'
-import type { ResolvedConfig } from '../config'
+import { getNodeAssetAttributes } from '../assetSource'
+import { toOutputFilePathInHtml } from '../build'
+import type { ResolvedConfig, ResolvedEnvironmentOptions } from '../config'
+import { perEnvironmentState } from '../environment'
+import { checkPublicFile } from '../publicDir'
+import {
+ decodeURIIfPossible,
+ encodeURIPath,
+ getHash,
+ isCSSRequest,
+ isDataUrl,
+ isExternalUrl,
+ partialEncodeURIPath,
+ processSrcSet,
+ removeLeadingSlash,
+} from '../utils'
+import {
+ assetUrlRE,
+ getAssetUrlPostfix,
+ getPublicAssetFilename,
+ publicAssetUrlRE,
+ urlToBuiltUrl,
+} from './asset'
+import { cssBundleNameCache } from './css'
+import { modulePreloadPolyfillId } from './modulePreloadPolyfill'
// NOTE(kazupon): commented out, because env will not be supported in vrowzer yet
// import { resolveEnvPrefix } from '../env'
import type { Logger } from '../logger'
@@ -145,8 +170,24 @@ const noInlineLinkRels = new Set([
'apple-touch-icon',
'apple-touch-startup-image',
'manifest',
+ 'modulepreload',
+ 'preload',
+ 'prefetch',
])
+// If the node is a link, check if it can be inlined. If not, return `false` to
+// force no inline. `undefined` leaves it to the default heuristics.
+function getLinkShouldInline(
+ node: DefaultTreeAdapterMap['element'],
+ attributes: Record,
+): false | undefined {
+ const isNoInlineLink =
+ node.nodeName === 'link' &&
+ attributes.rel &&
+ parseRelAttr(attributes.rel).some((v) => noInlineLinkRels.has(v))
+ return isNoInlineLink ? false : undefined
+}
+
export const isAsyncScriptMap: WeakMap<
ResolvedConfig,
Map
@@ -324,7 +365,763 @@ function handleParseError(
parseError.frame
}
-// TODO: fill in code later ...
+/**
+ * Collects CSS files for a chunk by traversing its imports depth-first,
+ * using a cache to avoid re-analyzing chunks while still returning the
+ * correct files when the same chunk is reached via different entry points.
+ */
+export function getCssFilesForChunk(
+ chunk: OutputChunk,
+ bundle: OutputBundle,
+ analyzedImportedCssFiles: Map,
+ seenChunks: Set = new Set(),
+ seenCss: Set = new Set(),
+): string[] {
+ if (seenChunks.has(chunk.fileName)) {
+ return []
+ }
+ seenChunks.add(chunk.fileName)
+
+ if (analyzedImportedCssFiles.has(chunk)) {
+ const files = analyzedImportedCssFiles.get(chunk)!
+ const additionals = files.filter((file) => !seenCss.has(file))
+ additionals.forEach((file) => seenCss.add(file))
+ return additionals
+ }
+
+ // Collect all CSS from imports (unfiltered for caching, filtered for return)
+ const allFiles: string[] = []
+ const filteredFiles: string[] = []
+ chunk.imports.forEach((file) => {
+ const importee = bundle[file]
+ if (importee?.type === 'chunk') {
+ const importeeCss = getCssFilesForChunk(
+ importee,
+ bundle,
+ analyzedImportedCssFiles,
+ seenChunks,
+ seenCss,
+ )
+ filteredFiles.push(...importeeCss)
+ // For cache: use the importee's full cached list
+ if (analyzedImportedCssFiles.has(importee)) {
+ allFiles.push(...analyzedImportedCssFiles.get(importee)!)
+ } else {
+ allFiles.push(...importeeCss)
+ }
+ }
+ })
+
+ chunk.viteMetadata!.importedCss.forEach((file) => {
+ allFiles.push(file)
+ if (!seenCss.has(file)) {
+ seenCss.add(file)
+ filteredFiles.push(file)
+ }
+ })
+
+ analyzedImportedCssFiles.set(chunk, unique(allFiles))
+
+ return filteredFiles
+}
+
+/**
+ * Compiles index.html into an entry js module
+ */
+export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
+ const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
+ config.plugins,
+ )
+ preHooks.unshift(injectCspNonceMetaTagHook(config))
+ preHooks.unshift(preImportMapHook(config))
+ preHooks.push(htmlEnvHook(config))
+ postHooks.push(injectNonceAttributeTagHook(config))
+ // NOTE(kazupon): the fork's postImportMapHook does not support the chunk import map (Vite 8.3) yet
+ postHooks.push(postImportMapHook())
+ // postHooks.push(postImportMapHook(config))
+ const processedHtml = perEnvironmentState(() => new Map())
+
+ const isExcludedUrl = (url: string) =>
+ url[0] === '#' || isExternalUrl(url) || isDataUrl(url)
+
+ // Same reason with `htmlInlineProxyPlugin`
+ isAsyncScriptMap.set(config, new Map())
+
+ return {
+ name: 'vite:build-html',
+
+ applyToEnvironment(environment) {
+ return environment.config.isBundled
+ },
+
+ transform: {
+ filter: { id: /\.html$/ },
+ async handler(html, id) {
+ id = normalizePath(id)
+ const relativeUrlPath = normalizePath(path.relative(config.root, id))
+ const publicPath = `/${relativeUrlPath}`
+ const publicBase = getBaseInHTML(relativeUrlPath, config)
+
+ const publicToRelative = (filename: string) => publicBase + filename
+ const toOutputPublicFilePath = (url: string) =>
+ toOutputFilePathInHtml(
+ url.slice(1),
+ 'public',
+ relativeUrlPath,
+ 'html',
+ config,
+ publicToRelative,
+ )
+ // Determines true start position for the node, either the < character
+ // position, or the newline at the end of the previous line's node.
+ const nodeStartWithLeadingWhitespace = (
+ node: DefaultTreeAdapterMap['node'],
+ ) => {
+ const startOffset = node.sourceCodeLocation!.startOffset
+ if (startOffset === 0) {return 0}
+
+ // Gets the offset for the start of the line including the
+ // newline trailing the previous node
+ const lineStartOffset =
+ startOffset - node.sourceCodeLocation!.startCol
+
+ //
+ //
+ //
+ // Here we want to target the newline at the end of the previous line
+ // as the start position for our target.
+ //
+ //
+ //
+ //
+ // However, if there is content between our target node start and the
+ // previous newline, we cannot strip it out without risking content deletion.
+ let isLineEmpty = false
+ try {
+ const line = s.slice(Math.max(0, lineStartOffset), startOffset)
+ isLineEmpty = !line.trim()
+ } catch {
+ // magic-string may throw if there's some content removed in the sliced string,
+ // which we ignore and assume the line is not empty
+ }
+
+ return isLineEmpty ? lineStartOffset : startOffset
+ }
+
+ // pre-transform
+ html = await applyHtmlTransforms(html, preHooks, this, {
+ path: publicPath,
+ filename: id,
+ })
+
+ let js = ''
+ const s = new MagicString(html)
+ const scriptUrls: ScriptAssetsUrl[] = []
+ const styleUrls: ScriptAssetsUrl[] = []
+ let inlineModuleIndex = -1
+
+ let everyScriptIsAsync = true
+ let someScriptsAreAsync = false
+ let someScriptsAreDefer = false
+
+ const assetUrlsPromises: Promise[] = []
+
+ // for each encountered asset url, rewrite original html so that it
+ // references the post-build location, ignoring empty attributes and
+ // attributes that directly reference named output.
+ const namedOutput = Object.keys(
+ config.build.rolldownOptions.input || {},
+ )
+ const processAssetUrl = async (url: string, shouldInline?: boolean) => {
+ if (
+ url !== '' && // Empty attribute
+ !namedOutput.includes(url) && // Direct reference to named output
+ !namedOutput.includes(removeLeadingSlash(url)) // Allow for absolute references as named output can't be an absolute path
+ ) {
+ try {
+ return await urlToBuiltUrl(this, url, id, shouldInline)
+ } catch (e) {
+ if (e.code !== 'ENOENT') {
+ throw e
+ }
+ }
+ }
+ return url
+ }
+
+ const setModuleSideEffectPromises: Promise[] = []
+ await traverseHtml(html, id, config.logger.warn, (node) => {
+ if (!nodeIsElement(node)) {
+ return
+ }
+
+ let shouldRemove = false
+
+ // script tags
+ if (node.nodeName === 'script') {
+ const { src, srcSourceCodeLocation, isModule, isAsync, isIgnored } =
+ getScriptInfo(node)
+
+ if (isIgnored) {
+ removeViteIgnoreAttr(s, node.sourceCodeLocation!)
+ } else {
+ const url = src && src.value
+ const isPublicFile = !!(url && checkPublicFile(url, config))
+ if (isPublicFile) {
+ // referencing public dir url, prefix with base
+ overwriteAttrValue(
+ s,
+ srcSourceCodeLocation!,
+ partialEncodeURIPath(toOutputPublicFilePath(url)),
+ )
+ }
+
+ if (isModule) {
+ inlineModuleIndex++
+ if (url && !isExcludedUrl(url) && !isPublicFile) {
+ setModuleSideEffectPromises.push(
+ this.resolve(url, id).then((resolved) => {
+ if (!resolved) {
+ return Promise.reject(
+ new Error(`Failed to resolve ${url} from ${id}`),
+ )
+ }
+ // set moduleSideEffects to keep the module even if `treeshake.moduleSideEffects=false` is set
+ const moduleInfo = this.getModuleInfo(resolved.id)
+ if (moduleInfo) {
+ moduleInfo.moduleSideEffects = true
+ } else if (!resolved.external) {
+ return this.load({
+ ...resolved,
+ moduleSideEffects: true,
+ }).then(() => {})
+ }
+ }),
+ )
+ //
+ // add it as an import
+ js += `\nimport ${JSON.stringify(url)}`
+ shouldRemove = true
+ } else if (node.childNodes.length) {
+ const scriptNode =
+ node.childNodes.pop() as DefaultTreeAdapterMap['textNode']
+ const contents = scriptNode.value
+ //
+ const filePath = id.replace(normalizePath(config.root), '')
+ addToHTMLProxyCache(config, filePath, inlineModuleIndex, {
+ code: contents,
+ })
+ js += `\nimport "${id}?html-proxy&index=${inlineModuleIndex}.js"`
+ shouldRemove = true
+ }
+
+ everyScriptIsAsync &&= isAsync
+ someScriptsAreAsync ||= isAsync
+ someScriptsAreDefer ||= !isAsync
+ } else if (url && !isPublicFile) {
+ if (!isExcludedUrl(url)) {
+ config.logger.warn(
+ ` asset
+ for (const { start, end, url } of scriptUrls) {
+ if (checkPublicFile(url, config)) {
+ s.update(
+ start,
+ end,
+ partialEncodeURIPath(toOutputPublicFilePath(url)),
+ )
+ } else if (!isExcludedUrl(url)) {
+ s.update(
+ start,
+ end,
+ partialEncodeURIPath(await urlToBuiltUrl(this, url, id)),
+ )
+ }
+ }
+
+ // ignore if its url can't be resolved
+ const resolvedStyleUrls = await Promise.all(
+ styleUrls.map(async (styleUrl) => ({
+ ...styleUrl,
+ resolved: await this.resolve(styleUrl.url, id),
+ })),
+ )
+ for (const { start, end, url, resolved } of resolvedStyleUrls) {
+ if (resolved == null) {
+ config.logger.warnOnce(
+ `\n${url} doesn't exist at build time, it will remain unchanged to be resolved at runtime`,
+ )
+ const importExpression = `\nimport ${JSON.stringify(url)}`
+ js = js.replace(importExpression, '')
+ } else {
+ s.remove(start, end)
+ }
+ }
+
+ processedHtml(this).set(id, s.toString())
+
+ // inject module preload polyfill only when configured and needed
+ const { modulePreload } = this.environment.config.build
+ if (
+ modulePreload !== false &&
+ modulePreload.polyfill &&
+ (someScriptsAreAsync || someScriptsAreDefer)
+ ) {
+ js = `import "${modulePreloadPolyfillId}";\n${js}`
+ }
+
+ await Promise.all(setModuleSideEffectPromises)
+
+ // Force rollup to keep this module from being shared between other entry points.
+ // If the resulting chunk is empty, it will be removed in generateBundle.
+ return {
+ code: js,
+ map: { mappings: '' },
+ moduleSideEffects: 'no-treeshake',
+ }
+ },
+ },
+
+ async generateBundle(options, bundle) {
+ const analyzedImportedCssFiles = new Map()
+ const inlineEntryChunk = new Set()
+ const getImportedChunks = (
+ chunk: OutputChunk,
+ seen: Set = new Set(),
+ ): (OutputChunk | string)[] => {
+ const chunks: (OutputChunk | string)[] = []
+ chunk.imports.forEach((file) => {
+ const importee = bundle[file]
+ if (importee) {
+ if (importee.type === 'chunk' && !seen.has(file)) {
+ seen.add(file)
+
+ // post-order traversal
+ chunks.push(...getImportedChunks(importee, seen))
+ chunks.push(importee)
+ }
+ } else {
+ // external imports
+ chunks.push(file)
+ }
+ })
+ return chunks
+ }
+
+ const toScriptTag = (
+ chunkOrUrl: OutputChunk | string,
+ toOutputPath: (filename: string) => string,
+ isAsync: boolean,
+ ): HtmlTagDescriptor => ({
+ tag: 'script',
+ attrs: {
+ ...(isAsync ? { async: true } : {}),
+ type: 'module',
+ // crossorigin must be set not only for serving assets in a different origin
+ // but also to make it possible to preload the script using ``.
+ // ``)
+ expect(html).toContain(``)
+ expect(html).toContain(`
`)
+ expect(html).not.toContain('/src/')
+ // The global CSS and the CSS of the entry go into one file
+ expect(text(files, css)).toContain('rgb(0, 128, 0)')
+ expect(text(files, css)).toContain('font-weight: 700')
+ expect(text(files, lazy)).toContain('lazy ok')
+ expect(text(files, lazyCss)).toContain('rgb(255, 255, 0)')
+
+ // The entry preloads the lazy chunk with its CSS, and has the module preload polyfill
+ const entryCode = text(files, entry)
+ expect(entryCode).toContain('__vitePreload(')
+ expect(entryCode).toContain(JSON.stringify(lazyCss))
+ expect(entryCode).toContain('relList.supports("modulepreload")')
+
+ for (const file of [entry, lazy]) {
+ expect(text(files, file)).toContain(
+ `//# sourceMappingURL=${file.slice('assets/'.length)}.map`
+ )
+ const map = JSON.parse(text(files, `${file}.map`))
+ expect(
+ map.sources.some(
+ (source: string) => source.endsWith('src/main.ts') || source.endsWith('src/lazy.ts')
+ )
+ ).toBe(true)
+ }
+ } finally {
+ await context.close()
+ }
+ })
+
+ test('builds a nested HTML entry', async () => {
+ const { context, page } = await openFixture({}, 'app')
+ try {
+ const { files } = await expectBuilt(page, {
+ build: { rolldownOptions: { input: '/nested/index.html' } }
+ })
+ expect(Object.keys(files)).toContain('nested/index.html')
+ expect(Object.keys(files)).not.toContain('index.html')
+
+ const app = await openBuiltApp(files, '/', 'nested/')
+ try {
+ await expect
+ .poll(() => app.page.evaluate(() => document.querySelector('#app')?.textContent ?? ''), {
+ timeout: 15_000
+ })
+ .toBe('nested ok')
+ expect(
+ await app.page.evaluate(
+ () => getComputedStyle(document.querySelector('#app') as HTMLElement).color
+ )
+ ).toBe('rgb(0, 128, 0)')
+ expect(app.pageErrors).toEqual([])
+ expect(app.missingFiles).toEqual([])
+ } finally {
+ await app.context.close()
+ }
+ } finally {
+ await context.close()
+ }
+ })
+
+ test('builds the default /index.html of the runtime', async () => {
+ // The library has no /index.html, so ready() gives it the default one, which loads /main.js
+ const { context, page } = await openFixture()
+ try {
+ const { files } = await expectBuilt(page, {})
+ expect(text(files, 'index.html')).toContain('Preview')
+
+ const app = await openBuiltApp(files, '/')
+ try {
+ await expect
+ .poll(() => app.page.evaluate(() => document.querySelector('#app')?.textContent ?? ''), {
+ timeout: 15_000
+ })
+ .toBe('preview v1')
+ expect(app.pageErrors).toEqual([])
+ } finally {
+ await app.context.close()
+ }
+ await expectPreviewUpdates(page, 'preview after an app build')
+ } finally {
+ await context.close()
+ }
+ })
+})
+
async function indexSource(page: Page): Promise {
return page.evaluate(() => (window as any).__indexSource__)
}
diff --git a/packages/vrowzer/integration/build/main.ts b/packages/vrowzer/integration/build/main.ts
index 9b663f0..1cb3a66 100644
--- a/packages/vrowzer/integration/build/main.ts
+++ b/packages/vrowzer/integration/build/main.ts
@@ -75,6 +75,80 @@ const projectFiles: Record = {
'/main.js': mainSource('preview v1')
}
+// Binary files larger than the inline limit (4 KiB), so that the builds emit them as files
+function binary(seed: number): Uint8Array {
+ const bytes = new Uint8Array(5000)
+ for (let i = 0; i < bytes.length; i++) {
+ bytes[i] = (i * 31 + seed) % 256
+ }
+ return bytes
+}
+const bigImage = binary(7)
+const photo = binary(11)
+
+// An HTML app, with a nested HTML entry
+const appFiles: Record = {
+ '/index.html': [
+ '',
+ '',
+ ' ',
+ ' ',
+ ' App',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ ' ',
+ ' ',
+ '',
+ ''
+ ].join('\n'),
+ '/src/global.css': '#app { color: rgb(0, 128, 0); }\n',
+ '/src/main.ts': [
+ "import './style.css'",
+ "import smallUrl from './small.svg'",
+ "import bigUrl from './big.png?url'",
+ "import message from './message.txt?raw'",
+ "document.querySelector('#app')!.textContent = 'app ok'",
+ "const small = document.createElement('img')",
+ "small.id = 'small'",
+ 'small.src = smallUrl',
+ 'document.body.append(small)',
+ "const photoUrl = new URL('./photo.png', import.meta.url).href",
+ 'Object.assign(document.body.dataset, { bigUrl, photoUrl, message })',
+ "import('./lazy').then(m => {",
+ ' document.body.dataset.lazy = m.value',
+ '})',
+ ''
+ ].join('\n'),
+ '/src/style.css': '#app { font-weight: 700; }\n',
+ '/src/lazy.ts': "import './lazy.css'\nexport const value: string = 'lazy ok'\n",
+ '/src/lazy.css': '#app { background-color: rgb(255, 255, 0); }\n',
+ '/src/small.svg':
+ '',
+ '/src/message.txt': 'Hello from a text file\n',
+ '/src/big.png': bigImage.slice().buffer,
+ '/src/photo.png': photo.slice().buffer,
+ '/public/robots.txt': 'User-agent: *\n',
+ '/nested/index.html': [
+ '',
+ '',
+ ' ',
+ ' ',
+ ' Nested',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ ' ',
+ '',
+ ''
+ ].join('\n'),
+ '/nested/nested.ts': "document.querySelector('#app')!.textContent = 'nested ok'\n"
+}
+
type SerializedContent = string | { bytes: number[] }
/**
@@ -106,6 +180,8 @@ async function runBuild(options?: VrowzerBuildOptions) {
Object.assign(window, {
__createVrowzer__: (options?: VrowzerOptions) => Vrowzer(options),
__projectFiles__: projectFiles,
+ __appFiles__: appFiles,
+ __appBinaries__: { big: Array.from(bigImage), photo: Array.from(photo) },
__indexSource__: indexSource,
__mainSource__: mainSource,
__runBuild__: runBuild,
diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts
index 355dbdd..a9fc799 100644
--- a/packages/vrowzer/src/index.ts
+++ b/packages/vrowzer/src/index.ts
@@ -255,7 +255,8 @@ export interface VrowzerBuildOptions {
*/
build?: {
/**
- * Builds a library. Required for now, because HTML app builds are not supported yet.
+ * Builds a library from a JavaScript or TypeScript entry. Without it, the build builds an app from
+ * its HTML entry: `/index.html`, or `rolldownOptions.input`.
*/
lib?: VrowzerBuildLibraryOptions | false
/**
@@ -303,7 +304,9 @@ export interface VrowzerBuildOptions {
*/
rolldownOptions?: {
/**
- * The entry of the build, e.g. an HTML file.
+ * The HTML entry of an app, e.g. `/nested/index.html`. One HTML file is supported.
+ *
+ * @default '/index.html'
*/
input?: string
/**
@@ -652,7 +655,7 @@ export interface Vrowzer extends Emittable {
*/
deleteFile(filePath: string): Promise
/**
- * Builds the project for production in a build Worker, e.g. as a library.
+ * Builds the project for production in a build Worker: an app from its HTML entry, or a library.
*
* The build uses the project files as they are when this method is called: the files of
* {@link Vrowzer.ready}, with the default `/index.html` when they have none, and the changes of
@@ -660,8 +663,9 @@ export interface Vrowzer extends Emittable {
* ends.
*
* Each build runs in a new build Worker, with the Worker config bundled for production, and the
- * build Worker is terminated when the build ends. The previews are not affected. Only library
- * builds (`build.lib`) in the `es` format are supported for now, and one build at a time.
+ * build Worker is terminated when the build ends. The previews are not affected. An app builds
+ * from one HTML entry, and a library (`build.lib`) from one entry in the `es` format. One build
+ * runs at a time.
*
* A closed build Worker takes about 2 seconds to stop in Chromium. When 4 of them closed within
* the last 2.5 seconds, e.g. after short builds one after another, a build waits before it