From f69c11830118e91ddb606fbf315118d777c9a9e0 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sat, 3 Oct 2026 00:39:48 +0900 Subject: [PATCH 1/6] feat(vite-dev-server): port the HTML build plugin and the preload polyfill Builds can now take an HTML entry. `buildHtmlPlugin()` and its helpers (`getCssFilesForChunk()`, `getLinkShouldInline()`, `isEntirelyImport()` and the import map hooks) come from upstream's `html.ts`, at the same commit as the fork's `asset.ts`, `css.ts` and `build.ts` (`cf5c0288d`, 23 commits after 8.3.1). The HTML plugin imports the id of the module preload polyfill, so `modulePreloadPolyfill.ts` comes with it. Both plugins are registered only for builds, so the dev Web Worker does not change: it has no bundled environment, and it did not register the polyfill before. The Worker aggregates import the polyfill plugin eagerly, like the other plugins. With `NOTE(kazupon)`: - `postImportMapHook()` is called in the fork's form, which does not support the chunk import map (Vite 8.3) yet. - The dev client of bundled dev mode is not injected (bundled dev was removed in #41). Tests: - ported from upstream: `getCssFilesForChunk()`, the module preload polyfill, and the builds of `build.spec.ts` that use the HTML entry (the hashes of CSS chunks, `renderBuiltUrl`, the tree shaking of JSON and the copy of the public directory). `onRollupLog` builds the HTML entry again, as upstream does. - `resolveConfig()` registers the plugins for builds, and leaves them out of the dev pipeline. - an HTML entry: the script and the style of the output HTML, and the polyfill with and without `modulePreload.polyfill` - Vitest gives the original module to the second of two concurrent dynamic imports of a mocked module, so the ported tests build one after another. - `html.ts` now imports the build code, so the tests that load it use the Node build of rolldown. --- packages/vite-dev-server/rolldown.config.ts | 1 + .../public-dir-write-false/index.html | 0 .../public-dir-write-false/public/favicon.svg | 1 + .../vite-dev-server/src/node/build.test.ts | 428 ++++++++- .../vite-dev-server/src/node/config.test.ts | 24 + .../src/node/plugins/asset.test.ts | 1 + .../src/node/plugins/clientInjections.test.ts | 10 +- .../src/node/plugins/css.test.ts | 1 + .../src/node/plugins/html.test.ts | 253 ++++- .../vite-dev-server/src/node/plugins/html.ts | 870 +++++++++++++++++- .../vite-dev-server/src/node/plugins/index.ts | 18 +- .../plugins/modulePreloadPolyfill.test.ts | 105 +++ .../src/node/plugins/modulePreloadPolyfill.ts | 35 + .../node/server/middlewares/indexHtml.test.ts | 10 + 14 files changed, 1741 insertions(+), 16 deletions(-) create mode 100644 packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/index.html create mode 100644 packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg create mode 100644 packages/vite-dev-server/src/node/plugins/modulePreloadPolyfill.test.ts create mode 100644 packages/vite-dev-server/src/node/plugins/modulePreloadPolyfill.ts diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index 6121cbe5..07a1eaf3 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -582,6 +582,7 @@ function createEagerWorkerTransformerImportsPlugin(): Plugin { './asset', './clientInjections', './define', + './modulePreloadPolyfill', './forwardConsole', ]], // buildEnvironment() loads rolldown lazily, while the static graph imports it as well 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 00000000..e69de29b 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 00000000..dc1ced5b --- /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 936c5c5b..0c699490 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,160 @@ describe('package resolution in library builds', () => { expect(chunk.code).not.toMatch(/exports-node|exports-default|browser-main|module-main/) }) }) + +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('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/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts index a54cacf0..9da93153 100644 --- a/packages/vite-dev-server/src/node/config.test.ts +++ b/packages/vite-dev-server/src/node/config.test.ts @@ -19,6 +19,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,6 +633,8 @@ describe('definePlugin JavaScript pre-check', () => { expect(config.plugins.map(plugin => plugin.name)).toEqual( expect.arrayContaining([ 'vite:define', + 'vite:modulepreload-polyfill', + 'vite:build-html', 'vite:prepare-out-dir', 'vite:rollup-options-plugins', 'native:reporter', @@ -637,6 +642,25 @@ describe('definePlugin JavaScript pre-check', () => { ]), ) }) + + test('keeps the build HTML and the module preload polyfill 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') + }) + + 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 3f694f3d..d0aa5e91 100644 --- a/packages/vite-dev-server/src/node/plugins/asset.test.ts +++ b/packages/vite-dev-server/src/node/plugins/asset.test.ts @@ -12,6 +12,7 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ 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/clientInjections.test.ts b/packages/vite-dev-server/src/node/plugins/clientInjections.test.ts index 5f2d635d..2c98a245 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 d4c5ff32..946b9927 100644 --- a/packages/vite-dev-server/src/node/plugins/css.test.ts +++ b/packages/vite-dev-server/src/node/plugins/css.test.ts @@ -8,6 +8,7 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ 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 e5f14bb8..df793900 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 a7facac4..2ad297ef 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(() => {}) + } + }), + ) + // + 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 ``. + // ``, + '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'], diff --git a/packages/vite-dev-server/src/node/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts index c28e310e..b6712dc3 100644 --- a/packages/vite-dev-server/src/node/config.test.ts +++ b/packages/vite-dev-server/src/node/config.test.ts @@ -638,6 +638,7 @@ describe('definePlugin JavaScript pre-check', () => { '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', @@ -648,12 +649,13 @@ describe('definePlugin JavaScript pre-check', () => { ) }) - test('keeps the build HTML and the module preload polyfill plugins out of the dev pipeline', async () => { + 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 () => { 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 00000000..d9566e42 --- /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 00000000..384cfc40 --- /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/index.ts b/packages/vite-dev-server/src/node/plugins/index.ts index 293aea18..c60fe619 100644 --- a/packages/vite-dev-server/src/node/plugins/index.ts +++ b/packages/vite-dev-server/src/node/plugins/index.ts @@ -39,7 +39,7 @@ export async function resolvePlugins( // Using dynamic import() guarded by __VROWZER_SERVICE_WORKER__ build-time constant // enables rolldown DCE(Dead Code Elimination) to eliminate these plugins and their heavy dependencies // (postcss, oxc-parser, es-module-lexer, etc.) from the Service Worker bundle. - const [preAliasMod, aliasMod, resolveMod, htmlMod, cssMod, oxcMod, jsonMod, importAnalysisMod, assetMod, clientInjectionsMod, defineMod, modulePreloadPolyfillMod] = await Promise.all([ + const [preAliasMod, aliasMod, resolveMod, htmlMod, cssMod, oxcMod, jsonMod, importAnalysisMod, assetMod, clientInjectionsMod, defineMod, modulePreloadPolyfillMod, assetImportMetaUrlMod] = await Promise.all([ import('./preAlias'), import('@rollup/plugin-alias'), import('./resolve'), @@ -52,6 +52,7 @@ export async function resolvePlugins( import('./clientInjections'), import('./define'), import('./modulePreloadPolyfill'), + import('./assetImportMetaUrl'), ]) const preAliasPlugin = preAliasMod.preAliasPlugin const aliasPlugin = aliasMod.default @@ -69,6 +70,7 @@ export async function resolvePlugins( const clientInjectionsPlugin = clientInjectionsMod.clientInjectionsPlugin const definePlugin = defineMod.definePlugin const modulePreloadPolyfillPlugin = modulePreloadPolyfillMod.modulePreloadPolyfillPlugin + const assetImportMetaUrlPlugin = assetImportMetaUrlMod.assetImportMetaUrlPlugin const forwardConsole = config.server.forwardConsole.enabled ? (await import('./forwardConsole')).forwardConsolePlugin({ environments: ['client'], @@ -151,7 +153,9 @@ export async function resolvePlugins( // buildHtmlPlugin(config), isBuild ? buildHtmlPlugin(config) : null, // workerImportMetaUrlPlugin(config), + // NOTE(kazupon): only for builds. The dev Web Worker did not register it. // assetImportMetaUrlPlugin(config), + isBuild ? assetImportMetaUrlPlugin(config) : null, ...buildPlugins.pre, // dynamicImportVarsPlugin(config), // importGlobPlugin(config), From a13a39bc427c02a3917e3d6e4aae617147a1a0d0 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sat, 3 Oct 2026 01:05:08 +0900 Subject: [PATCH 4/6] feat(vite-dev-server): accept HTML app builds in the builder The builder no longer requires `build.lib`. Without it, a build builds an app from one HTML entry: `build.rolldownOptions.input`, then the top-level `input`, and `/index.html` without them, as `resolveRolldownOptions()` takes them. An app build is rejected with `VROWZER_UNSUPPORTED_OPTION` when its entry is an array or an object, as libraries with more than one entry are, or when the entry is not an HTML file. A JavaScript entry is built with `build.lib`. The descriptions of `build()` and of its `lib` and `rolldownOptions.input` options cover app builds. The browser test of unsupported options rejects an app entry that is not HTML, instead of a build without `build.lib`. --- .../src/node/builderUtils.test.ts | 65 +++++++++++++++++-- .../vite-dev-server/src/node/builderUtils.ts | 30 +++++++-- .../integration/build.integration-test.ts | 2 +- packages/vrowzer/src/index.ts | 14 ++-- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/packages/vite-dev-server/src/node/builderUtils.test.ts b/packages/vite-dev-server/src/node/builderUtils.test.ts index 1220e58e..8469cd67 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 0fef669d..eafce6d0 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/vrowzer/integration/build.integration-test.ts b/packages/vrowzer/integration/build.integration-test.ts index f4eee56f..e70e28cf 100644 --- a/packages/vrowzer/integration/build.integration-test.ts +++ b/packages/vrowzer/integration/build.integration-test.ts @@ -447,7 +447,7 @@ describe('vrowzer.build()', () => { }) test.each([ - [{}, 'build.lib'], + [{ build: { rolldownOptions: { input: '/src/index.ts' } } }, 'build.rolldownOptions.input'], [{ build: { lib: { entry: '/src/index.ts', formats: ['umd'] } } }, 'build.lib.formats'], [{ build: { lib: { entry: '/src/index.ts' }, cssMinify: true } }, 'build.cssMinify'] ])('rejects options that the browser build does not support (%j)', async (options, option) => { diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index 355dbdd8..a9fc7990 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 From 8c309d346e80f0b216c4f6e8d7d064d13900a031 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sat, 3 Oct 2026 01:11:31 +0900 Subject: [PATCH 5/6] test(vrowzer): build HTML apps in the browser The fixture `integration/build/` gets the files of an HTML app: an `index.html` with a stylesheet, an image and a module script, an entry with CSS, a small SVG, `?url`, `?raw`, `new URL(..., import.meta.url)` and a lazy chunk with its CSS, two images larger than the inline limit, a public file, and a nested HTML entry. `build.integration-test.ts` builds the app in the browser, and serves the outputs from another origin with Playwright's routing, as a static server does: - with the bases `/`, `/app/` and `./` (served under a nested path), the app runs without the Service Worker, and every URL of the outputs is served. The CSS of the HTML, of the entry and of the lazy chunk applies, the images keep their bytes, and the outputs have nothing of the dev server. - the rewritten HTML, the lazy chunk and its CSS, the preload and the module preload polyfill, and the source maps - a nested HTML entry - the default `/index.html` that the runtime gives a project without one, and the preview after the build The fixture serves the builder minified by the host build, so the lazy CSS also checks the preload helper with the renamed `isCssPreloadUrl`. An app build with two HTML entries is rejected. --- .../integration/build.integration-test.ts | 292 +++++++++++++++++- packages/vrowzer/integration/build/main.ts | 76 +++++ 2 files changed, 354 insertions(+), 14 deletions(-) diff --git a/packages/vrowzer/integration/build.integration-test.ts b/packages/vrowzer/integration/build.integration-test.ts index e70e28cf..5cfc124a 100644 --- a/packages/vrowzer/integration/build.integration-test.ts +++ b/packages/vrowzer/integration/build.integration-test.ts @@ -1,7 +1,7 @@ import { chromium } from '@playwright/test' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { dirname, extname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { build, preview } from 'vite' import { afterAll, beforeAll, describe, expect, test } from 'vite-plus/test' @@ -51,25 +51,32 @@ const LIBRARY_OPTIONS = { /** * Opens the fixture in a new browser context, which has a Service Worker of its own, and mounts a - * preview of a ready Vrowzer instance. + * preview of a ready Vrowzer instance with the files of the library or of the HTML app. */ -async function openFixture(options: Record = {}): Promise { +async function openFixture( + options: Record = {}, + project: 'library' | 'app' = 'library' +): Promise { const context = await browser.newContext() const page = await context.newPage() await page.goto(`${origin}/`) await page.waitForFunction(() => document.body.dataset.fixtureReady === 'true') - const ready = await page.evaluate(async vrowzerOptions => { - const fixture = window as any - const vrowzer = fixture.__createVrowzer__(vrowzerOptions) - fixture.__vrowzer__ = vrowzer - const result = await vrowzer.ready({ files: fixture.__projectFiles__ }) - if (result) { - vrowzer.mount(document.getElementById('preview-container'), { id: 'preview' }) - } - return result - }, options) + const ready = await page.evaluate( + async ([vrowzerOptions, projectName]) => { + const fixture = window as any + const vrowzer = fixture.__createVrowzer__(vrowzerOptions) + fixture.__vrowzer__ = vrowzer + const files = projectName === 'app' ? fixture.__appFiles__ : fixture.__projectFiles__ + const result = await vrowzer.ready({ files }) + if (result) { + vrowzer.mount(document.getElementById('preview-container'), { id: 'preview' }) + } + return result + }, + [options, project] as const + ) expect(ready).toBe(true) - await expectPreviewText(page, 'preview v1') + await expectPreviewText(page, project === 'app' ? 'app ok' : 'preview v1') return { context, page } } @@ -144,6 +151,77 @@ async function liveWorkerCount(page: Page): Promise { return page.evaluate(() => (window as any).__liveWorkerCount__()) } +// Another origin, which has no Service Worker, for the outputs of app builds +const BUILT_ORIGIN = 'https://built.vrowzer.test' + +const CONTENT_TYPES: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.map': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain' +} + +interface BuiltApp extends Fixture { + pageErrors: string[] + missingFiles: string[] +} + +/** + * Serves the outputs under `prefix` of another origin, as a static server does, and opens `path` + * there in a new browser context. + */ +async function openBuiltApp( + files: Record, + prefix: string, + path = '' +): Promise { + const context = await browser.newContext() + const missingFiles: string[] = [] + await context.route(`${BUILT_ORIGIN}/**`, route => { + const { pathname } = new URL(route.request().url()) + let file = pathname.startsWith(prefix) ? pathname.slice(prefix.length) : undefined + if (file === '' || file?.endsWith('/')) { + file += 'index.html' + } + const content = file === undefined ? undefined : files[file] + if (content === undefined) { + if (pathname !== '/favicon.ico') { + missingFiles.push(pathname) + } + return route.fulfill({ status: 404, body: 'Not Found' }) + } + return route.fulfill({ + status: 200, + contentType: CONTENT_TYPES[extname(file!)] ?? 'application/octet-stream', + body: typeof content === 'string' ? content : Buffer.from(content.bytes) + }) + }) + const page = await context.newPage() + const pageErrors: string[] = [] + page.on('pageerror', error => pageErrors.push(error.message)) + await page.goto(`${BUILT_ORIGIN}${prefix}${path}`) + return { context, page, pageErrors, missingFiles } +} + +function findFile(files: Record, pattern: RegExp): string { + const file = Object.keys(files).find(name => pattern.test(name)) + if (file === undefined) { + throw new Error(`No output matches ${pattern}: ${Object.keys(files).join(', ')}`) + } + return file +} + +function text(files: Record, file: string): string { + const content = files[file] + if (typeof content !== 'string') { + throw new Error(`${file} is not a text output`) + } + return content +} + beforeAll(async () => { await build({ root: FIXTURE_DIR, logLevel: 'silent' }) server = await preview({ @@ -448,6 +526,10 @@ describe('vrowzer.build()', () => { test.each([ [{ build: { rolldownOptions: { input: '/src/index.ts' } } }, 'build.rolldownOptions.input'], + [ + { build: { rolldownOptions: { input: ['/index.html', '/nested/index.html'] } } }, + 'build.rolldownOptions.input' + ], [{ build: { lib: { entry: '/src/index.ts', formats: ['umd'] } } }, 'build.lib.formats'], [{ build: { lib: { entry: '/src/index.ts' }, cssMinify: true } }, 'build.cssMinify'] ])('rejects options that the browser build does not support (%j)', async (options, option) => { @@ -468,6 +550,188 @@ describe('vrowzer.build()', () => { }) }) +describe('vrowzer.build() of HTML apps', () => { + test.each([ + ['/', '/'], + ['/app/', '/app/'], + // A relative base works under any path + ['./', '/sub/dir/'] + ])( + 'builds an app with base %j that runs at %s of another origin without the Service Worker', + async (base, prefix) => { + const { context, page } = await openFixture({}, 'app') + try { + const { files } = await expectBuilt(page, { base }) + const binaries: { big: number[]; photo: number[] } = await page.evaluate( + () => (window as any).__appBinaries__ + ) + // Nothing of the dev server: the HMR client, the preview URLs and the Service Worker + const code = Object.values(files) + .filter(content => typeof content === 'string') + .join('\n') + expect(code).not.toContain('/@vite/client') + expect(code).not.toContain('import.meta.hot') + expect(code).not.toMatch(/\/preview\/[\da-f]{12}\//) + expect(code).not.toContain('serviceWorker') + + const app = await openBuiltApp(files, prefix) + try { + await expect + .poll(() => app.page.evaluate(() => document.body.dataset.lazy ?? ''), { + timeout: 15_000 + }) + .toBe('lazy ok') + const state = await app.page.evaluate(async () => { + const bytes = async (url: string) => + Array.from(new Uint8Array(await (await fetch(url)).arrayBuffer())) + const element = document.querySelector('#app') as HTMLElement + const style = getComputedStyle(element) + const { bigUrl, photoUrl, message } = document.body.dataset + return { + text: element.textContent, + color: style.color, + fontWeight: style.fontWeight, + background: style.backgroundColor, + small: (document.querySelector('#small') as HTMLImageElement).src, + big: await bytes((document.querySelector('#big') as HTMLImageElement).src), + bigUrl: await bytes(bigUrl!), + photo: await bytes(photoUrl!), + message, + robots: await (await fetch(new URL('robots.txt', document.baseURI))).text(), + controller: navigator.serviceWorker.controller, + registrations: (await navigator.serviceWorker.getRegistrations()).length + } + }) + + expect(state.text).toBe('app ok') + // The CSS of the HTML, of the entry, and of the lazy chunk + expect(state.color).toBe('rgb(0, 128, 0)') + expect(state.fontWeight).toBe('700') + expect(state.background).toBe('rgb(255, 255, 0)') + // A small SVG is inlined. The images keep their bytes. + expect(state.small).toMatch(/^data:image\/svg\+xml,/) + expect(state.big).toEqual(binaries.big) + expect(state.bigUrl).toEqual(binaries.big) + expect(state.photo).toEqual(binaries.photo) + expect(state.message).toBe('Hello from a text file\n') + expect(state.robots).toBe('User-agent: *\n') + expect(state.controller).toBeNull() + expect(state.registrations).toBe(0) + expect(app.pageErrors).toEqual([]) + // Every URL of the outputs is under the prefix + expect(app.missingFiles).toEqual([]) + } finally { + await app.context.close() + } + } finally { + await context.close() + } + }, + 60_000 + ) + + test('rewrites the HTML, and splits and preloads the chunks, with source maps', async () => { + const { context, page } = await openFixture({}, 'app') + try { + const { files } = await expectBuilt(page, { build: { minify: false, sourcemap: true } }) + + const entry = findFile(files, /^assets\/index-[\w-]+\.js$/) + const css = findFile(files, /^assets\/index-[\w-]+\.css$/) + const lazy = findFile(files, /^assets\/lazy-[\w-]+\.js$/) + const lazyCss = findFile(files, /^assets\/lazy-[\w-]+\.css$/) + const big = findFile(files, /^assets\/big-[\w-]+\.png$/) + findFile(files, /^assets\/photo-[\w-]+\.png$/) + expect(files['robots.txt']).toBe('User-agent: *\n') + + const html = text(files, 'index.html') + expect(html).toContain(``) + 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 9b663f0a..1cb3a66d 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, From 180a1dc00404ee2305cbb18ded372a5badaf6a46 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sat, 3 Oct 2026 01:14:27 +0900 Subject: [PATCH 6/6] docs(vrowzer): document HTML app builds - README: `build()` builds an app from its HTML entry, or a library with `build.lib`. The entry of an app, what the build of an app does, the three kinds of `base`, and the outputs that run on a static server. - The list of what is not supported yet is split into the options that the build rejects (now including an app with more than one entry or with an entry that is not HTML), and what it does not check: `import.meta.glob()`, variables in the paths of dynamic imports and of `new URL()`, the Workers of the project, `.env` files, tsconfig and the CommonJS files of dependencies. CSS Modules are not tested yet. - The API reference of `build()` is generated again. --- packages/vrowzer/README.md | 26 ++++++++++++++----- .../docs/default/interfaces/Vrowzer.md | 7 ++--- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index 40e91676..b0c1c05e 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -295,24 +295,30 @@ vrowzer.reloadPreview() #### `build(options?): Promise` -Builds the project for production in a build Worker, and resolves with the outputs and the warnings. Only library builds are supported for now: +Builds the project for production in a build Worker, and resolves with the outputs and the warnings. It builds an app from its HTML entry, or a library with `build.lib`: ```ts // vite.config.ts VrowzerPlugin({ build: true }) -// application.ts -const { files, warnings } = await vrowzer.build({ +// application.ts: an app from `/index.html` +const app = await vrowzer.build({ base: './' }) +// app.files: { 'index.html': '...', 'assets/index-[hash].js': '...', 'assets/index-[hash].css': '...', ... } + +// a library from `/src/index.ts` +const lib = await vrowzer.build({ define: { __VERSION__: JSON.stringify('1.0.0') }, build: { lib: { entry: '/src/index.ts', fileName: 'my-lib' }, sourcemap: true } }) -// files: { 'my-lib.js': '...', 'my-lib.js.map': '...', 'my-lib.css': '...' } +// lib.files: { 'my-lib.js': '...', 'my-lib.js.map': '...', 'my-lib.css': '...' } ``` - It needs `build: true` in the options of `@vrowzer/vite-plugin`. Without it, `build()` rejects, and the host output does not include the builder (rolldown and the Vite build). +- An app builds from one HTML entry: `/index.html`, or another HTML file of the project with `build.rolldownOptions.input`, e.g. `/nested/index.html`. As in Vite, the HTML is rewritten to the outputs, the CSS and the assets are emitted, and `?raw`, `?url`, `new URL('./file', import.meta.url)` and `/public` work. Dynamic imports become chunks, which are preloaded with their CSS (`modulepreload`, with its polyfill unless `build.modulePreload` turns it off). +- `base` can be absolute, e.g. `/` (the default) or `/app/`, or relative (`./`), which works under any path. The outputs of an app run on a static server, without the Service Worker, the dev server or the HMR client. - The build uses the project files as they are when `build()` is called: the files of `ready()`, with the default `/index.html` when they have none, and the changes of `addFile()`, `updateFile()` and `deleteFile()` called before. Changes made later do not reach the running build. - Each build runs in a new build Worker, with the plugins of the Worker config bundled for production (`process.env.NODE_ENV` is `"production"` there). The build Worker is terminated when the build ends, so nothing stays between builds, and the previews are not affected. - The options are a subset of the Vite config (`base`, `mode`, `define` and `build`), merged over the Worker config. They are sent to the build Worker, so they must be values that `postMessage()` can copy, e.g. no functions. The builder sets `root` to `/`, and `build.write` and `build.emptyOutDir` to `false`. @@ -321,14 +327,20 @@ const { files, warnings } = await vrowzer.build({ - 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, the next build waits before it creates its build Worker. The wait does not count toward `buildTimeout`. - A failed build rejects with `VrowzerBuildError`. Its `errors` have the message, code, plugin, module (`id`), location and code frame of each error, without colors, and its message summarizes the first one. A build that does not finish within `buildTimeout`, an aborted `signal` and `dispose()` reject it with an `Error`, and terminate the build Worker. -Not supported yet: +These options are not supported yet, and reject with a `VrowzerBuildError` whose code is `VROWZER_UNSUPPORTED_OPTION`: -- HTML app builds, i.e. builds without `build.lib` +- an app with more than one entry, or with an entry that is not HTML (build a JavaScript entry with `build.lib`) - library formats other than `es`, and libraries with more than one entry - CSS minification (`build.cssMinify`), and the `terser` and `esbuild` minifiers - `build.watch`, SSR builds, manifests and license files -These options reject with a `VrowzerBuildError` whose code is `VROWZER_UNSUPPORTED_OPTION`. As in Vite, a library keeps `process.env.NODE_ENV` for its users; replace it with `define` if needed. The build is tested in Chromium only. +These are not supported yet either, and the build does not check them: + +- `import.meta.glob()`, and variables in the paths of dynamic imports and of `new URL(..., import.meta.url)`. The build does not include the files that they point to, so they fail when they run. +- the Workers of the project (`new Worker(new URL(...))` and `?worker`) +- `.env` files, the options of tsconfig, and the CommonJS files of dependencies for production + +As in Vite, a library keeps `process.env.NODE_ENV` for its users; replace it with `define` if needed. CSS Modules are not tested yet, and the build is tested in Chromium only. #### `dispose(): Promise` diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index dd3c68c4..33467d86 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -57,7 +57,7 @@ be partly applied then; write the file again to resynchronize. build(options?: VrowzerBuildOptions): 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 [Vrowzer.ready](#method-ready), with the default `/index.html` when they have none, and the changes of @@ -65,8 +65,9 @@ the file methods called before. Changes made later are not included, even before 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