diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index 6121cbe..348dbaa 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -426,6 +426,7 @@ const webWorkerBuilderConfig = defineConfig({ createEagerWorkerTransformerImportsPlugin(), createHostViteHelperIsolationPlugin(), createBuilderGlobalsGuardPlugin(), + createPreloadHelperGuardPlugin(), ...createTransformerPlugins({ copyAssets: false, guardAggregate: 'node/web-worker-builder.js', @@ -582,6 +583,8 @@ function createEagerWorkerTransformerImportsPlugin(): Plugin { './asset', './clientInjections', './define', + './modulePreloadPolyfill', + './assetImportMetaUrl', './forwardConsole', ]], // buildEnvironment() loads rolldown lazily, while the static graph imports it as well @@ -738,6 +741,54 @@ function createBuilderGlobalsGuardPlugin(): Plugin { } } +function createPreloadHelperGuardPlugin(): Plugin { + // `getPreloadCode()` of the build inlines `preload()` into the output with `toString()`, with the + // helper that it calls. The host build of an app may minify the builder, which renames the helper, + // also where `preload()` calls it. So the preload code must declare the helper under its runtime + // name (`.name`), and `preload()` must not call other functions of the bundle. + return { + name: 'guard-preload-helper', + generateBundle(_options, bundle) { + const entry = Object.values(bundle).find( + output => output.type === 'chunk' && output.fileName === 'node/web-worker-builder.js' + ) + if (!entry || entry.type !== 'chunk') { + this.error('[guard-preload-helper] Could not find node/web-worker-builder.js entry') + } + const { code } = entry + const helper = /const seen = \{\};const \$\{(\w+)\.name\} = \$\{\1\.toString\(\)\};/.exec(code)?.[1] + if (!helper) { + this.error('[guard-preload-helper] The preload code must declare isCssPreloadUrl under its runtime name') + } + const preload = /const preloadMethodCode = (\w+)\.toString\(\)/.exec(code)?.[1] + const start = preload ? code.indexOf(`\nfunction ${preload}(`) : -1 + const end = start < 0 ? -1 : code.indexOf('\n}\n', start) + if (start < 0 || end < 0) { + this.error('[guard-preload-helper] Could not find preload()') + } + const source = code.slice(start, end) + const calls = new Set( + Array.from(source.matchAll(/(? match[1]) + ) + if (!calls.has(helper)) { + this.error(`[guard-preload-helper] preload() does not call ${helper}()`) + } + for (const name of calls) { + if ( + name !== helper && + name !== preload && + code.includes(`\nfunction ${name}(`) && + !source.includes(`function ${name}(`) + ) { + this.error( + `[guard-preload-helper] preload() calls ${name}(), which the preload code does not declare` + ) + } + } + }, + } +} + function createRuntimeNodeEnvGuardPlugin(): Plugin { // `resolveConfig()` decides `isProduction` from `process.env.NODE_ENV` at runtime, so that the dev // Web Worker and the build Worker can share the code. Stop the build when the bundle fixes it. diff --git a/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/index.html b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/index.html new file mode 100644 index 0000000..e69de29 diff --git a/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg new file mode 100644 index 0000000..dc1ced5 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vite-dev-server/src/node/build.test.ts b/packages/vite-dev-server/src/node/build.test.ts index 936c5c5..17fc787 100644 --- a/packages/vite-dev-server/src/node/build.test.ts +++ b/packages/vite-dev-server/src/node/build.test.ts @@ -1,4 +1,5 @@ import fs from 'node:fs' +import fsp from 'node:fs/promises' import os from 'node:os' import { basename, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -6,6 +7,7 @@ import { stripVTControlCharacters } from 'node:util' import colors from 'picocolors' import type { LogLevel, + OutputAsset, OutputChunk, OutputOptions, RolldownOptions, @@ -16,12 +18,18 @@ import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vite // NOTE(kazupon): vite-dev-server loads rolldown from the browser build (`@vrowzer/rolldown`). // The unit tests run in Node, so they build with the Node build of the same rolldown version. +// Vitest gives the original module to the second of two concurrent dynamic imports of a mocked +// module, so the tests run the builds one after another. vi.mock('@vrowzer/rolldown', () => import('rolldown')) vi.mock('@vrowzer/rolldown/experimental', () => import('rolldown/experimental')) vi.mock('@vrowzer/rolldown/parseAst', () => import('rolldown/parseAst')) vi.mock('@vrowzer/rolldown/utils', () => import('rolldown/utils')) -import type { LibraryFormats, LibraryOptions } from './build' +import type { + BuildEnvironmentOptions, + LibraryFormats, + LibraryOptions, +} from './build' import { BuildEnvironment, ChunkMetadataMap, @@ -35,10 +43,10 @@ import { import { resolveConfig } from './config' import type { Logger } from './logger' import { createLogger } from './logger' +import { injectQuery } from './utils' // Ported from upstream Vite (`packages/vite/src/node/__tests__/build.spec.ts`). // NOTE(kazupon): not ported yet: -// - the builds of HTML, CSS and assets (they need `buildHtmlPlugin` and the other build plugins) // - the SSR builds, `sharedConfigBuild`, `chunkImportMap`, the watch mode and the manifest // - `config.tsconfig` (Vite 8.3), which the resolved config does not have yet @@ -56,6 +64,111 @@ afterAll(() => { }) describe('build', () => { + test('file hash should change when css changes for dynamic entries', async () => { + const buildProject = async (cssColor: string) => { + return (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + build: { + write: false, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if ( + id === 'entry.js' || + id === 'subentry.js' || + id === 'foo.css' + ) { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return `window.addEventListener('click', () => { import('subentry.js') });` + } + if (id === '\0subentry.js') { + return `import 'foo.css'` + } + if (id === '\0foo.css') { + return `.foo { color: ${cssColor} }` + } + }, + }, + ], + })) as RolldownOutput + } + // NOTE(kazupon): one after another, as the note of the mocks says + // const result = await Promise.all([ + // buildProject('red'), + // buildProject('blue'), + // ]) + const result = [await buildProject('red'), await buildProject('blue')] + expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(` + { + "changed": [ + "index", + "_subentry.css", + ], + "unchanged": [ + "undefined", + ], + } + `) + assertOutputHashContentChange(result[0], result[1]) + }) + + test('file hash should change when renderBuiltUrl changes', async () => { + const createRenderBuiltUrl = (base: string) => (filename: string) => + `${base}/${filename}` + const renderBuiltUrlA = createRenderBuiltUrl('/cdn-a') + const renderBuiltUrlB = createRenderBuiltUrl('/cdn-b') + + expect(renderBuiltUrlA.toString()).toBe(renderBuiltUrlB.toString()) + + // NOTE(kazupon): one after another, as the note of the mocks says + // const result = await Promise.all([ + // buildProjectWithRenderBuiltUrl(renderBuiltUrlA), + // buildProjectWithRenderBuiltUrl(renderBuiltUrlB), + // ]) + const result = [await buildProjectWithRenderBuiltUrl(renderBuiltUrlA), await buildProjectWithRenderBuiltUrl(renderBuiltUrlB)] + + expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(` + { + "changed": [ + "index", + ], + "unchanged": [ + "_subentry", + "asset.txt", + "undefined", + ], + } + `) + assertOutputHashContentChange(result[0], result[1]) + }) + + test('renderBuiltUrl receives asset postfixes in JS and CSS', async () => { + const result = await buildProjectWithRenderBuiltUrl( + (filename) => injectQuery(filename, 'dpl=id'), + true, + ) + const entry = result.output.find( + (output): output is OutputChunk => + output.type === 'chunk' && output.isEntry, + ) + const css = result.output.find( + (output): output is OutputAsset => + output.type === 'asset' && output.fileName.endsWith('.css'), + ) + + expect(entry?.code).toContain('?dpl=id&marker=value') + expect(entry?.code).toContain('?dpl=id&marker=other') + expect(css?.source.toString()).toContain('?dpl=id&marker=value') + expect(css?.source.toString()).toContain('?dpl=id&marker=other') + }) + test('top-level input is used as the default build entry', async () => { const result = (await build({ root: resolve(dirname, 'packages/build-project'), @@ -115,6 +228,134 @@ describe('build', () => { expect(chunk?.code).toContain('from-virtual-top-level-input') }) + test('file hash should change when pure css chunk changes', async () => { + const buildProject = async (cssColor: string) => { + return (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + build: { + write: false, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if ( + id === 'entry.js' || + id === 'foo.js' || + id === 'bar.js' || + id === 'baz.js' || + id === 'foo.css' || + id === 'bar.css' || + id === 'baz.css' + ) { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return ` + window.addEventListener('click', () => { import('foo.js') }); + window.addEventListener('click', () => { import('bar.js') });` + } + if (id === '\0foo.js') {return `import 'foo.css'; import 'baz.js'`} + if (id === '\0bar.js') {return `import 'bar.css'; import 'baz.js'`} + if (id === '\0baz.js') {return `import 'baz.css'`} + if (id === '\0foo.css') {return `.foo { color: red }`} + if (id === '\0bar.css') {return `.foo { color: green }`} + if (id === '\0baz.css') {return `.foo { color: ${cssColor} }`} + }, + }, + ], + })) as RolldownOutput + } + // NOTE(kazupon): one after another, as the note of the mocks says + // const result = await Promise.all([ + // buildProject('yellow'), + // buildProject('blue'), + // ]) + const result = [await buildProject('yellow'), await buildProject('blue')] + expect(getOutputHashChanges(result[0], result[1])).toMatchInlineSnapshot(` + { + "changed": [ + "index", + "_bar", + "_foo", + "_baz.css", + ], + "unchanged": [ + "_bar.css", + "_foo.css", + "undefined", + ], + } + `) + assertOutputHashContentChange(result[0], result[1]) + }) + + test.for([ + [true, true], + [true, false], + [false, true], + [false, false], + ['auto', true], + ['auto', false], + ] as const)( + 'large json object files should have tree-shaking (json.stringify: %s, json.namedExports: %s)', + async ([stringify, namedExports]) => { + const esBundle = (await build({ + mode: 'development', + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + json: { stringify, namedExports }, + build: { + minify: false, + modulePreload: { polyfill: false }, + write: false, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if ( + id === 'entry.js' || + id === 'object.json' || + id === 'array.json' + ) { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return ` + import object from 'object.json'; + import array from 'array.json'; + console.log(); + ` + } + if (id === '\0object.json') { + return ` + {"value": {"${stringify}_${namedExports}":"JSON_OBJ${'_'.repeat(10_000)}"}} + ` + } + if (id === '\0array.json') { + return ` + ["${stringify}_${namedExports}","JSON_ARR${'_'.repeat(10_000)}"] + ` + } + }, + }, + ], + })) as RolldownOutput + + const foo = esBundle.output.find( + (chunk) => chunk.type === 'chunk' && chunk.isEntry, + ) as OutputChunk + expect(foo.code).not.contains('JSON_ARR') + expect(foo.code).not.contains('JSON_OBJ') + }, + ) + test('external modules should not be hoisted in library build', async () => { const [esBundle] = (await build({ logLevel: 'silent', @@ -757,9 +998,6 @@ describe('onRollupLog', () => { rolldownOptions: { ...options, logLevel: 'debug', - // NOTE(kazupon): build the JS entry directly, because the HTML entry (`index.html`) needs - // `buildHtmlPlugin`, which is not ported yet - input: 'entry.js', }, }, customLogger: logger, @@ -895,6 +1133,29 @@ describe('onRollupLog', () => { }) // vrowzer: builds resolve packages with the native resolve plugin (`viteResolvePlugin`), as upstream does +test('copies public directory after building same environment with write false first', async (ctx) => { + const root = resolve(dirname, 'fixtures/public-dir-write-false') + ctx.onTestFinished(() => + fsp.rm(resolve(root, 'dist'), { recursive: true, force: true }), + ) + + const builder = await createBuilder({ + root, + configFile: false, + logLevel: 'silent', + }) + + builder.environments.client.config.build.write = false + await builder.build(builder.environments.client) + + builder.environments.client.config.build.write = true + await builder.build(builder.environments.client) + + await expect( + fsp.readFile(resolve(root, 'dist/favicon.svg'), 'utf-8'), + ).resolves.toBe('') +}) + describe('package resolution in library builds', () => { let root: string @@ -967,3 +1228,288 @@ describe('package resolution in library builds', () => { expect(chunk.code).not.toMatch(/exports-node|exports-default|browser-main|module-main/) }) }) + +describe('new URL(..., import.meta.url) in builds', () => { + let root: string + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + test('emits the files that the URLs point to', async () => { + // NOTE(kazupon): the temporary directory of macOS is behind a symbolic link (`/var` to + // `/private/var`). Resolve it, so that the HTML entry is inside the root. + root = fs.realpathSync(fs.mkdtempSync(join(os.tmpdir(), 'vrowzer-build-url-'))) + const image = new Uint8Array([137, 80, 78, 71, 1, 2, 3, 4]) + const files: Record = { + 'index.html': ``, + 'main.js': [ + `export const image = new URL('./image.png', import.meta.url).href`, + `export const publicFile = new URL('/robots.txt', import.meta.url).href`, + `export const missing = new URL('./missing.png', import.meta.url).href`, + ].join('\n'), + 'image.png': image, + 'public/robots.txt': 'User-agent: *', + } + for (const [file, content] of Object.entries(files)) { + const filePath = join(root, file) + fs.mkdirSync(resolve(filePath, '..'), { recursive: true }) + fs.writeFileSync(filePath, content) + } + + const { output } = (await build({ + root, + logLevel: 'silent', + build: { + write: false, + minify: false, + assetsInlineLimit: 0, + }, + })) as RolldownOutput + + const entry = output.find( + (o): o is OutputChunk => o.type === 'chunk' && o.isEntry, + )! + const asset = output.find( + (o): o is OutputAsset => o.type === 'asset' && o.fileName.endsWith('.png'), + )! + expect(asset.fileName).toMatch(/^assets\/image-[\w-]+\.png$/) + expect([...(asset.source as Uint8Array)]).toEqual([...image]) + expect(entry.code).toContain( + `new URL("/${asset.fileName}", "" + import.meta.url)`, + ) + expect(entry.code).toContain(`new URL("/robots.txt", "" + import.meta.url)`) + // a file that does not exist is left for the runtime + expect(entry.code).toContain(`new URL("./missing.png", "" + import.meta.url)`) + }) +}) + +describe('HTML entries', () => { + const buildHtmlProject = async ( + modulePreload?: BuildEnvironmentOptions['modulePreload'], + ) => + (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + build: { + write: false, + minify: false, + modulePreload, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if (id === 'entry.js' || id === 'style.css') { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return `import 'style.css'\nconsole.log('from-html-entry')` + } + if (id === '\0style.css') { + return `h1 { color: red }` + } + }, + }, + ], + })) as RolldownOutput + + const findOutputs = (output: RolldownOutput['output']) => ({ + html: output.find( + (o): o is OutputAsset => o.type === 'asset' && o.fileName === 'index.html', + ), + entry: output.find( + (o): o is OutputChunk => o.type === 'chunk' && o.isEntry, + ), + css: output.find( + (o): o is OutputAsset => o.type === 'asset' && o.fileName.endsWith('.css'), + ), + }) + + test('rewrites the scripts and the styles of the HTML, and injects the module preload polyfill', async () => { + const { html, entry, css } = findOutputs((await buildHtmlProject()).output) + + expect(html?.source).toContain( + ``, + ) + expect(html?.source).toContain( + ``, + ) + expect(html?.source).toContain('

Hello world

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