From 47c2559c754f7a26a8cb6f9f9a998607999abfe0 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 19:37:28 +0900 Subject: [PATCH 01/11] fix(vite-dev-server): rename the bundled injectQuery helper in every transformer chunk The single-file Web Worker aggregate already renames the bundled `__vite__injectQuery` helper, because the host Vite injects its own `__vite__injectQuery` import into a module that has a dynamic import of a variable. The chunked build (transformer.js, vite.js and their chunks) did not. A chunk that declares the helper and also gets the host's import fails to load with "Identifier '__vite__injectQuery' has already been declared". A Worker config that imports `vite` then stops the preview's Web Worker with an error that has no message. Nothing breaks today, because the chunk that holds the helper gets no such import. Which chunk holds it depends on how rolldown groups the modules, though, and the build code of #36 changes that grouping. - Rename the helper in every chunk that declares it, as the aggregate does. - Stop the build when a chunk still declares `__vite__injectQuery`, or when no chunk holds the renamed helper. --- packages/vite-dev-server/rolldown.config.ts | 46 ++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index c605cd6..61f51cc 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -359,7 +359,10 @@ const transformerConfig = defineConfig({ process: '@vrowzer/node-polyfill/process', }, }, - plugins: createTransformerPlugins({ copyAssets: true }), + plugins: [ + createChunkedHostViteHelperIsolationPlugin(), + ...createTransformerPlugins({ copyAssets: true }), + ], }) const webWorkerTransformerConfig = defineConfig({ @@ -630,6 +633,47 @@ function createHostViteHelperIsolationPlugin(): Plugin { } } +function createChunkedHostViteHelperIsolationPlugin(): Plugin { + // The chunked build (`transformer.js`, `vite.js` and their chunks) needs the same isolation. + // The host Vite injects its own `__vite__injectQuery` import into a chunk that has a dynamic + // import of a variable, so a chunk that also declares the bundled helper fails to load + // ("Identifier '__vite__injectQuery' has already been declared"). Which chunk holds the helper + // depends on how rolldown groups the modules, so rename it in every chunk that declares it. + const declaration = 'function __vite__injectQuery(' + const toStringReference = '__vite__injectQuery.toString()' + return { + name: 'isolate-host-vite-helper-chunked', + renderChunk(code) { + if (!code.includes(declaration)) { + return null + } + + return code + .replace(declaration, 'function __vrowzer_internalInjectQuery(') + .replace(toStringReference, '__vrowzer_internalInjectQuery.toString()') + }, + generateBundle(_options, bundle) { + let renamed = false + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') { + continue + } + if (output.code.includes(declaration)) { + this.error( + `[isolate-host-vite-helper-chunked] ${output.fileName} still declares Vite injectQuery` + ) + } + renamed ||= output.code.includes('function __vrowzer_internalInjectQuery(') + } + if (!renamed) { + this.error( + '[isolate-host-vite-helper-chunked] Could not find Vite injectQuery implementation' + ) + } + }, + } +} + function createPostcssShimPlugin(): Plugin { return shimDepsPlugin({ 'postcss-import/index.js': [ From 1f05a63010d3b26354e39df53c617a881d0a5319 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 19:42:32 +0900 Subject: [PATCH 02/11] refactor(vite-dev-server): set process.env.NODE_ENV at runtime as upstream does The bundles of vite-dev-server replaced `process.env.NODE_ENV` with "development", so `resolveConfig()` resolved `isProduction` to false even for a build. The build Worker of #36 runs the same code and needs "production". - The bundles keep `process.env.NODE_ENV` as a reference. rolldown replaces it for the browser platform by default, so it is defined to itself. - `resolveConfig()` handles `NODE_ENV` as upstream does again. It reads and sets `process.env.NODE_ENV`, which is the process polyfill of each Worker, and reads `process.env.VITE_USER_NODE_ENV`, instead of `import.meta.env`. The lookup through Vite Task stays commented out. - A bundle check stops the build when `isProduction` is fixed again. The dev Web Worker still gets "development", because `resolveConfig()` sets it before the code that reads it runs. The new unit tests pin the upstream behavior of `resolveConfig()`. Under vitest, `import.meta.env` reflects `process.env`, so they pass with the old code too, and only the bundle check catches it. --- packages/vite-dev-server/rolldown.config.ts | 31 ++++++++++- .../vite-dev-server/src/node/config.test.ts | 55 +++++++++++++++++++ packages/vite-dev-server/src/node/config.ts | 33 +++++------ 3 files changed, 99 insertions(+), 20 deletions(-) diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index 61f51cc..c371f89 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -44,7 +44,10 @@ const sharedNodeOptions = defineConfig({ platform: 'browser', transform: { define: { - 'process.env.NODE_ENV': JSON.stringify('development'), // vrowzer always runs in dev mode (resolveConfig uses this for isProduction) + // `resolveConfig()` sets `process.env.NODE_ENV` at runtime, as upstream does: "development" in + // the dev Web Worker and "production" in the build Worker. Keep the reference, because rolldown + // replaces it for the browser platform by default. + 'process.env.NODE_ENV': 'process.env.NODE_ENV', 'process.platform': JSON.stringify('browser'), // for `tinyglobby` polyfill '__VROWZER_SERVICE_WORKER__': 'false', // default: not Service Worker (overridden in serviceWorkerConfig) '__VROWZER_ROLLDOWN_VERSION__': JSON.stringify(rolldownPackage.version), @@ -329,6 +332,7 @@ function createTransformerPlugins(options: { createRewriteRolldownUrlsPlugin(), ...(options.copyAssets ? [createCopyRolldownAssetsPlugin()] : []), createValidateRolldownVersionPlugin(), + createRuntimeNodeEnvGuardPlugin(), ...(options.guardAggregate ? [createWebWorkerTransformerGuardPlugin()] : []), ] } @@ -674,6 +678,31 @@ function createChunkedHostViteHelperIsolationPlugin(): Plugin { } } +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. + const runtimeCheck = 'const isProduction = process.env.NODE_ENV === "production"' + const fixedCheck = /\bconst isProduction = (?:true|false);/ + return { + name: 'guard-runtime-node-env', + generateBundle(_options, bundle) { + let found = false + for (const output of Object.values(bundle)) { + if (output.type !== 'chunk') { + continue + } + if (fixedCheck.test(output.code)) { + this.error(`[guard-runtime-node-env] ${output.fileName} fixes isProduction`) + } + found ||= output.code.includes(runtimeCheck) + } + if (!found) { + this.error('[guard-runtime-node-env] Could not find the NODE_ENV check of resolveConfig()') + } + }, + } +} + function createPostcssShimPlugin(): Plugin { return shimDepsPlugin({ 'postcss-import/index.js': [ diff --git a/packages/vite-dev-server/src/node/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts index d8b6c6d..77599bd 100644 --- a/packages/vite-dev-server/src/node/config.test.ts +++ b/packages/vite-dev-server/src/node/config.test.ts @@ -243,6 +243,61 @@ describe('input config', () => { }) }) +describe('resolveConfig NODE_ENV', () => { + let nodeEnv: string | undefined + + beforeEach(() => { + nodeEnv = process.env.NODE_ENV + delete process.env.NODE_ENV + }) + + afterEach(() => { + if (nodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = nodeEnv + } + }) + + test('sets production for a build', async () => { + const config = await resolveConfig( + createInlineConfig(), + 'build', + 'production', + 'production', + ) + + expect(process.env.NODE_ENV).toBe('production') + expect(config.isProduction).toBe(true) + expect(config.env.PROD).toBe(true) + expect(config.env.DEV).toBe(false) + }) + + test('sets development for serve', async () => { + const config = await resolveConfig(createInlineConfig(), 'serve') + + expect(process.env.NODE_ENV).toBe('development') + expect(config.isProduction).toBe(false) + expect(config.env.PROD).toBe(false) + expect(config.env.DEV).toBe(true) + }) + + test('keeps NODE_ENV when it is already set', async () => { + process.env.NODE_ENV = 'development' + + const config = await resolveConfig( + createInlineConfig(), + 'build', + 'production', + 'production', + ) + + expect(process.env.NODE_ENV).toBe('development') + expect(config.isProduction).toBe(false) + expect(config.env.PROD).toBe(false) + }) +}) + describe('resolveConfig per-environment isBundled', () => { test('defaults serve environments to unbundled', async () => { const config = await resolveConfig( diff --git a/packages/vite-dev-server/src/node/config.ts b/packages/vite-dev-server/src/node/config.ts index 7ce9d13..cde5caf 100644 --- a/packages/vite-dev-server/src/node/config.ts +++ b/packages/vite-dev-server/src/node/config.ts @@ -1403,27 +1403,24 @@ export async function resolveConfig( let configFileDependencies: string[] = [] let mode = inlineConfig.mode || defaultMode - // NOTE(kazupon): In browser/Worker environments, import.meta.env may be statically replaced - // by the bundler (e.g. Vite replaces import.meta.env.NODE_ENV with "production" or undefined). - // Direct assignment like `import.meta.env.NODE_ENV = ...` would become `undefined = ...` or - // `"production" = ...` which throws in strict mode (ESM Workers). - // Use try-catch to safely handle this. - const isNodeEnvSet = !!import.meta.env.NODE_ENV - // NOTE(kazupon): comment out because we need to understand the previous implementation as background - // const isNodeEnvSet = !!process.env.NODE_ENV + // NOTE(kazupon): comment out because Vite Task does not run in the browser + // // When `NODE_ENV` isn't set locally, ask Vite Task for it; the runner + // // also records the env in the build's cache key. + // if (process.env.NODE_ENV === undefined) { + // const nodeEnv = getEnv('NODE_ENV') + // if (nodeEnv !== undefined) { + // process.env.NODE_ENV = nodeEnv + // } + // } + // NOTE(kazupon): `process` is the process polyfill of each Worker. The bundles do not fix + // `process.env.NODE_ENV`, so the dev Web Worker and the build Worker set it here, as Node does. + const isNodeEnvSet = !!process.env.NODE_ENV const packageCache: PackageCache = new Map() // some dependencies e.g. @vue/compiler-* relies on NODE_ENV for getting // production-specific behavior, so set it early on if (!isNodeEnvSet) { - try { - import.meta.env.NODE_ENV = defaultNodeEnv - } catch { - // In bundled environments, import.meta.env.NODE_ENV may be replaced with a literal - // value by the bundler, making assignment impossible. This is safe to ignore. - } - // NOTE(kazupon): comment out because we need to understand the previous implementation as background - // process.env.NODE_ENV = defaultNodeEnv + process.env.NODE_ENV = defaultNodeEnv } const configEnv: ConfigEnv = { @@ -1688,9 +1685,7 @@ export async function resolveConfig( // Note it is possible for user to have a custom mode, e.g. `staging` where // development-like behavior is expected. This is indicated by NODE_ENV=development // loaded from `.staging.env` and set by us as VITE_USER_NODE_ENV - // NOTE(kazupon): comment out because we need to understand the previous implementation as background - // const userNodeEnv = process.env.VITE_USER_NODE_ENV - const userNodeEnv = import.meta.env.VITE_USER_NODE_ENV + const userNodeEnv = process.env.VITE_USER_NODE_ENV if (!isNodeEnvSet && userNodeEnv) { if (userNodeEnv === 'development') { process.env.NODE_ENV = 'development' From d839055629968f9f2f353bda6463cb98f54aadc0 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 19:54:10 +0900 Subject: [PATCH 03/11] feat(vite-dev-server): port the build core from upstream Vite Port the build core of Vite 8.3.1 (`build.ts`), so that the build Worker of #36 can run upstream's `build()`. It builds with `@vrowzer/rolldown`, the browser build of rolldown. - build.ts: `build()`, `createBuilder()`, `resolveConfigToBuild()`, `resolveRolldownOptions()`, `buildEnvironment()`, the library helpers (`resolveLibFilename()`, `resolveBuildOutputs()`), the log handling (`onRollupLog()`, `enhanceRollupError()`), `ChunkMetadataMap` and the hook wrappers that inject the environment. - Commented out with `NOTE(kazupon)`: the watch mode (`build.watch` throws), `esmExternalRequirePlugin` for SSR webworker builds, the DevTools, and the build plugins that the browser build does not support yet (preload, the esbuild and terser minifiers, license, manifest and Worker builds). - `config.tsconfig` and `build.chunkImportMap` (Vite 8.3) are read without their types, because the fork's config does not have them yet. - The classes of `@vrowzer/rolldown` are not assignable to the `rolldown` types, so the bundle is cast. - `resolveBuildPlugins()`: `vite:prepare-out-dir` (ported; it does nothing with `write: false`), `vite:rollup-options-plugins`, the native reporter (ported) and the native load fallback. - `resolvePlugins()` registers `vite:define` and the `pre` build plugins for builds. The dev Web Worker keeps handling `define` in clientInjections. - types/metadata.d.ts is synced with upstream (`AssetMetadata`). - The Worker aggregate loads `./define` and `@vrowzer/rolldown` eagerly. The unit tests port upstream's tests of the library helpers, the option resolution and the log handling. They build with the Node rolldown of the same version in place of `@vrowzer/rolldown`. The tests of HTML, CSS, SSR and watch builds are left for later stages. --- packages/vite-dev-server/rolldown.config.ts | 3 + .../packages/build-project/asset.txt | 1 + .../packages/build-project/index.html | 3 + .../__tests__/packages/module/package.json | 4 + .../node/__tests__/packages/name/package.json | 3 + .../__tests__/packages/noname/package.json | 1 + .../src/node/__tests__/packages/package.json | 3 + .../vite-dev-server/src/node/build.test.ts | 893 +++++++++++ packages/vite-dev-server/src/node/build.ts | 1345 ++++++++++++++++- .../vite-dev-server/src/node/config.test.ts | 23 + .../src/node/plugins/asset.test.ts | 2 + .../src/node/plugins/css.test.ts | 2 + .../vite-dev-server/src/node/plugins/index.ts | 8 +- .../src/node/plugins/prepareOutDir.ts | 110 ++ .../src/node/plugins/reporter.ts | 28 + packages/vite-dev-server/types/metadata.d.ts | 9 + 16 files changed, 2374 insertions(+), 64 deletions(-) create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/build-project/asset.txt create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/build-project/index.html create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/module/package.json create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/name/package.json create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/noname/package.json create mode 100644 packages/vite-dev-server/src/node/__tests__/packages/package.json create mode 100644 packages/vite-dev-server/src/node/build.test.ts create mode 100644 packages/vite-dev-server/src/node/plugins/prepareOutDir.ts create mode 100644 packages/vite-dev-server/src/node/plugins/reporter.ts diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index c371f89..9b88f2c 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -545,8 +545,11 @@ function createEagerWorkerTransformerImportsPlugin(): Plugin { './importAnalysis', './asset', './clientInjections', + './define', './forwardConsole', ]], + // buildEnvironment() loads rolldown lazily, while the static graph imports it as well + ['src/node/build.ts', ['@vrowzer/rolldown']], ]) return { diff --git a/packages/vite-dev-server/src/node/__tests__/packages/build-project/asset.txt b/packages/vite-dev-server/src/node/__tests__/packages/build-project/asset.txt new file mode 100644 index 0000000..2bd69c0 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/build-project/asset.txt @@ -0,0 +1 @@ +asset diff --git a/packages/vite-dev-server/src/node/__tests__/packages/build-project/index.html b/packages/vite-dev-server/src/node/__tests__/packages/build-project/index.html new file mode 100644 index 0000000..f05e0c9 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/build-project/index.html @@ -0,0 +1,3 @@ +

Hello world

+ + diff --git a/packages/vite-dev-server/src/node/__tests__/packages/module/package.json b/packages/vite-dev-server/src/node/__tests__/packages/module/package.json new file mode 100644 index 0000000..67756e1 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/module/package.json @@ -0,0 +1,4 @@ +{ + "name": "mylib", + "type": "module" +} diff --git a/packages/vite-dev-server/src/node/__tests__/packages/name/package.json b/packages/vite-dev-server/src/node/__tests__/packages/name/package.json new file mode 100644 index 0000000..43a1356 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/name/package.json @@ -0,0 +1,3 @@ +{ + "name": "mylib" +} diff --git a/packages/vite-dev-server/src/node/__tests__/packages/noname/package.json b/packages/vite-dev-server/src/node/__tests__/packages/noname/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/noname/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/vite-dev-server/src/node/__tests__/packages/package.json b/packages/vite-dev-server/src/node/__tests__/packages/package.json new file mode 100644 index 0000000..bd6442d --- /dev/null +++ b/packages/vite-dev-server/src/node/__tests__/packages/package.json @@ -0,0 +1,3 @@ +{ + "name": "named-testing-package" +} diff --git a/packages/vite-dev-server/src/node/build.test.ts b/packages/vite-dev-server/src/node/build.test.ts new file mode 100644 index 0000000..1e39c93 --- /dev/null +++ b/packages/vite-dev-server/src/node/build.test.ts @@ -0,0 +1,893 @@ +import { basename, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { stripVTControlCharacters } from 'node:util' +import colors from 'picocolors' +import type { + LogLevel, + OutputChunk, + OutputOptions, + RolldownOptions, + RolldownOutput, + RollupLog, +} from 'rolldown' +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from 'vite-plus/test' + +// 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. +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 { + BuildEnvironment, + ChunkMetadataMap, + build, + createBuilder, + onRollupLog, + resolveBuildOutputs, + resolveLibFilename, + resolveRolldownOptions, +} from './build' +import { resolveConfig } from './config' +import type { Logger } from './logger' +import { createLogger } from './logger' + +// 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 + +// NOTE(kazupon): the fixtures are in `__tests__`, as upstream has them next to its spec +const dirname = fileURLToPath(new URL('./__tests__', import.meta.url)) + +type FormatsToFileNames = [LibraryFormats, string][] + +beforeAll(() => { + vi.stubGlobal('__VROWZER_SERVICE_WORKER__', false) +}) + +afterAll(() => { + vi.unstubAllGlobals() +}) + +describe('build', () => { + test('top-level input is used as the default build entry', async () => { + const result = (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + input: 'top-level-entry.js', + build: { + write: false, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + if (id.replace(/\\/g, '/').endsWith('top-level-entry.js')) { + return '\0top-level-entry.js' + } + }, + load(id) { + if (id === '\0top-level-entry.js') { + return `console.log('from-top-level-input')` + } + }, + }, + ], + })) as RolldownOutput + const chunk = result.output.find((o) => o.type === 'chunk') + expect(chunk?.fileName).toContain('top-level-entry') + expect(chunk?.code).toContain('from-top-level-input') + }) + + test('top-level input can be a virtual module for build', async () => { + const input = 'virtual:entry' + const resolvedInput = `\0${input}` + const result = (await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'silent', + input, + build: { + write: false, + }, + plugins: [ + { + name: 'virtual-entry', + resolveId(id) { + if (id === input) { + return resolvedInput + } + }, + load(id) { + if (id === resolvedInput) { + return `console.log('from-virtual-top-level-input')` + } + }, + }, + ], + })) as RolldownOutput + const chunk = result.output.find((o) => o.type === 'chunk') + expect(chunk?.code).toContain('from-virtual-top-level-input') + }) + + test('external modules should not be hoisted in library build', async () => { + const [esBundle] = (await build({ + logLevel: 'silent', + build: { + lib: { + entry: ['foo.js', 'bar.js'], + formats: ['es'], + }, + rolldownOptions: { + external: 'external', + }, + write: false, + }, + plugins: [ + { + name: 'test', + resolveId(id) { + const name = basename(id) + if (name === 'foo.js' || name === 'bar.js') { + return name + } + }, + load(id) { + if (id === 'foo.js') { + return ` + import bar from 'bar.js' + export default bar() + ` + } + if (id === 'bar.js') { + return ` + import ext from 'external'; + export default ext();` + } + }, + }, + ], + })) as RolldownOutput[] + + // NOTE(kazupon): the default root is `/` (the virtual file system of the Worker), which has no + // package.json here, so the es output gets `.mjs` instead of upstream's `.js` + const foo = esBundle.output.find( + (chunk) => chunk.fileName === 'foo.mjs', + // (chunk) => chunk.fileName === 'foo.js', + ) as OutputChunk + expect(foo.code).not.contains('import "external"') + }) +}) + +const baseLibOptions: LibraryOptions = { + fileName: 'my-lib', + entry: 'mylib.js', +} + +describe('resolveBuildOutputs', () => { + test('resolves outputs correctly', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { ...baseLibOptions } + const outputs: OutputOptions[] = [{ format: 'es' }] + const resolvedOutputs = resolveBuildOutputs(outputs, libOptions, logger) + + expect(resolvedOutputs).toEqual([ + { + format: 'es', + }, + ]) + }) + + test('resolves outputs from lib options', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { ...baseLibOptions, name: 'lib' } + const resolvedOutputs = resolveBuildOutputs(void 0, libOptions, logger) + + expect(resolvedOutputs).toEqual([ + { + format: 'es', + }, + { + format: 'umd', + }, + ]) + }) + + test('does not change outputs when lib options are missing', () => { + const logger = createLogger() + const outputs: OutputOptions[] = [{ format: 'es' }] + const resolvedOutputs = resolveBuildOutputs(outputs, false, logger) + + expect(resolvedOutputs).toEqual(outputs) + }) + + test('logs a warning when outputs is an array and formats are specified', () => { + const logger = createLogger() + const loggerSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + const libOptions: LibraryOptions = { + ...baseLibOptions, + formats: ['iife'], + } + const outputs: OutputOptions[] = [{ format: 'es' }] + + resolveBuildOutputs(outputs, libOptions, logger) + + expect(loggerSpy).toHaveBeenCalledWith( + expect.stringContaining('"build.lib.formats" will be ignored because'), + ) + }) + + test('throws an error when lib.name is missing on iife format', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { + ...baseLibOptions, + formats: ['iife'], + } + const resolveBuild = () => resolveBuildOutputs(void 0, libOptions, logger) + + expect(resolveBuild).toThrowError(/Option "build\.lib\.name" is required/) + }) + + test('throws an error when lib.name is missing on umd format', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { ...baseLibOptions, formats: ['umd'] } + const resolveBuild = () => resolveBuildOutputs(void 0, libOptions, logger) + + expect(resolveBuild).toThrowError(/Option "build\.lib\.name" is required/) + }) + + test('throws an error when output.name is missing on iife format', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { ...baseLibOptions } + const outputs: OutputOptions[] = [{ format: 'iife' }] + const resolveBuild = () => resolveBuildOutputs(outputs, libOptions, logger) + + expect(resolveBuild).toThrowError( + /Entries in "build\.rolldownOptions\.output" must specify "name"/, + ) + }) + + test('throws an error when output.name is missing on umd format', () => { + const logger = createLogger() + const libOptions: LibraryOptions = { ...baseLibOptions } + const outputs: OutputOptions[] = [{ format: 'umd' }] + const resolveBuild = () => resolveBuildOutputs(outputs, libOptions, logger) + + expect(resolveBuild).toThrowError( + /Entries in "build\.rolldownOptions\.output" must specify "name"/, + ) + }) + + describe('input resolution in resolveRolldownOptions', () => { + const buildProjectRoot = resolve(dirname, 'packages/build-project') + + test('build.rolldownOptions.input takes precedence over top-level input', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + input: 'top-level-entry.js', + build: { + rolldownOptions: { input: 'explicit-entry.js' }, + }, + }) + const options = resolveRolldownOptions( + builder.environments.client, + new ChunkMetadataMap(), + ) + expect(options.input).toBe('explicit-entry.js') + }) + + // NOTE(kazupon): the resolved config does not have `tsconfig` (Vite 8.3) yet + // test('top-level tsconfig applies to Rolldown options', async () => { + // const builder = await createBuilder({ + // root: buildProjectRoot, + // logLevel: 'silent', + // tsconfig: './custom.tsconfig.json', + // build: { + // rolldownOptions: { + // tsconfig: './other.tsconfig.json', + // resolve: { tsconfigFilename: './legacy.tsconfig.json' }, + // }, + // }, + // }) + // const options = resolveRolldownOptions( + // builder.environments.client, + // new ChunkMetadataMap(), + // ) + // expect(options.tsconfig).toBe( + // normalizePath(resolve(buildProjectRoot, 'custom.tsconfig.json')), + // ) + // expect(options.resolve?.tsconfigFilename).toBeUndefined() + // }) + + test('falls back to index.html when no input is set', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + }) + const options = resolveRolldownOptions( + builder.environments.client, + new ChunkMetadataMap(), + ) + expect(options.input).toBe(resolve(buildProjectRoot, 'index.html')) + }) + + test('build.ssr string entry takes precedence over input', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + environments: { + ssr: { + input: 'env-input.js', + build: { ssr: 'ssr-entry.js' }, + }, + }, + }) + const options = resolveRolldownOptions( + builder.environments.ssr, + new ChunkMetadataMap(), + ) + expect(options.input).toBe(resolve(buildProjectRoot, 'ssr-entry.js')) + }) + + test('throws when build.lib is set without entry or top-level input', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + build: { + lib: { name: 'MyLib', formats: ['es'] }, + }, + }) + expect(() => + resolveRolldownOptions( + builder.environments.client, + new ChunkMetadataMap(), + ), + ).toThrow( + /Either "build\.lib\.entry" or the top-level "input" option is required/, + ) + }) + }) + + describe('output minify and comments resolution in resolveRolldownOptions', () => { + const buildProjectRoot = resolve(dirname, 'packages/build-project') + + test('merges a partial output.minify object with the lib defaults', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + build: { + minify: 'oxc', + lib: { ...baseLibOptions, formats: ['es'] }, + rolldownOptions: { + output: { + minify: { mangle: { keepNames: true } }, + }, + }, + }, + }) + const options = resolveRolldownOptions( + builder.environments.client, + new ChunkMetadataMap(), + ) + const outputs = options.output! + const output = Array.isArray(outputs) ? outputs[0] : outputs + expect(output.minify).toStrictEqual({ + compress: true, + mangle: { keepNames: true }, + codegen: false, + }) + }) + + test('keeps an explicit boolean output.minify untouched', async () => { + const builder = await createBuilder({ + root: buildProjectRoot, + logLevel: 'silent', + build: { + minify: 'oxc', + lib: { ...baseLibOptions, formats: ['es'] }, + rolldownOptions: { + output: { + minify: false, + }, + }, + }, + }) + const options = resolveRolldownOptions( + builder.environments.client, + new ChunkMetadataMap(), + ) + const outputs = options.output! + const output = Array.isArray(outputs) ? outputs[0] : outputs + expect(output.minify).toBe(false) + }) + }) +}) + +describe('resolveLibFilename', () => { + test('custom filename function', () => { + const filename = resolveLibFilename( + { + fileName: (format) => `custom-filename-function.${format}.js`, + entry: 'mylib.js', + }, + 'es', + 'myLib', + resolve(dirname, 'packages/name'), + ) + + expect(filename).toBe('custom-filename-function.es.js') + }) + + test('custom filename string', () => { + const filename = resolveLibFilename( + { + fileName: 'custom-filename', + entry: 'mylib.js', + }, + 'es', + 'myLib', + resolve(dirname, 'packages/name'), + ) + + expect(filename).toBe('custom-filename.mjs') + }) + + test('package name as filename', () => { + const filename = resolveLibFilename( + { + entry: 'mylib.js', + }, + 'es', + 'myLib', + resolve(dirname, 'packages/name'), + ) + + expect(filename).toBe('mylib.mjs') + }) + + test('custom filename and no package name', () => { + const filename = resolveLibFilename( + { + fileName: 'custom-filename', + entry: 'mylib.js', + }, + 'es', + 'myLib', + resolve(dirname, 'packages/noname'), + ) + + expect(filename).toBe('custom-filename.mjs') + }) + + test('missing filename', () => { + const filename = resolveLibFilename( + { + entry: 'mylib.js', + }, + 'es', + 'myLib', + resolve(dirname, 'packages/noname'), + ) + expect(filename).toBe('named-testing-package.mjs') + }) + + test('commonjs package extensions', () => { + const formatsToFilenames: FormatsToFileNames = [ + ['es', 'my-lib.mjs'], + ['umd', 'my-lib.umd.js'], + ['cjs', 'my-lib.js'], + ['iife', 'my-lib.iife.js'], + ] + + for (const [format, expectedFilename] of formatsToFilenames) { + const filename = resolveLibFilename( + baseLibOptions, + format, + 'myLib', + resolve(dirname, 'packages/noname'), + ) + + expect(filename).toBe(expectedFilename) + } + }) + + test('module package extensions', () => { + const formatsToFilenames: FormatsToFileNames = [ + ['es', 'my-lib.js'], + ['umd', 'my-lib.umd.cjs'], + ['cjs', 'my-lib.cjs'], + ['iife', 'my-lib.iife.js'], + ] + + for (const [format, expectedFilename] of formatsToFilenames) { + const filename = resolveLibFilename( + baseLibOptions, + format, + 'myLib', + resolve(dirname, 'packages/module'), + ) + + expect(expectedFilename).toBe(filename) + } + }) + + test('multiple entries with aliases', () => { + const libOptions: LibraryOptions = { + entry: { + entryA: 'entryA.js', + entryB: 'entryB.js', + }, + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('entryA.mjs') + expect(fileName2).toBe('entryB.mjs') + }) + + test('multiple entries with aliases: custom filename function', () => { + const libOptions: LibraryOptions = { + entry: { + entryA: 'entryA.js', + entryB: 'entryB.js', + }, + fileName: (format, entryAlias) => + `custom-filename-function.${entryAlias}.${format}.js`, + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('custom-filename-function.entryA.es.js') + expect(fileName2).toBe('custom-filename-function.entryB.es.js') + }) + + test('multiple entries with aliases: custom filename string', () => { + const libOptions: LibraryOptions = { + entry: { + entryA: 'entryA.js', + entryB: 'entryB.js', + }, + fileName: 'custom-filename', + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('custom-filename.mjs') + expect(fileName2).toBe('custom-filename.mjs') + }) + + test('multiple entries as array', () => { + const libOptions: LibraryOptions = { + entry: ['entryA.js', 'entryB.js'], + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('entryA.mjs') + expect(fileName2).toBe('entryB.mjs') + }) + + test('multiple entries as array: custom filename function', () => { + const libOptions: LibraryOptions = { + entry: ['entryA.js', 'entryB.js'], + fileName: (format, entryAlias) => + `custom-filename-function.${entryAlias}.${format}.js`, + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('custom-filename-function.entryA.es.js') + expect(fileName2).toBe('custom-filename-function.entryB.es.js') + }) + + test('multiple entries as array: custom filename string', () => { + const libOptions: LibraryOptions = { + entry: ['entryA.js', 'entryB.js'], + fileName: 'custom-filename', + } + + const [fileName1, fileName2] = ['entryA', 'entryB'].map((entryAlias) => + resolveLibFilename( + libOptions, + 'es', + entryAlias, + resolve(dirname, 'packages/name'), + ), + ) + + expect(fileName1).toBe('custom-filename.mjs') + expect(fileName2).toBe('custom-filename.mjs') + }) +}) + +describe('resolveBuildOutputs', () => { + test('default format: one entry', () => { + const libOptions: LibraryOptions = { + entry: 'entryA.js', + name: 'entryA', + } + + expect(resolveBuildOutputs(undefined, libOptions, {} as Logger)).toEqual([ + { format: 'es' }, + { format: 'umd' }, + ]) + expect( + resolveBuildOutputs({ name: 'A' }, libOptions, {} as Logger), + ).toEqual([ + { format: 'es', name: 'A' }, + { format: 'umd', name: 'A' }, + ]) + expect( + resolveBuildOutputs([{ name: 'A' }], libOptions, {} as Logger), + ).toEqual([{ name: 'A' }]) + }) + + test('default format: multiple entries', () => { + const libOptions: LibraryOptions = { + entry: ['entryA.js', 'entryB.js'], + } + + expect(resolveBuildOutputs(undefined, libOptions, {} as Logger)).toEqual([ + { format: 'es' }, + { format: 'cjs' }, + ]) + expect( + resolveBuildOutputs({ name: 'A' }, libOptions, {} as Logger), + ).toEqual([ + { format: 'es', name: 'A' }, + { format: 'cjs', name: 'A' }, + ]) + expect( + resolveBuildOutputs([{ name: 'A' }], libOptions, {} as Logger), + ).toEqual([{ name: 'A' }]) + }) + + test('umd or iife: should not support multiple entries', () => { + ;['umd', 'iife'].forEach((format) => { + expect(() => + resolveBuildOutputs( + undefined, + { + entry: ['entryA.js', 'entryB.js'], + formats: [format as LibraryFormats], + }, + {} as Logger, + ), + ).toThrow( + `Multiple entry points are not supported when output formats include "umd" or "iife".`, + ) + }) + }) + + test('umd or iife: should define build.lib.name', () => { + ;['umd', 'iife'].forEach((format) => { + expect(() => + resolveBuildOutputs( + undefined, + { + entry: 'entryA.js', + formats: [format as LibraryFormats], + }, + {} as Logger, + ), + ).toThrow( + `Option "build.lib.name" is required when output formats include "umd" or "iife".`, + ) + }) + }) + + test('array outputs: should ignore build.lib.formats', () => { + const log = { warn: vi.fn() } as unknown as Logger + expect( + resolveBuildOutputs( + [{ name: 'A' }], + { + entry: 'entryA.js', + formats: ['es'], + }, + log, + ), + ).toEqual([{ name: 'A' }]) + expect(log.warn).toHaveBeenLastCalledWith( + colors.yellow( + `"build.lib.formats" will be ignored because "build.rolldownOptions.output" is already an array format.`, + ), + ) + }) + + // NOTE(kazupon): not ported yet: `ssrEmitAssets`, `emitAssets`, `ssr builtin` and `ssr custom` (SSR builds) +}) + +describe('onRollupLog', () => { + const pluginName = 'rollup-plugin-test' + const msgInfo = 'This is the INFO message.' + const msgWarn = 'This is the WARN message.' + const buildProject = async ( + level: LogLevel | 'error', + message: string | RollupLog, + logger: Logger, + options?: Pick, + ) => { + await build({ + root: resolve(dirname, 'packages/build-project'), + logLevel: 'info', + build: { + write: false, + 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, + plugins: [ + { + name: pluginName, + resolveId(id) { + this[level](message) + if (id === 'entry.js') { + return '\0' + id + } + }, + load(id) { + if (id === '\0entry.js') { + return `export default "This is test module";` + } + }, + }, + ], + }) + } + + const callOnRollupLog = async ( + logger: Logger, + level: LogLevel, + log: RollupLog, + ) => { + const config = await resolveConfig( + { customLogger: logger }, + 'build', + 'production', + 'production', + ) + const buildEnvironment = new BuildEnvironment('client', config) + onRollupLog(level, log, buildEnvironment) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('Rollup logs of info should be handled by vite', async () => { + const logger = createLogger() + const loggerSpy = vi.spyOn(logger, 'info').mockImplementation(() => {}) + + await buildProject('info', msgInfo, logger) + const logs = loggerSpy.mock.calls.map((args) => + stripVTControlCharacters(args[0]), + ) + expect(logs).contain(`[plugin ${pluginName}] ${msgInfo}`) + }) + + test('Rollup logs of warn should be handled by vite', async () => { + const logger = createLogger('silent') + const loggerSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + await buildProject('warn', msgWarn, logger) + const logs = loggerSpy.mock.calls.map((args) => + stripVTControlCharacters(args[0]), + ) + expect(logs).contain(`[plugin ${pluginName}] ${msgWarn}`) + }) + + test('onLog passed by user is called', async () => { + const logger = createLogger('silent') + + const onLogInfo = vi.fn((_log: RollupLog) => {}) + await buildProject('info', msgInfo, logger, { + onLog(level, log) { + if (level === 'info') { + onLogInfo(log) + } + }, + }) + expect(onLogInfo).toBeCalledWith( + expect.objectContaining({ message: msgInfo, plugin: pluginName }), + ) + }) + + test('onwarn passed by user is called', async () => { + const logger = createLogger('silent') + + const onWarn = vi.fn((_log: RollupLog) => {}) + await buildProject('warn', msgWarn, logger, { + onwarn(warning) { + onWarn(warning) + }, + }) + expect(onWarn).toBeCalledWith( + expect.objectContaining({ message: msgWarn, plugin: pluginName }), + ) + }) + + test('should throw error when warning contains UNRESOLVED_IMPORT', async () => { + const logger = createLogger() + await expect(() => + callOnRollupLog(logger, 'warn', { + code: 'UNRESOLVED_IMPORT', + message: 'test', + }), + ).rejects.toThrowError(/Rolldown failed to resolve import/) + }) + + test.each([[`Unsupported expression`], [`statically analyzed`]])( + 'should ignore dynamic import warnings (%s)', + async (message: string) => { + const logger = createLogger() + const loggerSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + await callOnRollupLog(logger, 'warn', { + code: 'PLUGIN_WARNING', + message: message, + plugin: 'rollup-plugin-dynamic-import-variables', + }) + expect(loggerSpy).toBeCalledTimes(0) + }, + ) + + test.each([[`CIRCULAR_DEPENDENCY`], [`THIS_IS_UNDEFINED`]])( + 'should ignore some warnings (%s)', + async (code: string) => { + const logger = createLogger() + const loggerSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}) + + await callOnRollupLog(logger, 'warn', { + code: code, + message: 'test message', + plugin: pluginName, + }) + expect(loggerSpy).toBeCalledTimes(0) + }, + ) +}) diff --git a/packages/vite-dev-server/src/node/build.ts b/packages/vite-dev-server/src/node/build.ts index 4a31533..396d8e2 100644 --- a/packages/vite-dev-server/src/node/build.ts +++ b/packages/vite-dev-server/src/node/build.ts @@ -1,42 +1,104 @@ -import type { RollupCommonJSOptions } from '#dep-types/commonjs'; -import type { RollupDynamicImportVarsOptions } from '#dep-types/dynamicImportVars'; -import type { EsbuildTarget } from '#types/internal/esbuildOptions'; -import path from 'node:path'; +import type { RollupCommonJSOptions } from '#dep-types/commonjs' +import type { RollupDynamicImportVarsOptions } from '#dep-types/dynamicImportVars' +import type { EsbuildTarget } from '#types/internal/esbuildOptions' +import type { AssetMetadata, ChunkMetadata } from '#types/metadata' +import path from 'node:path' +import colors from 'picocolors' import type { - ExternalOption, - InputOption, - InternalModuleFormat, - ModuleFormat, - RolldownOptions, - RolldownOutput, - RolldownWatcher, - WatcherOptions -} from 'rolldown'; -import type { PartialEnvironment } from './baseEnvironment'; -import { BaseEnvironment } from './baseEnvironment'; + ExternalOption, + InputOption, + InternalModuleFormat, + LogLevel, + LogOrStringHandler, + MinimalPluginContext, + ModuleFormat, + OutputAsset, + OutputBundle, + OutputChunk, + OutputOptions, + PluginContext, + RenderedChunk, + RolldownBuild, + RolldownOptions, + RolldownOutput, + RolldownWatcher, + RollupError, + RollupLog, + WarningHandlerWithDefault, + WatcherOptions +} from 'rolldown' +// NOTE(kazupon): use @vrowzer/rolldown (browser build) instead of rolldown +import { viteLoadFallbackPlugin as nativeLoadFallbackPlugin } from '@vrowzer/rolldown/experimental' +// import { viteLoadFallbackPlugin as nativeLoadFallbackPlugin } from 'rolldown/experimental' +// NOTE(kazupon): `esmExternalRequirePlugin` is only for the SSR webworker target, which vrowzer does not build +// import { esmExternalRequirePlugin } from 'rolldown/plugins' +import type { PartialEnvironment } from './baseEnvironment' +import { BaseEnvironment } from './baseEnvironment' import type { - EnvironmentOptions, - ResolvedConfig, - ResolvedEnvironmentOptions -} from './config'; + EnvironmentOptions, + InlineConfig, + ResolvedConfig, + ResolvedEnvironmentOptions +} from './config' +import { resolveConfig } from './config' import { - DEFAULT_ASSETS_INLINE_LIMIT, - ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET -} from './constants'; -import type { Logger } from './logger'; -import type { MinimalPluginContextWithoutEnvironment, Plugin } from './plugin'; -import type { LicenseOptions } from './plugins/license'; -import type { TerserOptions } from './plugins/terser'; + DEFAULT_ASSETS_INLINE_LIMIT, + ESBUILD_BASELINE_WIDELY_AVAILABLE_TARGET, + ROLLUP_HOOKS, + VERSION +} from './constants' import { - joinUrlSegments, - mergeConfig, - mergeWithDefaults, - partialEncodeURIPath, - setupRollupOptionCompat, - unique -} from './utils'; - -// TODO: fill in later + isFutureDeprecationEnabled, + warnFutureDeprecation +} from './deprecations' +import type { Environment } from './environment' +import type { Logger } from './logger' +import { findNearestMainPackageData, findNearestPackageData } from './packages' +import type { PackageCache } from './packages' +import { perEnvironmentPlugin } from './plugin' +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' +// 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' +// import { manifestPlugin } from './plugins/manifest' +import { prepareOutDirPlugin } from './plugins/prepareOutDir' +import { buildReporterPlugin } from './plugins/reporter' +import type { TerserOptions } from './plugins/terser' +// import { type TerserOptions, terserPlugin } from './plugins/terser' +// NOTE(kazupon): comment out because the Worker builds are not supported yet +// import { webWorkerPostPlugin } from './plugins/worker' +import { + BasicMinimalPluginContext, + basePluginContextMeta +} from './server/pluginContainer' +// NOTE(kazupon): comment out because the browser build does not support manifest files yet +// import { ssrManifestPlugin } from './ssr/ssrManifestPlugin' +import type { RollupPluginHooks } from './typeUtils' +import { + arraify, + asyncFlatten, + createDebugger, + displayTime, + getPkgName, + joinUrlSegments, + mergeConfig, + mergeWithDefaults, + partialEncodeURIPath, + setupRollupOptionCompat, + unique +} from './utils' +// NOTE(kazupon): `build.watch` is not supported in the browser +// import { +// convertToWatcherOptions, +// getResolvedOutDirs, +// resolveChokidarOptions, +// resolveEmptyOutDir, +// } from './watch' export interface BuildEnvironmentOptions { /** @@ -454,45 +516,733 @@ export function resolveBuildEnvironmentOptions( return resolved } -export async function resolveBuildPlugins(config: ResolvedConfig): Promise<{ +export function resolveBuildPlugins(config: ResolvedConfig): { pre: Plugin[] post: Plugin[] -}> { +} { const isBuild = config.command === 'build' return { pre: [ - // TODO(kazupon): implement later ... - // ...(isBuild && !config.isWorker ? [prepareOutDirPlugin()] : []), - // perEnvironmentPlugin( - // 'vite:rollup-options-plugins', - // async (environment) => - // ( - // await asyncFlatten( - // arraify(environment.config.build.rollupOptions.plugins), - // ) - // ).filter(Boolean) as Plugin[], - // ), + ...(isBuild && !config.isWorker ? [prepareOutDirPlugin()] : []), + perEnvironmentPlugin( + 'vite:rollup-options-plugins', + async (environment) => { + if (!isBuild && !environment.config.isBundled) { + return false + } + return ( + await asyncFlatten( + arraify(environment.config.build.rolldownOptions.plugins), + ) + ).filter(Boolean) as Plugin[] + }, + ), + // NOTE(kazupon): comment out because the Worker builds (`new Worker(...)` in a build) are not supported yet // ...(config.isWorker ? [webWorkerPostPlugin(config)] : []), ], post: [ - // TODO(kazupon): implement later ... + // 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) : []), - // ...(config.build.minify === 'esbuild' ? [buildEsbuildPlugin()] : []), - // ...(isBuild ? [terserPlugin(config)] : []), - // ...(isBuild && !config.isWorker - // ? [ - // licensePlugin(), - // manifestPlugin(config), - // ssrManifestPlugin(), - // buildReporterPlugin(config), - // ] + // NOTE(kazupon): comment out because the browser build does not support the esbuild and terser minifiers + // ...(isBuild && config.build.minify === 'esbuild' + // ? [buildEsbuildPlugin()] // : []), - // nativeLoadFallbackPlugin(), + // ...(isBuild ? [terserPlugin(config)] : []), + ...(isBuild && !config.isWorker + ? [ + // NOTE(kazupon): comment out because the browser build does not support license and manifest files yet + // licensePlugin(), + // manifestPlugin(), + // ssrManifestPlugin(), + buildReporterPlugin(config), + ] + : []), + nativeLoadFallbackPlugin(), ], } } -// TODO: fill in later +/** + * Bundles a single environment for production. + * Returns a Promise containing the build result. + */ +export async function build( + inlineConfig: InlineConfig = {}, +): Promise { + const builder = await createBuilder(inlineConfig, true) + const environment = Object.values(builder.environments)[0] + if (!environment) {throw new Error('No environment found')} + return builder.build(environment) +} + +function resolveConfigToBuild( + inlineConfig: InlineConfig = {}, + patchConfig?: (config: ResolvedConfig) => void, + patchPlugins?: (resolvedPlugins: Plugin[]) => void, +): Promise { + return resolveConfig( + inlineConfig, + 'build', + 'production', + 'production', + false, + patchConfig, + patchPlugins, + ) +} + +export function resolveRolldownOptions( + environment: Environment, + chunkMetadataMap: ChunkMetadataMap, +): RolldownOptions { + const { root, packageCache, base, build: options } = environment.config + const libOptions = options.lib + const { logger } = environment + const ssr = environment.config.consumer === 'server' + + const resolve = (p: string) => path.resolve(root, p) + const topLevelInput = environment.config.input + if (libOptions && libOptions.entry == null) { + throw new Error( + `Either "build.lib.entry" or the top-level "input" option is required when "build.lib" is set.`, + ) + } + const input = libOptions + ? options.rolldownOptions.input || + (typeof libOptions.entry === 'string' + ? resolve(libOptions.entry) + : Array.isArray(libOptions.entry) + ? libOptions.entry.map(resolve) + : Object.fromEntries( + Object.entries(libOptions.entry!).map(([alias, file]) => [ + alias, + resolve(file), + ]), + )) + : typeof options.ssr === 'string' + ? resolve(options.ssr) + : options.rolldownOptions.input || + (topLevelInput ?? resolve('index.html')) + + if (ssr && typeof input === 'string' && input.endsWith('.html')) { + throw new Error( + `rolldownOptions.input should not be an html file when building for SSR. ` + + `Please specify a dedicated SSR entry.`, + ) + } + if (options.cssCodeSplit === false) { + const inputs = + typeof input === 'string' + ? [input] + : Array.isArray(input) + ? input + : Object.values(input) + if (inputs.some((input) => input.endsWith('.css'))) { + throw new Error( + `When "build.cssCodeSplit: false" is set, "rolldownOptions.input" should not include CSS files.`, + ) + } + } + + const outDir = resolve(options.outDir) + + // inject environment and ssr arg to plugin load/transform hooks + const plugins = environment.plugins.map((p) => + injectEnvironmentToHooks(environment, chunkMetadataMap, p), + ) + + const rolldownOptions: RolldownOptions = { + preserveEntrySignatures: ssr + ? 'allow-extension' + : libOptions + ? 'strict' + : false, + // cache: options.watch ? undefined : false, + ...options.rolldownOptions, + // NOTE(kazupon): the resolved config does not have `tsconfig` (Vite 8.3) yet, so read it without the type + // tsconfig: environment.config.tsconfig ?? options.rolldownOptions.tsconfig, + // resolve: environment.config.tsconfig + tsconfig: (environment.config as { tsconfig?: RolldownOptions['tsconfig'] }).tsconfig ?? options.rolldownOptions.tsconfig, + resolve: (environment.config as { tsconfig?: RolldownOptions['tsconfig'] }).tsconfig + ? { + ...options.rolldownOptions.resolve, + tsconfigFilename: undefined, + } + : options.rolldownOptions.resolve, + input, + plugins, + onLog(level, log) { + onRollupLog(level, log, environment) + }, + transform: { + target: options.target === false ? undefined : options.target, + ...options.rolldownOptions.transform, + define: { + ...options.rolldownOptions.transform?.define, + // disable builtin process.env.NODE_ENV replacement as it is handled by the define plugin + 'process.env.NODE_ENV': 'process.env.NODE_ENV', + }, + }, + // TODO: remove this and enable rolldown's CSS support later + moduleTypes: { + ...options.rolldownOptions.moduleTypes, + '.css': 'js', + }, + experimental: { + ...options.rolldownOptions.experimental, + viteMode: true, + // NOTE(kazupon): the build options do not have `chunkImportMap` (Vite 8.3) yet, so read it without the type + // chunkImportMap: options.chunkImportMap + chunkImportMap: (options as { chunkImportMap?: boolean }).chunkImportMap + ? { + ...(typeof options.rolldownOptions.experimental?.chunkImportMap === + 'object' + ? options.rolldownOptions.experimental?.chunkImportMap + : {}), + baseUrl: base, + } + : options.rolldownOptions.experimental?.chunkImportMap, + }, + } + + const isSsrTargetWebworkerEnvironment = + environment.name === 'ssr' && + environment.getTopLevelConfig().ssr?.target === 'webworker' + + // For webworker SSR with platform: 'browser', external CJS require() calls + // need to be converted to ESM imports since createRequire is not available. + // NOTE(kazupon): the SSR webworker target is not built by vrowzer + // if (isSsrTargetWebworkerEnvironment) { + // plugins.push(esmExternalRequirePlugin()) + // } + + const buildOutputOptions = (output: OutputOptions = {}): OutputOptions => { + // @ts-expect-error See https://github.com/vitejs/vite/issues/5812#issuecomment-984345618 + if (output.output) { + logger.warn( + `You've set "rolldownOptions.output.output" in your config. ` + + `This is deprecated and will override all Vite.js default output options. ` + + `Please use "rolldownOptions.output" instead.`, + ) + } + if (output.file) { + throw new Error( + `Vite does not support "rolldownOptions.output.file". ` + + `Please use "rolldownOptions.output.dir" and "rolldownOptions.output.entryFileNames" instead.`, + ) + } + if (output.sourcemap) { + logger.warnOnce( + colors.yellow( + `Vite does not support "rolldownOptions.output.sourcemap". ` + + `Please use "build.sourcemap" instead.`, + ), + ) + } + + const format = output.format || 'es' + const jsExt = + (ssr && !isSsrTargetWebworkerEnvironment) || libOptions + ? resolveOutputJsExtension( + format, + findNearestPackageData(root, packageCache)?.data.type, + ) + : 'js' + const resolvedMinify = + options.minify === 'oxc' + ? libOptions && (format === 'es' || format === 'esm') + ? { + compress: true, + mangle: true, + // Do not minify whitespace for ES lib output since that would remove + // pure annotations and break tree-shaking + codegen: false, + } + : true + : options.minify === false + ? 'dce-only' + : false + return { + dir: outDir, + // Default format is 'es' for regular and for SSR builds + format, + exports: 'auto', + sourcemap: options.sourcemap, + name: libOptions ? libOptions.name : undefined, + // hoistTransitiveImports: libOptions ? false : undefined, + // es2015 enables `generatedCode.symbols` + // - #764 add `Symbol.toStringTag` when build es module into cjs chunk + // - #1048 add `Symbol.toStringTag` for module default export + generatedCode: { + preset: 'es2015', + }, + entryFileNames: ssr + ? `[name].${jsExt}` + : libOptions + ? ({ name }) => + resolveLibFilename( + libOptions, + format, + name, + root, + jsExt, + packageCache, + ) + : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`), + chunkFileNames: libOptions + ? `[name]-[hash].${jsExt}` + : path.posix.join(options.assetsDir, `[name]-[hash].${jsExt}`), + assetFileNames: libOptions + ? `[name].[ext]` + : path.posix.join(options.assetsDir, `[name]-[hash].[ext]`), + codeSplitting: + output.codeSplitting ?? + (output.format === 'umd' || + output.format === 'iife' || + (isSsrTargetWebworkerEnvironment && + (typeof input === 'string' || Object.keys(input).length === 1)) + ? false + : undefined), + topLevelVar: true, + ...output, + minify: + output.minify === undefined + ? resolvedMinify + : typeof output.minify === 'object' && + typeof resolvedMinify === 'object' + ? { ...resolvedMinify, ...output.minify } + : output.minify, + comments: + typeof output.comments === 'boolean' + ? output.comments + : { + // Do not minify whitespace for ES lib output since that would remove + // pure annotations and break tree-shaking + annotation: + !options.minify || + (libOptions && (format === 'es' || format === 'esm')), + jsdoc: !options.minify, + legal: !options.minify, + ...output.comments, + }, + } + } + + // resolve lib mode outputs + const outputs = resolveBuildOutputs( + options.rolldownOptions.output, + libOptions, + logger, + ) + + if (Array.isArray(outputs)) { + rolldownOptions.output = outputs.map(buildOutputOptions) + } else { + rolldownOptions.output = buildOutputOptions(outputs) + } + + return rolldownOptions +} + +/** + * Build an App environment, or a App library (if libraryOptions is provided) + **/ +async function buildEnvironment( + environment: BuildEnvironment, +): Promise { + const { logger, config } = environment + const { root, build: options } = config + + logger.info( + colors.cyan( + `vite v${VERSION} ${colors.green( + `building ${environment.name} environment for ${environment.config.mode}...`, + )}`, + ), + ) + + let bundle: RolldownBuild | undefined + let startTime: number | undefined + try { + const chunkMetadataMap = new ChunkMetadataMap() + const rolldownOptions = resolveRolldownOptions( + environment, + chunkMetadataMap, + ) + + // NOTE(kazupon): `build.watch` is not supported in the browser. The builder of the build Worker rejects it + // before it gets here, so this only guards other callers. + if (options.watch) { + throw new Error('[vrowzer] build.watch is not supported') + } + // // watch file changes with rollup + // if (options.watch) { + // logger.info(colors.cyan(`\nwatching for file changes...`)) + // + // const resolvedOutDirs = getResolvedOutDirs( + // root, + // options.outDir, + // options.rolldownOptions.output, + // ) + // const emptyOutDir = resolveEmptyOutDir( + // options.emptyOutDir, + // root, + // resolvedOutDirs, + // logger, + // ) + // const resolvedChokidarOptions = resolveChokidarOptions( + // { + // // @ts-expect-error chokidar option does not exist in rolldown but used for backward compat + // ...(rolldownOptions.watch || {}).chokidar, + // // @ts-expect-error chokidar option does not exist in rolldown but used for backward compat + // ...options.watch.chokidar, + // }, + // resolvedOutDirs, + // emptyOutDir, + // environment.config.cacheDir, + // ) + // + // const { watch } = await import('rolldown') + // const watcher = watch({ + // ...rolldownOptions, + // watch: { + // ...rolldownOptions.watch, + // ...options.watch, + // watcher: convertToWatcherOptions(resolvedChokidarOptions), + // }, + // }) + // + // watcher.on('event', (event) => { + // if (event.code === 'BUNDLE_START') { + // logger.info(colors.cyan(`\nbuild started...`)) + // chunkMetadataMap.clearResetChunks() + // } else if (event.code === 'BUNDLE_END') { + // event.result.close() + // logger.info(colors.cyan(`built in ${event.duration}ms.`)) + // } else if (event.code === 'ERROR') { + // const e = event.error + // enhanceRollupError(e) + // clearLine() + // logger.error(e.message, { error: e }) + // } + // }) + // + // return watcher + // } + + // write or generate files with rolldown + // NOTE(kazupon): use @vrowzer/rolldown (browser build) instead of rolldown + const { rolldown } = await import('@vrowzer/rolldown') + // const { rolldown } = await import('rolldown') + startTime = Date.now() + // NOTE(kazupon): @vrowzer/rolldown is typed by @rolldown/browser, whose classes are not assignable to the rolldown types + bundle = (await rolldown(rolldownOptions as never)) as unknown as RolldownBuild + + const res: RolldownOutput[] = [] + for (const output of arraify(rolldownOptions.output!)) { + res.push(await bundle[options.write ? 'write' : 'generate'](output)) + } + for (const output of res) { + for (const chunk of output.output) { + injectChunkMetadata(chunkMetadataMap, chunk) + } + } + logger.info( + `${colors.green(`✓ built in ${displayTime(Date.now() - startTime)}`)}`, + ) + return Array.isArray(rolldownOptions.output) ? res : res[0] + } catch (e) { + enhanceRollupError(e) + clearLine() + if (startTime) { + logger.error( + `${colors.red('✗')} Build failed in ${displayTime(Date.now() - startTime)}`, + ) + startTime = undefined + } + throw e + } finally { + if (bundle) {await bundle.close()} + } +} + +export function enhanceRollupError(e: RollupError): void { + const stackOnly = extractStack(e) + + let msg = colors.red((e.plugin ? `[${e.plugin}] ` : '') + e.message) + if (e.loc && e.loc.file && e.loc.file !== e.id) { + msg += `\nfile: ${colors.cyan( + `${e.loc.file}:${e.loc.line}:${e.loc.column}` + + (e.id ? ` (${e.id})` : ''), + )}` + } else if (e.id) { + msg += `\nfile: ${colors.cyan( + e.id + (e.loc ? `:${e.loc.line}:${e.loc.column}` : ''), + )}` + } + if (e.frame) { + msg += `\n` + colors.yellow(normalizeCodeFrame(e.frame)) + } + + e.message = msg + + // We are rebuilding the stack trace to include the more detailed message at the top. + // Previously this code was relying on mutating e.message changing the generated stack + // when it was accessed, but we don't have any guarantees that the error we are working + // with hasn't already had its stack accessed before we get here. + if (stackOnly !== undefined) { + e.stack = `${e.message}\n${stackOnly}` + } +} + +/** + * The stack string usually contains a copy of the message at the start of the stack. + * If the stack starts with the message, we remove it and just return the stack trace + * portion. Otherwise the original stack trace is used. + */ +function extractStack(e: RollupError) { + const { stack, name = 'Error', message } = e + + // If we don't have a stack, not much we can do. + if (!stack) { + return stack + } + + const expectedPrefix = `${name}: ${message}\n` + if (stack.startsWith(expectedPrefix)) { + return stack.slice(expectedPrefix.length) + } + + return stack +} + +/** + * Esbuild code frames have newlines at the start and end of the frame, rollup doesn't + * This function normalizes the frame to match the esbuild format which has more pleasing padding + */ +function normalizeCodeFrame(frame: string) { + const trimmedPadding = frame.replace(/^\n|\n$/g, '') + return `\n${trimmedPadding}\n` +} + +type JsExt = 'js' | 'cjs' | 'mjs' + +function resolveOutputJsExtension( + format: ModuleFormat, + type: string = 'commonjs', +): JsExt { + if (type === 'module') { + return format === 'cjs' || format === 'umd' ? 'cjs' : 'js' + } else { + return format === 'es' ? 'mjs' : 'js' + } +} + +export function resolveLibFilename( + libOptions: LibraryOptions, + format: ModuleFormat, + entryName: string, + root: string, + extension?: JsExt, + packageCache?: PackageCache, +): string { + if (typeof libOptions.fileName === 'function') { + return libOptions.fileName(format, entryName) + } + + const packageJson = findNearestMainPackageData(root, packageCache)?.data + const name = + libOptions.fileName || + (packageJson && typeof libOptions.entry === 'string' + ? getPkgName(packageJson.name) + : entryName) + + if (!name) + {throw new Error( + 'Name in package.json is required if option "build.lib.fileName" is not provided.', + )} + + extension ??= resolveOutputJsExtension(format, packageJson?.type) + + if (format === 'cjs' || format === 'es') { + return `${name}.${extension}` + } + + return `${name}.${format}.${extension}` +} + +export function resolveBuildOutputs( + outputs: OutputOptions | OutputOptions[] | undefined, + libOptions: LibraryOptions | false, + logger: Logger, +): OutputOptions | OutputOptions[] | undefined { + if (libOptions) { + const libHasMultipleEntries = + typeof libOptions.entry !== 'string' && + libOptions.entry && + Object.values(libOptions.entry).length > 1 + const libFormats = + libOptions.formats || + (libHasMultipleEntries ? ['es', 'cjs'] : ['es', 'umd']) + + if (!Array.isArray(outputs)) { + if (libFormats.includes('umd') || libFormats.includes('iife')) { + if (libHasMultipleEntries) { + throw new Error( + 'Multiple entry points are not supported when output formats include "umd" or "iife".', + ) + } + + if (!libOptions.name) { + throw new Error( + 'Option "build.lib.name" is required when output formats include "umd" or "iife".', + ) + } + } + + return libFormats.map((format) => ({ ...outputs, format })) + } + + // By this point, we know "outputs" is an Array. + if (libOptions.formats) { + logger.warn( + colors.yellow( + '"build.lib.formats" will be ignored because "build.rolldownOptions.output" is already an array format.', + ), + ) + } + + outputs.forEach((output) => { + if ( + (output.format === 'umd' || output.format === 'iife') && + !output.name + ) { + throw new Error( + 'Entries in "build.rolldownOptions.output" must specify "name" when the format is "umd" or "iife".', + ) + } + }) + } + + return outputs +} + +const warningIgnoreList = [`CIRCULAR_DEPENDENCY`, `THIS_IS_UNDEFINED`] +const dynamicImportWarningIgnoreList = [ + `Unsupported expression`, + `statically analyzed`, +] + +export function clearLine(): void { + const tty = process.stdout.isTTY && !process.env.CI + if (tty) { + process.stdout.clearLine(0) + process.stdout.cursorTo(0) + } +} + +export function onRollupLog( + level: LogLevel, + log: RollupLog, + environment: Environment, +): void { + const debugLogger = createDebugger('vite:build') + const viteLog: LogOrStringHandler = (logLeveling, rawLogging) => { + const logging = + typeof rawLogging === 'object' ? rawLogging : { message: rawLogging } + + if (logging.code === 'UNRESOLVED_IMPORT') { + const id = logging.id + const exporter = logging.exporter + // throw unless it's commonjs external... + if (!id || !id.endsWith('?commonjs-external')) { + throw new Error( + `[vite]: Rolldown failed to resolve import "${exporter}" from "${id}".\n` + + `This is most likely unintended because it can break your application at runtime.\n` + + `If you do want to externalize this module explicitly add it to\n` + + `\`build.rolldownOptions.external\``, + ) + } + } + + if (logLeveling === 'warn') { + if ( + logging.plugin === 'rollup-plugin-dynamic-import-variables' && + dynamicImportWarningIgnoreList.some((msg) => + logging.message.includes(msg), + ) + ) { + return + } + + if (warningIgnoreList.includes(logging.code!)) { + return + } + } + + // append plugin name to align with Rollup's behavior + let message = logging.message + if (logging.plugin) { + message = `[plugin ${logging.plugin}] ${message}` + } + + switch (logLeveling) { + case 'info': + environment.logger.info(message) + return + case 'warn': + environment.logger.warn(colors.yellow(message)) + return + case 'error': + environment.logger.error(colors.red(message)) + return + case 'debug': + debugLogger?.(message) + return + default: + logLeveling satisfies never + // fallback to info if a unknown log level is passed + environment.logger.info(message) + return + } + } + + clearLine() + const userOnLog = environment.config.build.rolldownOptions?.onLog + const userOnWarn = environment.config.build.rolldownOptions?.onwarn + if (userOnLog) { + if (userOnWarn) { + const normalizedUserOnWarn = normalizeUserOnWarn(userOnWarn, viteLog) + userOnLog(level, log, normalizedUserOnWarn) + } else { + userOnLog(level, log, viteLog) + } + } else if (userOnWarn) { + const normalizedUserOnWarn = normalizeUserOnWarn(userOnWarn, viteLog) + normalizedUserOnWarn(level, log) + } else { + viteLog(level, log) + } +} + +function normalizeUserOnWarn( + userOnWarn: WarningHandlerWithDefault, + defaultHandler: LogOrStringHandler, +): LogOrStringHandler { + return (logLevel, logging) => { + if (logLevel === 'warn') { + userOnWarn(normalizeLog(logging), (log) => + defaultHandler('warn', typeof log === 'function' ? log() : log), + ) + } else { + defaultHandler(logLevel, logging) + } + } +} + +const normalizeLog = (log: RollupLog | string): RollupLog => + typeof log === 'string' ? { message: log } : log export function resolveUserExternal( user: ExternalOption, @@ -517,7 +1267,326 @@ function isExternal(id: string, test: string | RegExp) { } } -// TODO: fill in later +export class ChunkMetadataMap { + private _inner = new Map() + private _resetChunks = new Set() + + private _getKey(chunk: RenderedChunk | OutputChunk | OutputAsset): string { + return 'preliminaryFileName' in chunk + ? chunk.preliminaryFileName + : chunk.fileName + } + + private _getDefaultValue( + chunk: RenderedChunk | OutputChunk | OutputAsset, + ): ChunkMetadata | AssetMetadata { + return chunk.type === 'chunk' + ? { + importedAssets: new Set(), + importedCss: new Set(), + // NOTE: adding this as a workaround for now ideally we'd want to remove this workaround + // use shared `chunk.modules` object to allow mutation on js side plugins + __modules: chunk.modules, + } + : { + importedAssets: new Set(), + importedCss: new Set(), + } + } + + get( + chunk: RenderedChunk | OutputChunk | OutputAsset, + ): ChunkMetadata | AssetMetadata { + const key = this._getKey(chunk) + if (!this._inner.has(key)) { + this._inner.set(key, this._getDefaultValue(chunk)) + } + return this._inner.get(key)! + } + + // reset chunk metadata on the first RenderChunk call for watch mode + reset(chunk: RenderedChunk | OutputChunk | OutputAsset): void { + const key = this._getKey(chunk) + if (this._resetChunks.has(key)) {return} + + this._resetChunks.add(key) + this._inner.set(key, this._getDefaultValue(chunk)) + } + + clearResetChunks(): void { + this._resetChunks.clear() + } +} + +export function injectEnvironmentToHooks( + environment: Environment, + chunkMetadataMap: ChunkMetadataMap, + plugin: Plugin, +): Plugin { + const { resolveId, load, transform } = plugin + + // the plugin can be a class instance (e.g. native plugins) + const clone: Plugin = Object.assign( + Object.create(Object.getPrototypeOf(plugin)), + plugin, + ) + + for (const hook of Object.keys(clone) as RollupPluginHooks[]) { + switch (hook) { + case 'resolveId': + clone[hook] = wrapEnvironmentResolveId( + environment, + resolveId, + plugin.name, + ) + break + case 'load': + clone[hook] = wrapEnvironmentLoad(environment, load, plugin.name) + break + case 'transform': + clone[hook] = wrapEnvironmentTransform( + environment, + transform, + plugin.name, + ) + break + default: + if (ROLLUP_HOOKS.includes(hook)) { + ;(clone as any)[hook] = wrapEnvironmentHook( + environment, + chunkMetadataMap, + plugin, + hook, + ) + } + break + } + } + + return clone +} + +type AbstractHook = { + handler: Handler + filter?: unknown + order?: unknown +} +const wrappedHookMap = new WeakMap< + AbstractHook, + Array> +>() +function wrapHookObject< + Handler extends Function, + Hook extends AbstractHook, +>(hook: Hook, handler: Handler): Hook { + const newHook = { + ...hook, + handler, + } + + if (!wrappedHookMap.has(hook)) { + wrappedHookMap.set(hook, []) + Object.defineProperty(hook, 'filter', { + get() { + return wrappedHookMap.get(hook)![0].filter + }, + set(v) { + for (const h of wrappedHookMap.get(hook)!) { + h.filter = v + } + }, + }) + Object.defineProperty(hook, 'order', { + get() { + return wrappedHookMap.get(hook)![0].order + }, + set(v) { + for (const h of wrappedHookMap.get(hook)!) { + h.order = v + } + }, + }) + } + wrappedHookMap.get(hook)!.push(newHook) + + return newHook +} + +function wrapEnvironmentResolveId( + environment: Environment, + hook: Plugin['resolveId'] | undefined, + pluginName: string, +): Plugin['resolveId'] { + if (!hook) {return} + + const fn = getHookHandler(hook) + const handler: Plugin['resolveId'] = function (id, importer, options) { + return fn.call( + injectEnvironmentInContext(this, environment), + id, + importer, + injectSsrFlag(options, environment, pluginName), + ) + } + + if ('handler' in hook) { + return wrapHookObject(hook, handler) + } else { + return handler + } +} + +function wrapEnvironmentLoad( + environment: Environment, + hook: Plugin['load'] | undefined, + pluginName: string, +): Plugin['load'] { + if (!hook) {return} + + const fn = getHookHandler(hook) + const handler: Plugin['load'] = function (id, ...args) { + return fn.call( + injectEnvironmentInContext(this, environment), + id, + injectSsrFlag(args[0], environment, pluginName), + ) + } + + if ('handler' in hook) { + return wrapHookObject(hook, handler) + } else { + return handler + } +} + +function wrapEnvironmentTransform( + environment: Environment, + hook: Plugin['transform'] | undefined, + pluginName: string, +): Plugin['transform'] { + if (!hook) {return} + + const fn = getHookHandler(hook) + const handler: Plugin['transform'] = function (code, importer, ...args) { + return fn.call( + injectEnvironmentInContext(this, environment), + code, + importer, + injectSsrFlag(args[0], environment, pluginName), + ) + } + + if ('handler' in hook) { + return wrapHookObject(hook, handler) + } else { + return handler + } +} + +function wrapEnvironmentHook( + environment: Environment, + chunkMetadataMap: ChunkMetadataMap, + plugin: Plugin, + hookName: HookName, +): Plugin[HookName] { + const hook = plugin[hookName] + if (!hook) {return} + + const fn = getHookHandler(hook) + if (typeof fn !== 'function') {return hook} + + const handler: Plugin[HookName] = function ( + this: PluginContext, + ...args: any[] + ) { + if (hookName === 'renderChunk') { + injectChunkMetadata(chunkMetadataMap, args[1], true) + } + if (hookName === 'augmentChunkHash') { + injectChunkMetadata(chunkMetadataMap, args[0]) + } + if (hookName === 'generateBundle' || hookName === 'writeBundle') { + const bundle = args[1] as OutputBundle + for (const chunk of Object.values(bundle)) { + injectChunkMetadata(chunkMetadataMap, chunk) + } + } + return fn.call(injectEnvironmentInContext(this, environment), ...args) + } + + if ('handler' in hook) { + return wrapHookObject(hook, handler) + } else { + return handler + } +} + +function injectChunkMetadata( + chunkMetadataMap: ChunkMetadataMap, + chunk: RenderedChunk | OutputChunk | OutputAsset, + resetChunkMetadata = false, +) { + if (resetChunkMetadata) { + chunkMetadataMap.reset(chunk) + } + // define instead of assign to avoid detected as a change + // https://github.com/rolldown/rolldown/blob/f4c5ff27799f2b0152c689c398e61bc7d30429ff/packages/rolldown/src/utils/transform-to-rollup-output.ts#L87 + Object.defineProperty(chunk, 'viteMetadata', { + value: chunkMetadataMap.get(chunk), + enumerable: true, + }) + if (chunk.type === 'chunk') { + Object.defineProperty(chunk, 'modules', { + get() { + return chunk.viteMetadata!.__modules + }, + enumerable: true, + }) + } +} + +function injectEnvironmentInContext( + context: Context, + environment: Environment, +) { + context.meta.viteVersion ??= VERSION + context.environment ??= environment + return context +} + +function injectSsrFlag>( + options: T | undefined, + environment: Environment, + pluginName: string, +): T & { ssr?: boolean } { + let ssr = environment.config.consumer === 'server' + const newOptions = { ...options, ssr } as T & { + ssr?: boolean + } + + if ( + isFutureDeprecationEnabled( + environment?.getTopLevelConfig(), + 'removePluginHookSsrArgument', + ) + ) { + Object.defineProperty(newOptions, 'ssr', { + get() { + warnFutureDeprecation( + environment?.getTopLevelConfig(), + 'removePluginHookSsrArgument', + `Used in plugin "${pluginName}".`, + ) + return ssr + }, + set(v) { + ssr = v + }, + }) + } + + return newOptions +} export type RenderBuiltAssetUrl = ( filename: string, @@ -755,7 +1824,159 @@ export function resolveBuilderOptions( export type ResolvedBuilderOptions = Required -// TODO: fill in later +/** + * Creates a ViteBuilder to orchestrate building multiple environments. + * @experimental + */ +export async function createBuilder( + inlineConfig: InlineConfig = {}, + useLegacyBuilder: null | boolean = false, +): Promise { + const patchConfig = (resolved: ResolvedConfig) => { + if (!(useLegacyBuilder ?? !resolved.builder)) {return} + + // Until the ecosystem updates to use `environment.config.build` instead of `config.build`, + // we need to make override `config.build` for the current environment. + // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later + // remove the default values that shouldn't be used at all once the config is resolved + const environmentName = resolved.build.ssr ? 'ssr' : 'client' + ;(resolved.build as ResolvedBuildOptions) = { + ...resolved.environments[environmentName].build, + } + } + const config = await resolveConfigToBuild(inlineConfig, patchConfig) + useLegacyBuilder ??= !config.builder + const configBuilder = config.builder ?? resolveBuilderOptions({})! + + const environments: Record = {} + + const builder: ViteBuilder = { + environments, + config, + async buildApp() { + const pluginContext = new BasicMinimalPluginContext( + { ...basePluginContextMeta, watchMode: false }, + config.logger, + ) + + // order 'pre' and 'normal' hooks are run first, then config.builder.buildApp, then 'post' hooks + let configBuilderBuildAppCalled = false + for (const p of config.getSortedPlugins('buildApp')) { + const hook = p.buildApp + if ( + !configBuilderBuildAppCalled && + typeof hook === 'object' && + hook.order === 'post' + ) { + configBuilderBuildAppCalled = true + await configBuilder.buildApp(builder) + } + const handler = getHookHandler(hook) + await handler.call(pluginContext, builder) + } + if (!configBuilderBuildAppCalled) { + await configBuilder.buildApp(builder) + } + // fallback to building all environments if no environments have been built + if ( + Object.values(builder.environments).every( + (environment) => !environment.isBuilt, + ) + ) { + for (const environment of Object.values(builder.environments)) { + await builder.build(environment) + } + } + }, + async build( + environment: BuildEnvironment, + ): Promise { + const output = await buildEnvironment(environment) + environment.isBuilt = true + return output + }, + // NOTE(kazupon): @vitejs/devtools is not supported in the browser + // async runDevTools() { + // if (config.devtools) { + // try { + // const { runDevTools } = await import('@vitejs/devtools/integration') + // await runDevTools(builder) + // } catch (e) { + // config.logger.error( + // colors.red( + // `Failed to run Vite DevTools: ${e?.message || e?.stack}`, + // ), + // { error: e }, + // ) + // } + // } + // }, + } + + async function setupEnvironment(name: string, config: ResolvedConfig) { + const environment = await config.build.createEnvironment(name, config) + await environment.init() + environments[name] = environment + } + + if (useLegacyBuilder) { + await setupEnvironment(config.build.ssr ? 'ssr' : 'client', config) + } else { + const environmentConfigs: [string, ResolvedConfig][] = [] + for (const environmentName of Object.keys(config.environments)) { + // We need to resolve the config again so we can properly merge options + // and get a new set of plugins for each build environment. The ecosystem + // expects plugins to be run for the same environment once they are created + // and to process a single bundle at a time (contrary to dev mode where + // plugins are built to handle multiple environments concurrently). + let environmentConfig = config + if (!configBuilder.sharedConfigBuild) { + const patchConfig = (resolved: ResolvedConfig) => { + // Until the ecosystem updates to use `environment.config.build` instead of `config.build`, + // we need to make override `config.build` for the current environment. + // We can deprecate `config.build` in ResolvedConfig and push everyone to upgrade, and later + // remove the default values that shouldn't be used at all once the config is resolved + ;(resolved.build as ResolvedBuildOptions) = { + ...resolved.environments[environmentName].build, + } + } + const patchPlugins = (resolvedPlugins: Plugin[]) => { + // Force opt-in shared plugins + let j = 0 + for (let i = 0; i < resolvedPlugins.length; i++) { + const environmentPlugin = resolvedPlugins[i] + if ( + configBuilder.sharedPlugins || + environmentPlugin.sharedDuringBuild + ) { + for (let k = j; k < config.plugins.length; k++) { + if (environmentPlugin.name === config.plugins[k].name) { + resolvedPlugins[i] = config.plugins[k] + j = k + 1 + break + } + } + } + } + } + environmentConfig = await resolveConfigToBuild( + inlineConfig, + patchConfig, + patchPlugins, + ) + } + environmentConfigs.push([environmentName, environmentConfig]) + } + await Promise.all( + environmentConfigs.map( + async ([environmentName, environmentConfig]) => + await setupEnvironment(environmentName, environmentConfig), + ), + ) + } + + return builder +} export type BuildAppHook = ( this: MinimalPluginContextWithoutEnvironment, diff --git a/packages/vite-dev-server/src/node/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts index 77599bd..c20f3e8 100644 --- a/packages/vite-dev-server/src/node/config.test.ts +++ b/packages/vite-dev-server/src/node/config.test.ts @@ -16,6 +16,12 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ viteTransformPlugin: vi.fn<() => { name: string }>(() => ({ name: 'native:transform', })), + viteLoadFallbackPlugin: vi.fn<() => { name: string }>(() => ({ + name: 'builtin:vite-load-fallback', + })), + viteReporterPlugin: vi.fn<() => { name: string }>(() => ({ + name: 'builtin:vite-reporter', + })), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ @@ -611,6 +617,23 @@ describe('definePlugin JavaScript pre-check', () => { } expect(transformSync).not.toHaveBeenCalled() }) + + test('registers the define plugin and the build plugins for builds', async () => { + const config = await resolveConfig( + createInlineConfig({ define: { $FOO: JSON.stringify('bar') } }), + 'build', + ) + + expect(config.plugins.map(plugin => plugin.name)).toEqual( + expect.arrayContaining([ + 'vite:define', + 'vite:prepare-out-dir', + 'vite:rollup-options-plugins', + 'native:reporter', + 'builtin:vite-load-fallback', + ]), + ) + }) }) 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 a1afa51..a435c48 100644 --- a/packages/vite-dev-server/src/node/plugins/asset.test.ts +++ b/packages/vite-dev-server/src/node/plugins/asset.test.ts @@ -11,6 +11,8 @@ vi.mock('@vrowzer/rolldown', () => ({ rolldown: vi.fn<(...args: unknown[]) => un vi.mock('@vrowzer/rolldown/experimental', () => ({ viteJsonPlugin: () => ({ name: 'vite:json' }), viteTransformPlugin: () => ({ name: 'native:transform' }), + viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }), + viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ parseAst: vi.fn<(...args: unknown[]) => unknown>(), 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 87a6c42..582139c 100644 --- a/packages/vite-dev-server/src/node/plugins/css.test.ts +++ b/packages/vite-dev-server/src/node/plugins/css.test.ts @@ -7,6 +7,8 @@ vi.mock('@vrowzer/rolldown', () => ({ vi.mock('@vrowzer/rolldown/experimental', () => ({ viteJsonPlugin: () => ({ name: 'vite:json' }), viteTransformPlugin: () => ({ name: 'native:transform' }), + viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }), + viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ parseAst: vi.fn<(...args: unknown[]) => unknown>(), diff --git a/packages/vite-dev-server/src/node/plugins/index.ts b/packages/vite-dev-server/src/node/plugins/index.ts index 858e70f..f53ed20 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] = await Promise.all([ + const [preAliasMod, aliasMod, resolveMod, htmlMod, cssMod, oxcMod, jsonMod, importAnalysisMod, assetMod, clientInjectionsMod, defineMod] = await Promise.all([ import('./preAlias'), import('@rollup/plugin-alias'), import('./resolve'), @@ -50,6 +50,7 @@ export async function resolvePlugins( import('./importAnalysis'), import('./asset'), import('./clientInjections'), + import('./define'), ]) const preAliasPlugin = preAliasMod.preAliasPlugin const aliasPlugin = aliasMod.default @@ -63,6 +64,7 @@ export async function resolvePlugins( const importAnalysisPlugin = importAnalysisMod.importAnalysisPlugin const assetPlugin = assetMod.assetPlugin const clientInjectionsPlugin = clientInjectionsMod.clientInjectionsPlugin + const definePlugin = defineMod.definePlugin const forwardConsole = config.server.forwardConsole.enabled ? (await import('./forwardConsole')).forwardConsolePlugin({ environments: ['client'], @@ -103,12 +105,14 @@ export async function resolvePlugins( ...normalPlugins, // wasmFallbackPlugin(config), + // NOTE(kazupon): only for builds. The dev Web Worker replaces `define` in clientInjections. // definePlugin(config), + isBuild ? definePlugin(config) : null, cssPostPlugin(config), // isBundled && buildHtmlPlugin(config), // workerImportMetaUrlPlugin(config), // assetImportMetaUrlPlugin(config), - // ...buildPlugins.pre, + ...buildPlugins.pre, // dynamicImportVarsPlugin(config), // importGlobPlugin(config), diff --git a/packages/vite-dev-server/src/node/plugins/prepareOutDir.ts b/packages/vite-dev-server/src/node/plugins/prepareOutDir.ts new file mode 100644 index 0000000..497cbf0 --- /dev/null +++ b/packages/vite-dev-server/src/node/plugins/prepareOutDir.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs' +import path from 'node:path' +// NOTE(kazupon): comment out because Vite Task does not run in the browser +// import { ignoreInput } from '@voidzero-dev/vite-task-client' +import colors from 'picocolors' +import { withTrailingSlash } from '../../shared/utils' +import type { Environment } from '../environment' +import type { Plugin } from '../plugin' +import { copyDir, emptyDir, normalizePath } from '../utils' +import { getResolvedOutDirs, resolveEmptyOutDir } from '../watch' + +export function prepareOutDirPlugin(): Plugin { + const rendered = new Set() + return { + name: 'vite:prepare-out-dir', + watchChange() { + rendered.delete(this.environment) + }, + renderStart: { + order: 'pre', + handler() { + if (rendered.has(this.environment)) { + return + } + + const { config } = this.environment + if (config.build.write) { + rendered.add(this.environment) + const { root, build: options } = config + const resolvedOutDirs = getResolvedOutDirs( + root, + options.outDir, + options.rolldownOptions.output, + ) + const emptyOutDir = resolveEmptyOutDir( + options.emptyOutDir, + root, + resolvedOutDirs, + this.environment.logger, + ) + prepareOutDir(resolvedOutDirs, emptyOutDir, this.environment) + } + }, + }, + } +} + +function prepareOutDir( + outDirs: Set, + emptyOutDir: boolean | null, + environment: Environment, +) { + const { publicDir } = environment.config + const outDirsArray = [...outDirs] + for (const outDir of outDirs) { + // NOTE(kazupon): comment out because Vite Task does not run in the browser + // // When run inside Vite Task, `emptyDir` below reads the entries of + // // `outDir`. Without this, those reads would be recorded as build inputs + // // and mix with the writes that follow, tripping Vite Task's read-write + // // overlap check. + // ignoreInput(outDir) + if (emptyOutDir !== false && fs.existsSync(outDir)) { + // skip those other outDirs which are nested in current outDir + const skipDirs = outDirsArray + .map((dir) => { + const relative = path.relative(outDir, dir) + if ( + relative && + !relative.startsWith('..') && + !path.isAbsolute(relative) + ) { + return relative + } + return '' + }) + .filter(Boolean) + emptyDir(outDir, [...skipDirs, '.git']) + } + if ( + environment.config.build.copyPublicDir && + publicDir && + fs.existsSync(publicDir) + ) { + if (!areSeparateFolders(outDir, publicDir)) { + environment.logger.warn( + colors.yellow( + `\n${colors.bold( + `(!)`, + )} The public directory feature may not work correctly. outDir ${colors.white( + colors.dim(outDir), + )} and publicDir ${colors.white( + colors.dim(publicDir), + )} are not separate folders.\n`, + ), + ) + } + copyDir(publicDir, outDir) + } + } +} + +function areSeparateFolders(a: string, b: string) { + const na = normalizePath(a) + const nb = normalizePath(b) + return ( + na !== nb && + !na.startsWith(withTrailingSlash(nb)) && + !nb.startsWith(withTrailingSlash(na)) + ) +} diff --git a/packages/vite-dev-server/src/node/plugins/reporter.ts b/packages/vite-dev-server/src/node/plugins/reporter.ts new file mode 100644 index 0000000..0e77f37 --- /dev/null +++ b/packages/vite-dev-server/src/node/plugins/reporter.ts @@ -0,0 +1,28 @@ +import path from 'node:path' +// NOTE(kazupon): use @vrowzer/rolldown (browser build) instead of rolldown +import { viteReporterPlugin as nativeReporterPlugin } from '@vrowzer/rolldown/experimental' +// import { viteReporterPlugin as nativeReporterPlugin } from 'rolldown/experimental' +import type { ResolvedConfig } from '../config' +import { LogLevels } from '../logger' +import { type Plugin, perEnvironmentPlugin } from '../plugin' + +export function buildReporterPlugin(config: ResolvedConfig): Plugin { + return perEnvironmentPlugin('native:reporter', (env) => { + const tty = process.stdout.isTTY && !process.env.CI + const shouldLogInfo = LogLevels[config.logLevel || 'info'] >= LogLevels.info + const assetsDir = path.join(env.config.build.assetsDir, '/') + return nativeReporterPlugin({ + root: env.config.root, + isTty: !!tty, + isLib: !!env.config.build.lib, + assetsDir, + chunkLimit: env.config.build.chunkSizeWarningLimit, + logInfo: shouldLogInfo ? (msg) => env.logger.info(msg) : undefined, + reportCompressedSize: env.config.build.reportCompressedSize, + warnLargeChunks: + env.config.build.minify && + !env.config.build.lib && + env.config.consumer === 'client', + }) + }) +} diff --git a/packages/vite-dev-server/types/metadata.d.ts b/packages/vite-dev-server/types/metadata.d.ts index eb3c74e..8e317f9 100644 --- a/packages/vite-dev-server/types/metadata.d.ts +++ b/packages/vite-dev-server/types/metadata.d.ts @@ -1,3 +1,8 @@ +export interface AssetMetadata { + importedAssets: Set + importedCss: Set +} + export interface ChunkMetadata { importedAssets: Set importedCss: Set @@ -25,6 +30,10 @@ export interface CustomPluginOptionsVite { } declare module 'rolldown' { + export interface OutputAsset { + viteMetadata?: AssetMetadata + } + export interface RenderedChunk { viteMetadata?: ChunkMetadata } From faee1b59343ab29140276b7dfd017400ba1b788d Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 20:05:51 +0900 Subject: [PATCH 04/11] feat(vite-dev-server): resolve with the native plugin in builds Builds now resolve modules with rolldown's native resolve plugin (`viteResolvePlugin`), as upstream Vite does. The JavaScript resolve plugin returns `packageJsonPath: null` for a module without a package.json, and rolldown 1.2.12 rejects it in a build ("Failed to convert JavaScript value `Null` into rust type `String`"). - Port upstream's `oxcResolvePlugin()`. The resolved config does not have `tsconfig` (Vite 8.3) yet, so it is not passed. - `resolvePlugins()` uses it for builds. The dev Web Worker keeps the JavaScript resolve plugin for now, so that the preview stays as it is. - The unit tests that load `resolve.ts` mock the native plugin. A new test builds a library that imports packages through `exports`, `browser` and `module`. --- .../vite-dev-server/src/node/build.test.ts | 78 +++++++- .../vite-dev-server/src/node/config.test.ts | 3 + .../src/node/plugins/asset.test.ts | 1 + .../src/node/plugins/css.test.ts | 1 + .../vite-dev-server/src/node/plugins/index.ts | 44 ++++- .../src/node/plugins/resolve.test.ts | 7 + .../src/node/plugins/resolve.ts | 171 +++++++++++++++++- 7 files changed, 288 insertions(+), 17 deletions(-) diff --git a/packages/vite-dev-server/src/node/build.test.ts b/packages/vite-dev-server/src/node/build.test.ts index 1e39c93..936c5c5 100644 --- a/packages/vite-dev-server/src/node/build.test.ts +++ b/packages/vite-dev-server/src/node/build.test.ts @@ -1,4 +1,6 @@ -import { basename, resolve } from 'node:path' +import fs from 'node:fs' +import os from 'node:os' +import { basename, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { stripVTControlCharacters } from 'node:util' import colors from 'picocolors' @@ -891,3 +893,77 @@ describe('onRollupLog', () => { }, ) }) + +// vrowzer: builds resolve packages with the native resolve plugin (`viteResolvePlugin`), as upstream does +describe('package resolution in library builds', () => { + let root: string + + function writeFiles(files: Record) { + for (const [file, content] of Object.entries(files)) { + const filePath = join(root, file) + fs.mkdirSync(resolve(filePath, '..'), { recursive: true }) + fs.writeFileSync(filePath, content) + } + } + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }) + }) + + test('picks the browser entries of the packages', async () => { + root = fs.mkdtempSync(join(os.tmpdir(), 'vrowzer-build-resolve-')) + writeFiles({ + 'entry.js': [ + `import { value as fromExports } from 'pkg-exports'`, + `import { value as fromBrowser } from 'pkg-browser'`, + `import { value as fromModule } from 'pkg-module'`, + `export const values = [fromExports, fromBrowser, fromModule]`, + ].join('\n'), + 'node_modules/pkg-exports/package.json': JSON.stringify({ + name: 'pkg-exports', + exports: { + '.': { + browser: './browser.js', + node: './node.js', + default: './default.js', + }, + }, + }), + 'node_modules/pkg-exports/browser.js': `export const value = 'exports-browser'`, + 'node_modules/pkg-exports/node.js': `export const value = 'exports-node'`, + 'node_modules/pkg-exports/default.js': `export const value = 'exports-default'`, + 'node_modules/pkg-browser/package.json': JSON.stringify({ + name: 'pkg-browser', + main: './main.js', + browser: './browser.js', + }), + 'node_modules/pkg-browser/main.js': `export const value = 'browser-main'`, + 'node_modules/pkg-browser/browser.js': `export const value = 'browser-field'`, + 'node_modules/pkg-module/package.json': JSON.stringify({ + name: 'pkg-module', + main: './main.cjs', + module: './module.js', + }), + 'node_modules/pkg-module/main.cjs': `exports.value = 'module-main'`, + 'node_modules/pkg-module/module.js': `export const value = 'module-field'`, + }) + + const [output] = (await build({ + root, + logLevel: 'silent', + build: { + write: false, + minify: false, + lib: { entry: 'entry.js', formats: ['es'], fileName: 'lib' }, + }, + })) as RolldownOutput[] + + const chunk = output.output.find( + (o) => o.type === 'chunk' && o.isEntry, + ) as OutputChunk + expect(chunk.code).toContain('exports-browser') + expect(chunk.code).toContain('browser-field') + expect(chunk.code).toContain('module-field') + expect(chunk.code).not.toMatch(/exports-node|exports-default|browser-main|module-main/) + }) +}) diff --git a/packages/vite-dev-server/src/node/config.test.ts b/packages/vite-dev-server/src/node/config.test.ts index c20f3e8..a54cacf 100644 --- a/packages/vite-dev-server/src/node/config.test.ts +++ b/packages/vite-dev-server/src/node/config.test.ts @@ -22,6 +22,9 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ viteReporterPlugin: vi.fn<() => { name: string }>(() => ({ name: 'builtin:vite-reporter', })), + viteResolvePlugin: vi.fn<() => { name: string }>(() => ({ + name: 'builtin:vite-resolve', + })), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ 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 a435c48..3f694f3 100644 --- a/packages/vite-dev-server/src/node/plugins/asset.test.ts +++ b/packages/vite-dev-server/src/node/plugins/asset.test.ts @@ -13,6 +13,7 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ viteTransformPlugin: () => ({ name: 'native:transform' }), viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }), viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }), + viteResolvePlugin: () => ({ name: 'builtin:vite-resolve' }), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ parseAst: vi.fn<(...args: unknown[]) => unknown>(), 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 582139c..d4c5ff3 100644 --- a/packages/vite-dev-server/src/node/plugins/css.test.ts +++ b/packages/vite-dev-server/src/node/plugins/css.test.ts @@ -9,6 +9,7 @@ vi.mock('@vrowzer/rolldown/experimental', () => ({ viteTransformPlugin: () => ({ name: 'native:transform' }), viteLoadFallbackPlugin: () => ({ name: 'builtin:vite-load-fallback' }), viteReporterPlugin: () => ({ name: 'builtin:vite-reporter' }), + viteResolvePlugin: () => ({ name: 'builtin:vite-resolve' }), })) vi.mock('@vrowzer/rolldown/parseAst', () => ({ parseAst: vi.fn<(...args: unknown[]) => unknown>(), diff --git a/packages/vite-dev-server/src/node/plugins/index.ts b/packages/vite-dev-server/src/node/plugins/index.ts index f53ed20..63b2329 100644 --- a/packages/vite-dev-server/src/node/plugins/index.ts +++ b/packages/vite-dev-server/src/node/plugins/index.ts @@ -55,6 +55,7 @@ export async function resolvePlugins( const preAliasPlugin = preAliasMod.preAliasPlugin const aliasPlugin = aliasMod.default const resolvePlugin = resolveMod.resolvePlugin + const oxcResolvePlugin = resolveMod.oxcResolvePlugin const htmlInlineProxyPlugin = htmlMod.htmlInlineProxyPlugin const cssPlugin = cssMod.cssPlugin const cssPostPlugin = cssMod.cssPostPlugin @@ -83,15 +84,40 @@ export async function resolvePlugins( ...prePlugins, - resolvePlugin({ - root: config.root, - isProduction: config.isProduction, - isBuild, - packageCache: config.packageCache, - asSrc: true, - optimizeDeps: true, - externalize: true, - }), + // NOTE(kazupon): builds resolve with the native plugin as upstream does. The dev Web Worker keeps + // the JavaScript resolve plugin for now. + ...(isBuild + ? oxcResolvePlugin( + { + root: config.root, + isProduction: config.isProduction, + isBuild, + packageCache: config.packageCache, + asSrc: true, + optimizeDeps: true, + externalize: true, + legacyInconsistentCjsInterop: config.legacy?.inconsistentCjsInterop, + }, + isWorker + ? { + ...config, + consumer: 'client', + isBundled: true, + optimizeDepsPluginNames: [], + } + : undefined, + ) + : [ + resolvePlugin({ + root: config.root, + isProduction: config.isProduction, + isBuild, + packageCache: config.packageCache, + asSrc: true, + optimizeDeps: true, + externalize: true, + }), + ]), htmlInlineProxyPlugin(config), cssPlugin(config), // esbuildBannerFooterCompatPlugin(config), diff --git a/packages/vite-dev-server/src/node/plugins/resolve.test.ts b/packages/vite-dev-server/src/node/plugins/resolve.test.ts index f629602..9a421d0 100644 --- a/packages/vite-dev-server/src/node/plugins/resolve.test.ts +++ b/packages/vite-dev-server/src/node/plugins/resolve.test.ts @@ -13,6 +13,13 @@ vi.mock('../external', () => ({ shouldExternalize: vi.fn<() => boolean>(() => false), })) +// The native resolve plugin is for builds. These tests cover the JavaScript resolve plugin. +vi.mock('@vrowzer/rolldown/experimental', () => ({ + viteResolvePlugin: vi.fn<() => { name: string }>(() => ({ + name: 'builtin:vite-resolve', + })), +})) + vi.mock('../optimizer', () => ({ isDepOptimizationDisabled: vi.fn<() => boolean>(() => true), optimizedDepInfoFromFile: vi.fn<() => undefined>(), diff --git a/packages/vite-dev-server/src/node/plugins/resolve.ts b/packages/vite-dev-server/src/node/plugins/resolve.ts index 8eb228d..b443a5a 100644 --- a/packages/vite-dev-server/src/node/plugins/resolve.ts +++ b/packages/vite-dev-server/src/node/plugins/resolve.ts @@ -8,6 +8,9 @@ import { fileURLToPath } from 'node:url' import colors from 'picocolors' import { exports, imports } from 'resolve.exports' import type { PartialResolvedId } from 'rolldown' +// NOTE(kazupon): use @vrowzer/rolldown (browser build) instead of rolldown +import { viteResolvePlugin } from '@vrowzer/rolldown/experimental' +// import { viteResolvePlugin } from 'rolldown/experimental' import type { Environment } from '..' import { cleanUrl, @@ -230,19 +233,173 @@ const perEnvironmentOrWorkerPlugin = ( ] } /** - * Not implemented yet. Kept to follow Vite's `resolve.ts` structure. - * - * NOTE(kazupon): `createIdResolver` and `resolvePlugins` do not use this plugin. - * Vrowzer always uses the JavaScript `resolvePlugin` instead. - * - * @internal + * NOTE(kazupon): the builds use this native plugin as upstream does. The dev Web Worker still uses the + * JavaScript `resolvePlugin` (see `resolvePlugins()`). */ export function oxcResolvePlugin( resolveOptions: ResolvePluginOptionsWithOverrides, overrideEnvConfig: (ResolvedConfig & ResolvedEnvironmentOptions) | undefined, + isJsPluginContainer = false, ): Plugin[] { - // TODO: fill in later ... return [ + ...(resolveOptions.optimizeDeps && !resolveOptions.isBuild + ? [optimizerResolvePlugin(resolveOptions)] + : []), + ...perEnvironmentOrWorkerPlugin( + 'vite:resolve-builtin', + overrideEnvConfig, + (partialEnv, getEnv) => { + // The resolve plugin is used for createIdResolver and the depsOptimizer should be + // disabled in that case, so deps optimization is opt-in when creating the plugin. + const depsOptimizerEnabled = + resolveOptions.optimizeDeps && + !resolveOptions.isBuild && + !partialEnv.config.isBundled && + !isDepOptimizationDisabled(partialEnv.config.optimizeDeps) + const getDepsOptimizer = () => { + const env = getEnv() + if (env.mode !== 'dev') + {throw new Error('The environment mode should be dev')} + if (!env.depsOptimizer) + {throw new Error('The environment should have a depsOptimizer')} + return env.depsOptimizer + } + + const options: InternalResolveOptions = { + ...partialEnv.config.resolve, + ...resolveOptions, // plugin options + resolve options overrides + } + const noExternal = + Array.isArray(options.noExternal) || options.noExternal === true + ? options.noExternal + : [options.noExternal] + + const plugin = viteResolvePlugin({ + // NOTE(kazupon): comment out because the resolved config does not have `tsconfig` (Vite 8.3) yet + // tsconfig: partialEnv.config.tsconfig, + resolveOptions: { + isBuild: options.isBuild, + isProduction: options.isProduction, + asSrc: options.asSrc ?? false, + preferRelative: options.preferRelative ?? false, + isRequire: options.isRequire, + root: options.root, + scan: options.scan ?? false, + + mainFields: options.skipMainField + ? options.mainFields + : [...options.mainFields, 'main'], + conditions: options.conditions, + externalConditions: options.externalConditions, + extensions: options.extensions, + tryIndex: options.tryIndex ?? true, + tryPrefix: options.tryPrefix, + preserveSymlinks: options.preserveSymlinks, + tsconfigPaths: options.tsconfigPaths, + }, + environmentConsumer: partialEnv.config.consumer, + environmentName: partialEnv.name, + builtins: partialEnv.config.resolve.builtins, + external: options.external, + noExternal: noExternal, + dedupe: options.dedupe, + disableCache: + partialEnv.config.command === 'serve' && + // eslint-disable-next-line eqeqeq + partialEnv.config.server.watch === null, + legacyInconsistentCjsInterop: options.legacyInconsistentCjsInterop, + finalizeBareSpecifier: !depsOptimizerEnabled + ? undefined + : (resolvedId, rawId, importer) => { + const depsOptimizer = getDepsOptimizer() + // if we reach here, it's a valid dep import that hasn't been optimized. + const isJsType = isOptimizable( + resolvedId, + depsOptimizer.options, + ) + const exclude = depsOptimizer?.options.exclude + + // check for deep import, e.g. "my-lib/foo" + const deepMatch = deepImportRE.exec(rawId) + // package name doesn't include postfixes + // trim them to support importing package with queries (e.g. `import css from 'normalize.css?inline'`) + const pkgId = deepMatch + ? deepMatch[1] || deepMatch[2] + : cleanUrl(rawId) + + const skipOptimization = + depsOptimizer.options.noDiscovery || + !isJsType || + (importer && isInNodeModules(importer)) || + exclude?.includes(pkgId) || + exclude?.includes(rawId) || + SPECIAL_QUERY_RE.test(resolvedId) + + let newId = resolvedId + if (skipOptimization) { + // excluded from optimization + // Inject a version query to npm deps so that the browser + // can cache it without re-validation, but only do so for known js types. + // otherwise we may introduce duplicated modules for externalized files + // from pre-bundled deps. + const versionHash = depsOptimizer!.metadata.browserHash + if (versionHash && isJsType) { + newId = injectQuery(newId, `v=${versionHash}`) + } + } else { + // this is a missing import, queue optimize-deps re-run and + // get a resolved its optimized info + const optimizedInfo = depsOptimizer!.registerMissingImport( + rawId, + newId, + ) + newId = depsOptimizer!.getOptimizedDepId(optimizedInfo) + } + return newId + }, + finalizeOtherSpecifiers: !depsOptimizerEnabled + ? undefined + : (resolvedId, rawId) => { + const depsOptimizer = getDepsOptimizer() + const newResolvedId = ensureVersionQuery( + resolvedId, + rawId, + options, + depsOptimizer, + ) + return newResolvedId === resolvedId ? undefined : newResolvedId + }, + resolveSubpathImports(id, importer, isRequire) { + return resolveSubpathImports(id, importer, { + ...options, + isRequire: resolveOptions.isRequire ?? isRequire, + }) + }, + + ...(partialEnv.config.command === 'serve' || isJsPluginContainer + ? { + async onWarn(msg) { + // use `partialEnv` instead of `getEnv()` because `buildStart` is + // not called for plugin container used by `createIdResolver` + partialEnv.config.logger.warn(`warning: ${msg}`, { + clear: true, + timestamp: true, + }) + }, + } + : {}), + ...(debug + ? { + async onDebug(message) { + debug(message) + }, + } + : {}), + }) + ;(plugin as Plugin).perEnvironmentWatchChangeDuringDev = true + return plugin + }, + ), ] } From fb7c5ddbb4896239171ead1444306df548a9c6cb Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:16:42 +0900 Subject: [PATCH 05/11] feat(vite-dev-server): add the builder for the build Worker `@vrowzer/vite-dev-server/builder` builds a snapshot of the project files with the ported `build()`. The build Worker of vrowzer (#36) loads it, and gets the outputs and the warnings, or the errors of the build. - `buildProject(files, inlineConfig)` writes the snapshot to the virtual file system of the Worker, and builds it with `root: '/'`, `configFile: false`, `build.write: false`, `build.emptyOutDir: false` and `logLevel: 'warn'`. It returns the outputs, keyed by the path from the output root (binary assets as ArrayBuffers), and the warnings. It also adds the public files, which Vite copies only when it writes. - A plugin of the builder defaults `build.lib.formats` to `['es']`, and rejects what the browser build does not support yet: HTML app builds (no `build.lib`), formats other than `es`, multiple library entries, `build.cssMinify`, the terser and esbuild minifiers, `build.watch`, `build.ssr`, `build.manifest`, `build.ssrManifest` and `build.license`. - A failure throws `BuildProjectError`. Its `errors` keep the code, plugin, id, location and frame of each rolldown error, without colors. An output outside the output directory, and a public file with the path of an output, are errors too. - The builder is bundled into one file, like the transformer of the dev Web Worker. Its entry sets `globalThis.Buffer` before the rolldown binding initializes, and loads `config.ts` before `build.ts`. A bundle check keeps the first order. --- packages/vite-dev-server/package.json | 4 + packages/vite-dev-server/rolldown.config.ts | 69 +++- .../vite-dev-server/rolldown.dts.config.ts | 1 + packages/vite-dev-server/src/node/builder.ts | 97 +++++ .../src/node/builderGlobals.ts | 18 + .../src/node/builderUtils.test.ts | 346 ++++++++++++++++++ .../vite-dev-server/src/node/builderUtils.ts | 327 +++++++++++++++++ 7 files changed, 856 insertions(+), 6 deletions(-) create mode 100644 packages/vite-dev-server/src/node/builder.ts create mode 100644 packages/vite-dev-server/src/node/builderGlobals.ts create mode 100644 packages/vite-dev-server/src/node/builderUtils.test.ts create mode 100644 packages/vite-dev-server/src/node/builderUtils.ts diff --git a/packages/vite-dev-server/package.json b/packages/vite-dev-server/package.json index b3cfb91..3a0c738 100644 --- a/packages/vite-dev-server/package.json +++ b/packages/vite-dev-server/package.json @@ -59,6 +59,10 @@ "types": "./dist/node/transformer.d.ts", "default": "./dist/node/transformer.js" }, + "./builder": { + "types": "./dist/node/builder.d.ts", + "default": "./dist/node/web-worker-builder.js" + }, "./service-worker": { "types": "./dist/node/service-worker.d.ts", "default": "./dist/node/service-worker.js" diff --git a/packages/vite-dev-server/rolldown.config.ts b/packages/vite-dev-server/rolldown.config.ts index 9b88f2c..6121cbe 100644 --- a/packages/vite-dev-server/rolldown.config.ts +++ b/packages/vite-dev-server/rolldown.config.ts @@ -324,7 +324,10 @@ const transformerAliases = { function createTransformerPlugins(options: { copyAssets: boolean - guardAggregate?: boolean + /** + * The entry file of a single-file aggregate to validate + */ + guardAggregate?: string }): Plugin[] { return [ createPostcssLoadConfigStubPlugin(), @@ -333,7 +336,7 @@ function createTransformerPlugins(options: { ...(options.copyAssets ? [createCopyRolldownAssetsPlugin()] : []), createValidateRolldownVersionPlugin(), createRuntimeNodeEnvGuardPlugin(), - ...(options.guardAggregate ? [createWebWorkerTransformerGuardPlugin()] : []), + ...(options.guardAggregate ? [createWebWorkerTransformerGuardPlugin(options.guardAggregate)] : []), ] } @@ -393,7 +396,39 @@ const webWorkerTransformerConfig = defineConfig({ createHostViteHelperIsolationPlugin(), ...createTransformerPlugins({ copyAssets: false, - guardAggregate: true, + guardAggregate: 'node/web-worker-transformer.js', + }), + ], +}) + +// The build Worker aggregate (vrowzer-only). Like the transformer aggregate, it is one file that +// has rolldown, but its entry is the builder. +const webWorkerBuilderConfig = defineConfig({ + ...sharedNodeOptions, + input: { + 'web-worker-builder': path.resolve(__dirname, 'src/node/builder.ts'), + }, + resolve: { + alias: transformerAliases, + }, + transform: { + ...sharedNodeOptions.transform, + inject: { + process: '@vrowzer/node-polyfill/process', + }, + }, + output: { + ...sharedNodeOptions.output, + entryFileNames: 'node/web-worker-builder.js', + codeSplitting: false, + }, + plugins: [ + createEagerWorkerTransformerImportsPlugin(), + createHostViteHelperIsolationPlugin(), + createBuilderGlobalsGuardPlugin(), + ...createTransformerPlugins({ + copyAssets: false, + guardAggregate: 'node/web-worker-builder.js', }), ], }) @@ -490,6 +525,7 @@ export default defineConfig([ serviceWorkerConfig, transformerConfig, webWorkerTransformerConfig, + webWorkerBuilderConfig, webWorkerConfig, moduleRunnerConfig, messagesConfig, @@ -681,6 +717,27 @@ function createChunkedHostViteHelperIsolationPlugin(): Plugin { } } +function createBuilderGlobalsGuardPlugin(): Plugin { + // The rolldown binding reads `globalThis.Buffer` when it is initialized, so the builder must set it + // before the binding code. One file evaluates from the top, so check the order of the code. + return { + name: 'validate-builder-globals', + 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('[validate-builder-globals] Could not find node/web-worker-builder.js entry') + } + const globals = entry.code.indexOf('globalThis.Buffer ??=') + const binding = entry.code.indexOf('rolldown-binding.wasm32-wasi.wasm') + if (globals < 0 || binding < 0 || globals > binding) { + this.error('[validate-builder-globals] The builder globals must come before the rolldown binding') + } + }, + } +} + 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. @@ -799,15 +856,15 @@ function createValidateRolldownVersionPlugin(): Plugin { } } -function createWebWorkerTransformerGuardPlugin(): Plugin { +function createWebWorkerTransformerGuardPlugin(entryFileName: string): Plugin { return { name: 'validate-web-worker-transformer', generateBundle(_options, bundle) { const chunks = Object.values(bundle).filter(output => output.type === 'chunk') - const entry = chunks.find(chunk => chunk.fileName === 'node/web-worker-transformer.js') + const entry = chunks.find(chunk => chunk.fileName === entryFileName) if (!entry || entry.type !== 'chunk') { throw new Error( - '[validate-web-worker-transformer] Could not find web-worker-transformer entry' + `[validate-web-worker-transformer] Could not find ${entryFileName} entry` ) } diff --git a/packages/vite-dev-server/rolldown.dts.config.ts b/packages/vite-dev-server/rolldown.dts.config.ts index 320a232..a42fe24 100644 --- a/packages/vite-dev-server/rolldown.dts.config.ts +++ b/packages/vite-dev-server/rolldown.dts.config.ts @@ -29,6 +29,7 @@ export default defineConfig({ internal: './src/node/internalIndex.ts', 'service-worker': './src/node/service-worker.ts', transformer: './src/node/transformer.ts', + builder: './src/node/builder.ts', vite: './src/node/vite.ts', 'web-worker': './src/node/web-worker.ts', }, diff --git a/packages/vite-dev-server/src/node/builder.ts b/packages/vite-dev-server/src/node/builder.ts new file mode 100644 index 0000000..bae3d45 --- /dev/null +++ b/packages/vite-dev-server/src/node/builder.ts @@ -0,0 +1,97 @@ +/** + * Build Worker entry point for @vrowzer/vite-dev-server + * + * vrowzer-only module, not in upstream Vite. It builds a snapshot of the project files with + * upstream's `build()`, in the build Worker of vrowzer. The files are written to the virtual file + * system of the Worker, built with `write: false`, and returned as a map of the outputs. + * + * @module node/builder + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +// The order of the imports matters: +// - `./builderGlobals` sets the globals that the rolldown binding reads when it is initialized, +// so it must come first. +// - `./config` must be evaluated before `./build`. The two modules import each other, and +// `config.ts` uses the build defaults while it is evaluated (the transformer entry has the same +// order). In the other order, the bundle fails with "Cannot access ... before initialization". +import './builderGlobals' +import { fs, vol } from '@vrowzer/fs' +import './config' +import { build } from './build' +import { + addPublicFiles, + collectOutputs, + createBuildOptionsPlugin, + createCollectingLogger, + toBuildProjectError, +} from './builderUtils' + +import type { BuildProjectLog, BuildProjectResult } from './builderUtils' +import type { InlineConfig, ResolvedConfig } from './config' + +export { BuildProjectError } from './builderUtils' +export type { BuildProjectLog, BuildProjectResult } from './builderUtils' +export type { InlineConfig } from './config' +export { mergeConfig } from './utils' + +function writeSnapshot(files: Record): void { + vol.reset() + for (const [filePath, content] of Object.entries(files)) { + const dir = filePath.slice(0, filePath.lastIndexOf('/')) + if (dir) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(filePath, typeof content === 'string' ? content : new Uint8Array(content)) + } +} + +/** + * Build the project files with Vite, without writing the outputs. + * + * The builder decides some options over `inlineConfig`: `root` is `/`, `configFile` is `false`, + * `build.write` and `build.emptyOutDir` are `false`, and `logLevel` is `warn`, because the result + * has the warnings and errors only. Options that the browser build does not support are rejected. + * + * @param files - The project files, keyed by absolute path + * @param inlineConfig - The Vite config: the Worker config and the options of the build, merged + * @returns The outputs and the warnings + * @throws {@link BuildProjectError} when the build fails + */ +export async function buildProject( + files: Record, + inlineConfig: InlineConfig = {} +): Promise { + const warnings: BuildProjectLog[] = [] + const state: { config?: ResolvedConfig } = {} + writeSnapshot(files) + try { + const result = await build({ + ...inlineConfig, + root: '/', + configFile: false, + logLevel: 'warn', + customLogger: createCollectingLogger(warnings), + plugins: [...(inlineConfig.plugins ?? []), createBuildOptionsPlugin(state)], + build: { + ...inlineConfig.build, + write: false, + emptyOutDir: false, + }, + }) + const outputs = collectOutputs(result) + if (state.config) { + addPublicFiles(outputs, files, state.config) + } + return { files: outputs, warnings } + } catch (error) { + throw toBuildProjectError(error) + } finally { + // The build Worker closes after a build, but do not keep the snapshot in any case + vol.reset() + } +} diff --git a/packages/vite-dev-server/src/node/builderGlobals.ts b/packages/vite-dev-server/src/node/builderGlobals.ts new file mode 100644 index 0000000..dab9ff8 --- /dev/null +++ b/packages/vite-dev-server/src/node/builderGlobals.ts @@ -0,0 +1,18 @@ +/** + * Globals that the rolldown binding needs in the build Worker + * + * The binding (emnapi) looks for `Buffer` when it is initialized, and creates Node.js Buffers for + * binary outputs, e.g. emitted assets. The builder entry imports this module first, so that it is + * evaluated before the binding. + * + * @module node/builderGlobals + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Buffer } from 'node:buffer' + +;(globalThis as { Buffer?: unknown }).Buffer ??= Buffer diff --git a/packages/vite-dev-server/src/node/builderUtils.test.ts b/packages/vite-dev-server/src/node/builderUtils.test.ts new file mode 100644 index 0000000..51111a0 --- /dev/null +++ b/packages/vite-dev-server/src/node/builderUtils.test.ts @@ -0,0 +1,346 @@ +import type { RolldownOutput, RolldownWatcher } from 'rolldown' +import { describe, expect, test } from 'vite-plus/test' +import type { ResolvedBuildOptions } from './build' +import { + BuildProjectError, + INVALID_OUTPUT_PATH, + PUBLIC_FILE_COLLISION, + UNSUPPORTED_OPTION, + addPublicFiles, + collectOutputs, + createBuildOptionsPlugin, + createCollectingLogger, + stripAnsi, + toBuildProjectError, + validateBuildOptions, +} from './builderUtils' +import type { ResolvedConfig, UserConfig } from './config' + +const ESC = String.fromCharCode(27) + +function red(text: string): string { + return `${ESC}[31m${text}${ESC}[0m` +} + +function buildOptions(options: Partial = {}): ResolvedBuildOptions { + return { + lib: { entry: '/src/index.ts', formats: ['es'] }, + minify: 'oxc', + watch: null, + ssr: false, + manifest: false, + ssrManifest: false, + license: false, + ...options, + } as ResolvedBuildOptions +} + +function output(items: unknown[]): RolldownOutput { + return { output: items } as unknown as RolldownOutput +} + +function catchError(fn: () => unknown): unknown { + try { + fn() + } catch (error) { + return error + } + throw new Error('expected an error') +} + +describe('validateBuildOptions', () => { + test('accepts a library build in the es format', () => { + expect(validateBuildOptions(buildOptions())).toEqual([]) + expect(validateBuildOptions(buildOptions({ minify: false }))).toEqual([]) + }) + + test('rejects HTML app builds', () => { + expect(validateBuildOptions(buildOptions({ lib: false }))).toEqual([ + expect.objectContaining({ + code: UNSUPPORTED_OPTION, + message: expect.stringContaining('build.lib: HTML app builds are not supported yet'), + }), + ]) + }) + + test('rejects library formats other than es', () => { + const problems = validateBuildOptions( + buildOptions({ lib: { entry: '/src/index.ts', formats: ['es', 'umd', 'cjs'] } }), + ) + + expect(problems).toHaveLength(1) + expect(problems[0].message).toContain('build.lib.formats') + expect(problems[0].message).toContain('"umd", "cjs"') + }) + + test.each([ + [['/src/a.ts', '/src/b.ts']], + [{ a: '/src/a.ts', b: '/src/b.ts' }], + ])('rejects multiple library entries (%o)', (entry) => { + expect( + validateBuildOptions(buildOptions({ lib: { entry, formats: ['es'] } })), + ).toEqual([ + expect.objectContaining({ message: expect.stringContaining('build.lib.entry') }), + ]) + }) + + test('leaves a missing library entry to Vite', () => { + expect( + validateBuildOptions( + buildOptions({ lib: { formats: ['es'] } as ResolvedBuildOptions['lib'] }), + ), + ).toEqual([]) + }) + + test.each(['terser', 'esbuild'] as const)('rejects the %s minifier', (minify) => { + expect(validateBuildOptions(buildOptions({ minify }))).toEqual([ + expect.objectContaining({ message: expect.stringContaining('build.minify') }), + ]) + }) + + test('reports every unsupported option', () => { + const problems = validateBuildOptions( + buildOptions({ + watch: {}, + ssr: true, + manifest: true, + ssrManifest: true, + license: true, + }), + ) + + expect(problems.map(problem => problem.message.split(':')[0])).toEqual([ + '[vrowzer] build.watch', + '[vrowzer] build.ssr', + '[vrowzer] build.manifest', + '[vrowzer] build.ssrManifest', + '[vrowzer] build.license', + ]) + expect(problems.every(problem => problem.code === UNSUPPORTED_OPTION)).toBe(true) + }) +}) + +describe('createBuildOptionsPlugin', () => { + function callConfig(config: UserConfig): unknown { + const plugin = createBuildOptionsPlugin({}) + return (plugin.config as (config: UserConfig) => unknown)(config) + } + + test('runs after the plugins of the Worker config', () => { + expect(createBuildOptionsPlugin({}).enforce).toBe('post') + }) + + test('defaults the library formats to es', () => { + expect(callConfig({ build: { lib: { entry: '/src/index.ts' } } })).toEqual({ + build: { lib: { formats: ['es'] } }, + }) + expect( + callConfig({ build: { lib: { entry: '/src/index.ts', formats: ['umd'] } } }), + ).toBeUndefined() + expect(callConfig({})).toBeUndefined() + }) + + test.each([true, 'lightningcss', 'esbuild'] as const)( + 'rejects build.cssMinify: %s', + (cssMinify) => { + const error = catchError(() => callConfig({ build: { cssMinify } })) + + expect(error).toBeInstanceOf(BuildProjectError) + expect((error as BuildProjectError).errors).toEqual([ + expect.objectContaining({ + code: UNSUPPORTED_OPTION, + message: expect.stringContaining('build.cssMinify'), + }), + ]) + }, + ) + + test('keeps the resolved config and rejects unsupported options', () => { + const state: { config?: ResolvedConfig } = {} + 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 + + configResolved(supported) + expect(state.config).toBe(supported) + + const error = catchError(() => configResolved(unsupported)) + expect(error).toBeInstanceOf(BuildProjectError) + expect((error as BuildProjectError).message).toContain('build.lib') + }) +}) + +describe('createCollectingLogger', () => { + test('collects the warnings without colors and drops the other logs', () => { + const warnings: { message: string }[] = [] + const logger = createCollectingLogger(warnings) + + logger.info('info') + logger.error('error') + expect(logger.hasWarned).toBe(false) + + logger.warn(red('[plugin test] careful')) + logger.warnOnce('once') + logger.warnOnce('once') + + expect(warnings).toEqual([{ message: '[plugin test] careful' }, { message: 'once' }]) + expect(logger.hasWarned).toBe(true) + }) +}) + +describe('collectOutputs', () => { + test('returns code, text assets and binary assets', () => { + const bytes = new Uint8Array([1, 2, 3]) + const files = collectOutputs( + output([ + { type: 'chunk', fileName: 'my-lib.js', code: 'export {}' }, + { type: 'asset', fileName: 'my-lib.css', source: '.a{}' }, + { type: 'asset', fileName: 'assets/a.bin', source: bytes }, + ]), + ) + + expect(files['my-lib.js']).toBe('export {}') + expect(files['my-lib.css']).toBe('.a{}') + expect(files['assets/a.bin']).toBeInstanceOf(ArrayBuffer) + expect([...new Uint8Array(files['assets/a.bin'] as ArrayBuffer)]).toEqual([1, 2, 3]) + }) + + test('copies the bytes of a view into a larger or shared buffer', () => { + const view = new Uint8Array(new ArrayBuffer(8), 2, 3) + view.set([4, 5, 6]) + const shared = new Uint8Array(new SharedArrayBuffer(2)) + shared.set([7, 8]) + const files = collectOutputs( + output([ + { type: 'asset', fileName: 'view.bin', source: view }, + { type: 'asset', fileName: 'shared.bin', source: shared }, + ]), + ) + + expect(files['view.bin']).toBeInstanceOf(ArrayBuffer) + expect([...new Uint8Array(files['view.bin'] as ArrayBuffer)]).toEqual([4, 5, 6]) + expect(files['shared.bin']).toBeInstanceOf(ArrayBuffer) + expect([...new Uint8Array(files['shared.bin'] as ArrayBuffer)]).toEqual([7, 8]) + }) + + test.each(['../outside.js', 'nested/../../outside.js', '/absolute.js'])( + 'rejects the output %s outside the output directory', + (fileName) => { + const error = catchError(() => + collectOutputs(output([{ type: 'chunk', fileName, code: '' }])), + ) + + expect(error).toBeInstanceOf(BuildProjectError) + expect((error as BuildProjectError).errors[0].code).toBe(INVALID_OUTPUT_PATH) + }, + ) + + test('rejects a watcher', () => { + expect(() => collectOutputs({} as RolldownWatcher)).toThrow(BuildProjectError) + }) +}) + +describe('addPublicFiles', () => { + const config = { publicDir: '/public', build: { copyPublicDir: true } } + + test('adds the public files at the output root', () => { + const binary = new Uint8Array([1, 2]).buffer + const files: Record = { 'my-lib.js': '' } + addPublicFiles( + files, + { + '/src/index.ts': '', + '/public/robots.txt': 'robots', + '/public/images/logo.png': binary, + '/publicity.txt': 'not public', + }, + config, + ) + + expect(Object.keys(files).sort()).toEqual(['images/logo.png', 'my-lib.js', 'robots.txt']) + expect(files['robots.txt']).toBe('robots') + expect(files['images/logo.png']).not.toBe(binary) + expect([...new Uint8Array(files['images/logo.png'] as ArrayBuffer)]).toEqual([1, 2]) + }) + + test('does nothing without the public directory', () => { + const files = {} + addPublicFiles(files, { '/public/a.txt': 'a' }, { publicDir: '', build: { copyPublicDir: true } }) + addPublicFiles(files, { '/public/a.txt': 'a' }, { ...config, build: { copyPublicDir: false } }) + + expect(files).toEqual({}) + }) + + test('rejects a public file that has the path of an output', () => { + const error = catchError(() => + addPublicFiles({ 'my-lib.js': '' }, { '/public/my-lib.js': '' }, config), + ) + + expect(error).toBeInstanceOf(BuildProjectError) + expect((error as BuildProjectError).errors[0].code).toBe(PUBLIC_FILE_COLLISION) + }) +}) + +describe('toBuildProjectError', () => { + test('converts the errors of a rolldown build', () => { + const error = Object.assign(new Error('Build failed with 2 errors'), { + errors: [ + { + code: 'PARSE_ERROR', + message: `${red('[PARSE_ERROR]')} Unexpected token`, + id: '/src/index.ts', + loc: { line: 1, column: 17, file: '/src/index.ts' }, + pos: 17, + }, + { + code: 'PLUGIN_ERROR', + message: 'boom', + plugin: 'my-plugin', + id: '/src/a.ts', + loc: { line: 2, column: 0 }, + frame: red('1: const a = 1'), + hook: 'transform', + }, + ], + }) + + const converted = toBuildProjectError(error) + + expect(converted).toBeInstanceOf(BuildProjectError) + expect(converted.message).toBe('[PARSE_ERROR] Unexpected token') + expect(converted.errors).toEqual([ + { + code: 'PARSE_ERROR', + message: '[PARSE_ERROR] Unexpected token', + id: '/src/index.ts', + loc: { line: 1, column: 17, file: '/src/index.ts' }, + }, + { + code: 'PLUGIN_ERROR', + message: 'boom', + plugin: 'my-plugin', + id: '/src/a.ts', + loc: { line: 2, column: 0 }, + frame: '1: const a = 1', + }, + ]) + }) + + test('converts other failures into one log', () => { + expect(toBuildProjectError(new Error(red('failed'))).errors).toEqual([{ message: 'failed' }]) + expect(toBuildProjectError('failed').errors).toEqual([{ message: 'failed' }]) + }) + + test('keeps a BuildProjectError', () => { + const error = new BuildProjectError([{ message: 'unsupported' }]) + + expect(toBuildProjectError(error)).toBe(error) + }) +}) + +describe('stripAnsi', () => { + test('removes the color codes', () => { + expect(stripAnsi(`${red('a')} b ${ESC}[38;5;246mc${ESC}[0m`)).toBe('a b c') + }) +}) diff --git a/packages/vite-dev-server/src/node/builderUtils.ts b/packages/vite-dev-server/src/node/builderUtils.ts new file mode 100644 index 0000000..89bfc70 --- /dev/null +++ b/packages/vite-dev-server/src/node/builderUtils.ts @@ -0,0 +1,327 @@ +/** + * Helpers of the builder: options, outputs, errors and warnings + * + * vrowzer-only module, not in upstream Vite. These helpers do not load rolldown, so that unit tests + * can run them in Node. + * + * @module node/builderUtils + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import path from 'node:path' +import type { RolldownOutput, RolldownWatcher } from 'rolldown' +import { withTrailingSlash } from '../shared/utils' +import type { ResolvedBuildOptions } from './build' +import type { ResolvedConfig } from './config' +import { createLogger } from './logger' +import type { Logger } from './logger' +import type { Plugin } from './plugin' +import { normalizePath } from './utils' + +/** + * A log of the builder: an error or a warning + */ +export interface BuildProjectLog { + message: string + code?: string + plugin?: string + id?: string + loc?: { line: number; column: number; file?: string } + frame?: string +} + +/** + * The result of {@link buildProject} + */ +export interface BuildProjectResult { + /** + * The outputs, keyed by the path from the output root. Binary assets are ArrayBuffers. + */ + files: Record + warnings: BuildProjectLog[] +} + +/** + * The error of a failed build, with the structured logs of the failure + */ +export class BuildProjectError extends Error { + readonly errors: BuildProjectLog[] + + constructor(errors: BuildProjectLog[]) { + super(errors[0]?.message ?? 'Build failed') + this.name = 'BuildProjectError' + this.errors = errors + } +} + +export const UNSUPPORTED_OPTION = 'VROWZER_UNSUPPORTED_OPTION' +export const INVALID_OUTPUT_PATH = 'VROWZER_INVALID_OUTPUT_PATH' +export const PUBLIC_FILE_COLLISION = 'VROWZER_PUBLIC_FILE_COLLISION' + +const ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g') + +export function stripAnsi(text: string): string { + return text.replace(ansiPattern, '') +} + +function unsupported(option: string, message: string): BuildProjectLog { + return { code: UNSUPPORTED_OPTION, message: `[vrowzer] ${option}: ${message}` } +} + +/** + * Check the resolved build options against what the browser build supports. + * + * @param options - The resolved build options + * @returns The problems. An empty array when the options are supported. + */ +export function validateBuildOptions(options: ResolvedBuildOptions): 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.') + ) + } else { + const formats = options.lib.formats ?? ['es'] + const others = formats.filter(format => format !== 'es') + if (others.length > 0) { + problems.push( + unsupported( + 'build.lib.formats', + `only "es" is supported, but got ${others.map(format => `"${format}"`).join(', ')}.` + ) + ) + } + // An undefined entry is left to Vite, which reports it + if (options.lib.entry !== undefined && typeof options.lib.entry !== 'string') { + problems.push(unsupported('build.lib.entry', 'only a single entry is supported.')) + } + } + if (options.minify === 'terser' || options.minify === 'esbuild') { + problems.push( + unsupported('build.minify', `"${options.minify}" is not supported. Use "oxc" or false.`) + ) + } + if (options.watch) { + problems.push(unsupported('build.watch', 'the watch mode is not supported.')) + } + if (options.ssr) { + problems.push(unsupported('build.ssr', 'SSR builds are not supported.')) + } + if (options.manifest) { + problems.push(unsupported('build.manifest', 'manifest files are not supported yet.')) + } + if (options.ssrManifest) { + problems.push(unsupported('build.ssrManifest', 'SSR builds are not supported.')) + } + if (options.license) { + problems.push(unsupported('build.license', 'license files are not supported yet.')) + } + return problems +} + +/** + * The plugin that applies the defaults of the browser build, and rejects what it does not support. + * + * It runs after the plugins of the Worker config, so that it sees the options they set. + * + * @param state - Receives the resolved config + * @returns The plugin + */ +export function createBuildOptionsPlugin(state: { config?: ResolvedConfig }): Plugin { + return { + name: 'vrowzer:build-options', + enforce: 'post', + config(config) { + // `build.cssMinify` resolves to `true` by default, while CSS minification does nothing in the + // browser build. Reject only the option that the config sets. + if (config.build?.cssMinify) { + throw new BuildProjectError([ + unsupported('build.cssMinify', 'CSS minification is not supported in the browser build yet.'), + ]) + } + // The browser build supports the `es` format only, so default to it instead of + // upstream's `['es', 'umd']` + const lib = config.build?.lib + if (lib && lib.formats === undefined) { + return { build: { lib: { formats: ['es'] } } } + } + }, + configResolved(config) { + state.config = config + const problems = validateBuildOptions(config.build) + if (problems.length > 0) { + throw new BuildProjectError(problems) + } + }, + } +} + +/** + * Create the logger of a build. It collects the warnings, and drops the other logs. + * + * @param warnings - Receives the warnings + * @returns The logger + */ +export function createCollectingLogger(warnings: BuildProjectLog[]): Logger { + const logger = createLogger('silent', { allowClearScreen: false }) + const warned = new Set() + const collecting: Logger = { + ...logger, + warn(msg) { + collecting.hasWarned = true + warnings.push({ message: stripAnsi(msg) }) + }, + warnOnce(msg) { + if (warned.has(msg)) { + return + } + warned.add(msg) + collecting.warn(msg) + }, + } + return collecting +} + +function toArrayBuffer(source: Uint8Array): ArrayBuffer { + // The buffer can be shared, or larger than the source + return source.buffer instanceof ArrayBuffer && + source.byteOffset === 0 && + source.byteLength === source.buffer.byteLength + ? source.buffer + : source.slice().buffer +} + +function assertOutputPath(fileName: string): void { + const normalized = path.posix.normalize(fileName) + if ( + path.posix.isAbsolute(normalized) || + normalized === '..' || + normalized.startsWith('../') + ) { + throw new BuildProjectError([ + { + code: INVALID_OUTPUT_PATH, + message: `[vrowzer] The output ${fileName} is outside the output directory.`, + }, + ]) + } +} + +/** + * Collect the outputs of a build. + * + * @param result - The result of `build()` + * @returns The outputs, keyed by the path from the output root + */ +export function collectOutputs( + result: RolldownOutput | RolldownOutput[] | RolldownWatcher +): Record { + if (!Array.isArray(result) && !('output' in result)) { + throw new BuildProjectError([{ message: '[vrowzer] The build did not return outputs.' }]) + } + const files: Record = {} + for (const output of Array.isArray(result) ? result : [result]) { + for (const item of output.output) { + assertOutputPath(item.fileName) + if (item.type === 'chunk') { + files[item.fileName] = item.code + } else { + files[item.fileName] = + typeof item.source === 'string' ? item.source : toArrayBuffer(item.source) + } + } + } + return files +} + +/** + * Add the public files of the snapshot to the outputs. + * + * Vite copies the public directory only when it writes the bundle (`vite:prepare-out-dir`), and the + * builder does not write it. + * + * @param files - The outputs, which receive the public files + * @param snapshot - The project files, keyed by absolute path + * @param config - The resolved config + */ +export function addPublicFiles( + files: Record, + snapshot: Record, + config: Pick & { build: Pick } +): void { + if (!config.build.copyPublicDir || !config.publicDir) { + return + } + const publicDir = withTrailingSlash(normalizePath(config.publicDir)) + for (const [filePath, content] of Object.entries(snapshot)) { + if (!filePath.startsWith(publicDir)) { + continue + } + const fileName = filePath.slice(publicDir.length) + if (Object.hasOwn(files, fileName)) { + throw new BuildProjectError([ + { + code: PUBLIC_FILE_COLLISION, + message: `[vrowzer] The public file ${filePath} has the same path as the output ${fileName}.`, + }, + ]) + } + files[fileName] = typeof content === 'string' ? content : content.slice(0) + } +} + +function toBuildLog(error: unknown): BuildProjectLog { + if (typeof error !== 'object' || error === null) { + return { message: String(error) } + } + const e = error as { + message?: unknown + code?: unknown + plugin?: unknown + id?: unknown + loc?: { line?: unknown; column?: unknown; file?: unknown } + frame?: unknown + } + const log: BuildProjectLog = { message: stripAnsi(String(e.message ?? error)) } + if (typeof e.code === 'string') { + log.code = e.code + } + if (typeof e.plugin === 'string') { + log.plugin = e.plugin + } + if (typeof e.id === 'string') { + log.id = e.id + } + if (e.loc && typeof e.loc.line === 'number' && typeof e.loc.column === 'number') { + log.loc = { line: e.loc.line, column: e.loc.column } + if (typeof e.loc.file === 'string') { + log.loc.file = e.loc.file + } + } + if (typeof e.frame === 'string') { + log.frame = stripAnsi(e.frame) + } + return log +} + +/** + * Convert a failure of a build into a {@link BuildProjectError}. + * + * rolldown reports the errors of a build in `errors`. Other failures become one log. + * + * @param error - The thrown value + * @returns The error with the structured logs + */ +export function toBuildProjectError(error: unknown): BuildProjectError { + if (error instanceof BuildProjectError) { + return error + } + const errors = (error as { errors?: unknown } | null)?.errors + return new BuildProjectError( + Array.isArray(errors) && errors.length > 0 ? errors.map(toBuildLog) : [toBuildLog(error)] + ) +} From 1b8d453025e14144c4bd94d0e0a31b6872f3e857 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:26:48 +0900 Subject: [PATCH 06/11] feat(vrowzer): add vrowzer.build() for library builds `vrowzer.build(options)` builds the project for production in a build Worker, and resolves with the outputs and the warnings (part of #36). Only library builds (`build.lib`) in the `es` format are supported for now. - The runtime keeps a copy of the project files: the files of `ready()` with the default `/index.html`, and the changes of `addFile()`, `updateFile()` and `deleteFile()`, recorded before they are sent. `build()` takes a snapshot of it when it is called, so later changes do not reach the running build. ArrayBuffers are copied. - Each build creates a build Worker (`build-worker.ts`). It loads the builder of `@vrowzer/vite-dev-server`, and merges the options over the Worker config with Vite's `mergeConfig()`. The runtime terminates the Worker when the build ends, whatever the outcome, so nothing stays in memory between builds. - One build at a time. `build()` rejects before `ready()` resolves to true, after `dispose()`, while another build is running, and when `@vrowzer/vite-plugin` does not enable it (`__VROWZER_INTERNAL_BUILD__`). - A failed build rejects with `VrowzerBuildError`, whose `errors` have the code, plugin, id, location and frame of each error. A build that does not finish within the new `buildTimeout` option (120 seconds by default), an aborted `signal`, an error of the build Worker and `dispose()` reject the build, and terminate the build Worker. - The messages of the build Worker are `V_BW_READY`, `V_BW_BUILD` and `V_BW_RESULT`. The results of other builds are ignored. The `build` option of the plugin, which enables it, comes in the next commit. --- packages/vrowzer/package.json | 2 + packages/vrowzer/src/build-messages.ts | 77 ++++ packages/vrowzer/src/build-worker-core.ts | 107 +++++ packages/vrowzer/src/build-worker.ts | 18 + packages/vrowzer/src/index-build.test.ts | 493 ++++++++++++++++++++++ packages/vrowzer/src/index.ts | 389 ++++++++++++++++- packages/vrowzer/vite.config.ts | 3 + 7 files changed, 1081 insertions(+), 8 deletions(-) create mode 100644 packages/vrowzer/src/build-messages.ts create mode 100644 packages/vrowzer/src/build-worker-core.ts create mode 100644 packages/vrowzer/src/build-worker.ts create mode 100644 packages/vrowzer/src/index-build.test.ts diff --git a/packages/vrowzer/package.json b/packages/vrowzer/package.json index 1ec3d1f..5ea6068 100644 --- a/packages/vrowzer/package.json +++ b/packages/vrowzer/package.json @@ -39,6 +39,8 @@ }, "./web-worker": "./dist/web-worker.ts", "./web-worker-core": "./dist/web-worker-core.ts", + "./build-worker": "./dist/build-worker.ts", + "./build-worker-core": "./dist/build-worker-core.ts", "./service-worker": "./dist/service-worker.ts", "./service-worker-core": "./dist/service-worker-core.ts", "./package.json": "./package.json" diff --git a/packages/vrowzer/src/build-messages.ts b/packages/vrowzer/src/build-messages.ts new file mode 100644 index 0000000..0faf8d4 --- /dev/null +++ b/packages/vrowzer/src/build-messages.ts @@ -0,0 +1,77 @@ +/** + * Messages between the runtime and the build Worker. + * + * Like `build-worker-core.ts`, this file is exported as TypeScript source, so it must not import + * the runtime. + * + * @module build-messages + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +/** + * Sent by the build Worker once it has loaded the builder, or failed to. + */ +export const V_BW_READY = 'V_BW_READY' +/** + * Sent by the runtime to request a build. + */ +export const V_BW_BUILD = 'V_BW_BUILD' +/** + * Sent by the build Worker with the result of a build. + */ +export const V_BW_RESULT = 'V_BW_RESULT' + +/** + * A log of a build: an error or a warning. + */ +export interface BuildWorkerLog { + message: string + code?: string + plugin?: string + id?: string + loc?: { line: number; column: number; file?: string } + frame?: string +} + +export interface BuildWorkerReadyMessage { + type: typeof V_BW_READY + /** + * Why the builder could not be loaded + */ + error?: string +} + +export interface BuildWorkerBuildMessage { + type: typeof V_BW_BUILD + /** + * Identifies the build, so that the runtime can ignore the results of other builds + */ + id: number + /** + * The snapshot of the project files, keyed by absolute path + */ + files: Record + /** + * The options of `build()`, without `signal`, merged over the Worker config + */ + options: Record +} + +export type BuildWorkerResultMessage = + | { + type: typeof V_BW_RESULT + id: number + ok: true + files: Record + warnings: BuildWorkerLog[] + } + | { + type: typeof V_BW_RESULT + id: number + ok: false + errors: BuildWorkerLog[] + } diff --git a/packages/vrowzer/src/build-worker-core.ts b/packages/vrowzer/src/build-worker-core.ts new file mode 100644 index 0000000..f8c0687 --- /dev/null +++ b/packages/vrowzer/src/build-worker-core.ts @@ -0,0 +1,107 @@ +/** + * Build Worker core for vrowzer. + * + * Builds a snapshot of the project files with Vite's build, in a Worker that the runtime creates + * for each `build()` and terminates when the build ends. + * + * This file is NOT bundled by vrowzer's build. It is exported as TypeScript source and bundled by + * the user's Vite + Vrowzer, like `web-worker-core.ts`. + * + * @module build-worker-core + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +/// + +import { V_BW_BUILD, V_BW_READY, V_BW_RESULT } from './build-messages.ts' + +import type { UserConfig } from '@vrowzer/vite-dev-server/vite' +import type { + BuildWorkerBuildMessage, + BuildWorkerLog, + BuildWorkerReadyMessage, + BuildWorkerResultMessage +} from './build-messages.ts' + +declare const self: DedicatedWorkerGlobalScope + +function isBuildMessage(data: unknown): data is BuildWorkerBuildMessage { + return ( + typeof data === 'object' && + data !== null && + (data as { type?: unknown }).type === V_BW_BUILD && + typeof (data as { id?: unknown }).id === 'number' + ) +} + +function toErrors(error: unknown): BuildWorkerLog[] { + const errors = (error as { errors?: unknown } | null)?.errors + if (Array.isArray(errors) && errors.length > 0) { + return errors as BuildWorkerLog[] + } + const message = + typeof error === 'object' && error !== null && 'message' in error + ? String(error.message) + : String(error) + return [{ message }] +} + +/** + * Starts the build Worker. + * + * @param config - The Worker config, bundled for builds. The options of each `build()` are merged + * over it. + */ +export function initBuildWorker(config: UserConfig = {}): void { + // Listen before loading the builder, which loads rolldown and its WebAssembly + const builder = import('@vrowzer/vite-dev-server/builder') + + self.addEventListener('message', (event: MessageEvent) => { + const data = event.data + if (!isBuildMessage(data)) { + return + } + void (async () => { + try { + const { buildProject, mergeConfig } = await builder + const result = await buildProject(data.files, mergeConfig(config, data.options)) + const transfer = Object.values(result.files).filter( + (content): content is ArrayBuffer => content instanceof ArrayBuffer + ) + self.postMessage( + { + type: V_BW_RESULT, + id: data.id, + ok: true, + files: result.files, + warnings: result.warnings + } satisfies BuildWorkerResultMessage, + transfer + ) + } catch (error) { + self.postMessage({ + type: V_BW_RESULT, + id: data.id, + ok: false, + errors: toErrors(error) + } satisfies BuildWorkerResultMessage) + } + })() + }) + + builder.then( + () => { + self.postMessage({ type: V_BW_READY } satisfies BuildWorkerReadyMessage) + }, + (error: unknown) => { + self.postMessage({ + type: V_BW_READY, + error: error instanceof Error ? error.message : String(error) + } satisfies BuildWorkerReadyMessage) + } + ) +} diff --git a/packages/vrowzer/src/build-worker.ts b/packages/vrowzer/src/build-worker.ts new file mode 100644 index 0000000..cbc37ce --- /dev/null +++ b/packages/vrowzer/src/build-worker.ts @@ -0,0 +1,18 @@ +/** + * Build Worker entry point for vrowzer. + * + * This file is NOT bundled by vrowzer's build. It is exported as TypeScript source and bundled by + * the user's Vite + Vrowzer. `@vrowzer/vite-plugin` rewrites it to pass the Worker config, or to a + * stub when `vrowzer.build()` is not enabled. + * + * @module build-worker + */ + +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { initBuildWorker } from './build-worker-core' + +initBuildWorker() diff --git a/packages/vrowzer/src/index-build.test.ts b/packages/vrowzer/src/index-build.test.ts new file mode 100644 index 0000000..9393940 --- /dev/null +++ b/packages/vrowzer/src/index-build.test.ts @@ -0,0 +1,493 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vite-plus/test' +import { + V_WW_READY, + V_WW_SETUP, + V_WW_SETUP_ACK, + V_WW_CONNECT_PORT, + V_WW_CONNECT_PORT_ACK, + V_SW_CONNECT_PORT, + V_SW_CONNECT_PORT_ACK +} from '@vrowzer/vite-dev-server/messages' +import { V_BW_BUILD, V_BW_READY, V_BW_RESULT } from './build-messages.ts' + +import type { BuildWorkerBuildMessage } from './build-messages.ts' + +const controllerMocks = vi.hoisted(() => ({ + container: new EventTarget(), + on: vi.fn<(...args: unknown[]) => () => void>(() => () => {}), + postMessage: vi.fn<(message: { type: string }, transfer?: Transferable[]) => void>(), + initServiceWorker: vi.fn<() => Promise>(async () => undefined) +})) + +vi.mock('./controller.ts', () => ({ + getController: () => controllerMocks, + getServiceWorker: () => ({ postMessage: controllerMocks.postMessage }), + getServiceWorkerInstanceId: () => 'sw-1', + initServiceWorker: controllerMocks.initServiceWorker +})) +vi.mock('@vrowzer/vite-dev-server/dist/client/client.mjs?raw', () => ({ default: 'client code' })) +vi.mock('@vrowzer/vite-dev-server/dist/client/env.mjs?raw', () => ({ default: 'env code' })) + +import { Vrowzer, VrowzerBuildError, type VrowzerOptions } from './index.ts' + +interface TestMessage { + type: string + id?: string | number + files?: Record + options?: Record +} + +/** + * How the fake build Workers behave + */ +interface BuildBehavior { + /** + * `ok` reports V_BW_READY, `error` reports that the builder could not load, `none` never answers + */ + ready: 'ok' | 'error' | 'none' + /** + * Answers V_BW_BUILD automatically with this result, or never when `null` + */ + result: Record | null +} + +let webWorkers: TestWorker[] +let buildWorkers: TestWorker[] +let buildBehavior: BuildBehavior + +class TestWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + onmessageerror: ((event: MessageEvent) => void) | null = null + readonly messages: TestMessage[] = [] + readonly terminate = vi.fn<() => void>() + readonly kind: 'web' | 'build' + + constructor(url: URL | string) { + this.kind = String(url).includes('build-worker.ts') ? 'build' : 'web' + if (this.kind === 'build') { + buildWorkers.push(this) + if (buildBehavior.ready === 'ok') { + this.reply({ type: V_BW_READY }) + } else if (buildBehavior.ready === 'error') { + this.reply({ type: V_BW_READY, error: 'cannot load the builder' }) + } + } else { + webWorkers.push(this) + this.reply({ type: V_WW_READY }) + } + } + + postMessage(message: TestMessage): void { + this.messages.push(message) + if (message.type === V_WW_SETUP) { + this.reply({ type: V_WW_SETUP_ACK }) + } else if (message.type === V_SW_CONNECT_PORT) { + this.reply({ type: V_SW_CONNECT_PORT_ACK }) + } else if (message.type === 'V_FS_WRITE' || message.type === 'V_FS_UNLINK') { + this.reply({ type: 'V_FS_ACK', id: message.id }) + } else if (message.type === V_BW_BUILD && buildBehavior.result) { + this.reply({ type: V_BW_RESULT, id: message.id, ...buildBehavior.result }) + } + } + + /** + * The build request that the runtime sent + */ + get buildMessage(): BuildWorkerBuildMessage { + const message = this.messages.find(item => item.type === V_BW_BUILD) + if (!message) { + throw new Error('No build request was sent to the build Worker') + } + return message as unknown as BuildWorkerBuildMessage + } + + send(data: object): void { + this.onmessage?.({ data } as MessageEvent) + } + + private reply(data: object): void { + queueMicrotask(() => this.onmessage?.({ data } as MessageEvent)) + } +} + +async function readyInstance( + options?: VrowzerOptions, + files: Record = {} +) { + const vrowzer = Vrowzer(options) + await expect(vrowzer.ready({ files })).resolves.toBe(true) + return vrowzer +} + +async function settle(): Promise { + for (let index = 0; index < 10; index++) { + await Promise.resolve() + } +} + +async function isPending(promise: Promise): Promise { + let settled = false + promise.then( + () => { + settled = true + }, + () => { + settled = true + } + ) + await settle() + return !settled +} + +const okResult = { ok: true, files: { 'my-lib.js': 'export {}' }, warnings: [] } + +beforeEach(() => { + vi.clearAllMocks() + webWorkers = [] + buildWorkers = [] + buildBehavior = { ready: 'ok', result: okResult } + controllerMocks.container = new EventTarget() + vi.stubGlobal('Worker', TestWorker) + vi.stubGlobal('__VROWZER_INTERNAL_BUILD__', true) + vi.stubGlobal( + 'MessageChannel', + class { + port1 = {} + port2 = {} + } + ) + controllerMocks.postMessage.mockImplementation(message => { + if (message.type === V_WW_CONNECT_PORT) { + const { runtimeId } = message as { runtimeId?: string } + queueMicrotask(() => { + controllerMocks.container.dispatchEvent( + new MessageEvent('message', { data: { type: V_WW_CONNECT_PORT_ACK, runtimeId } }) + ) + }) + } + }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('Vrowzer.build() state', () => { + test('rejects before ready() resolves, without a build Worker', async () => { + const vrowzer = Vrowzer() + + await expect(vrowzer.build()).rejects.toThrow( + '[Vrowzer] build() can only be called after ready() resolves to true (current state: idle)' + ) + expect(buildWorkers).toHaveLength(0) + }) + + test('rejects after dispose()', async () => { + const vrowzer = await readyInstance() + await vrowzer.dispose() + + await expect(vrowzer.build()).rejects.toThrow( + '[Vrowzer] build() cannot be called after dispose()' + ) + expect(buildWorkers).toHaveLength(0) + }) + + test('rejects when the plugin did not enable it', async () => { + vi.stubGlobal('__VROWZER_INTERNAL_BUILD__', false) + const vrowzer = await readyInstance() + + await expect(vrowzer.build()).rejects.toThrow( + '[Vrowzer] build() is not enabled. Set `build: true` in the options of Vrowzer() from @vrowzer/vite-plugin.' + ) + expect(buildWorkers).toHaveLength(0) + }) + + test('rejects without the plugin', async () => { + vi.unstubAllGlobals() + vi.stubGlobal('Worker', TestWorker) + vi.stubGlobal( + 'MessageChannel', + class { + port1 = {} + port2 = {} + } + ) + const vrowzer = await readyInstance() + + await expect(vrowzer.build()).rejects.toThrow('[Vrowzer] build() is not enabled.') + expect(buildWorkers).toHaveLength(0) + }) + + test('rejects a build while another one is running', async () => { + buildBehavior.result = null + const vrowzer = await readyInstance() + const first = vrowzer.build() + + await expect(vrowzer.build()).rejects.toThrow('[Vrowzer] build() is already running.') + expect(buildWorkers).toHaveLength(1) + expect(await isPending(first)).toBe(true) + + buildWorkers[0]!.send({ type: V_BW_RESULT, id: buildWorkers[0]!.buildMessage.id, ...okResult }) + await expect(first).resolves.toEqual({ files: okResult.files, warnings: [] }) + buildBehavior.result = okResult + await expect(vrowzer.build()).resolves.toEqual({ files: okResult.files, warnings: [] }) + }) + + test('rejects with the reason of an aborted signal, without a build Worker', async () => { + const vrowzer = await readyInstance() + const reason = new Error('aborted before') + + await expect(vrowzer.build({ signal: AbortSignal.abort(reason) })).rejects.toBe(reason) + expect(buildWorkers).toHaveLength(0) + }) +}) + +describe('Vrowzer.build() files', () => { + test('sends the files of ready() with the default index.html, without the client files', async () => { + const binary = new Uint8Array([1, 2, 3]).buffer + const vrowzer = await readyInstance(undefined, { + '/src/index.ts': 'export const a = 1', + '/logo.png': binary + }) + + await vrowzer.build() + + const { files } = buildWorkers[0]!.buildMessage + expect(Object.keys(files).sort()).toEqual(['/index.html', '/logo.png', '/src/index.ts']) + expect(files['/index.html']).toContain('') + expect(files['/logo.png']).not.toBe(binary) + expect([...new Uint8Array(files['/logo.png'] as ArrayBuffer)]).toEqual([1, 2, 3]) + }) + + test('includes the file operations called before', async () => { + const vrowzer = await readyInstance(undefined, { + '/index.html': '', + '/src/a.ts': 'a', + '/src/b.ts': 'b' + }) + const binary = new Uint8Array([4, 5]) + + await vrowzer.addFile('/src/c.ts', 'c') + await vrowzer.updateFile('/src/a.ts', 'a2') + await vrowzer.deleteFile('/src/b.ts') + await vrowzer.addFile('/data.bin', binary.buffer) + binary[0] = 9 + await vrowzer.build() + + const { files } = buildWorkers[0]!.buildMessage + expect(files).toEqual({ + '/index.html': '', + '/src/a.ts': 'a2', + '/src/c.ts': 'c', + '/data.bin': expect.any(ArrayBuffer) + }) + expect([...new Uint8Array(files['/data.bin'] as ArrayBuffer)]).toEqual([4, 5]) + }) + + test('builds the files as they are when build() is called', async () => { + buildBehavior.ready = 'none' + const vrowzer = await readyInstance(undefined, { '/src/a.ts': 'before' }) + + const result = vrowzer.build() + const update = vrowzer.updateFile('/src/a.ts', 'after') + const add = vrowzer.addFile('/src/new.ts', 'new') + await Promise.all([update, add]) + buildWorkers[0]!.send({ type: V_BW_READY }) + await settle() + + const { files } = buildWorkers[0]!.buildMessage + expect(files['/src/a.ts']).toBe('before') + expect(files).not.toHaveProperty('/src/new.ts') + buildWorkers[0]!.send({ type: V_BW_RESULT, id: buildWorkers[0]!.buildMessage.id, ...okResult }) + await expect(result).resolves.toEqual({ files: okResult.files, warnings: [] }) + + buildBehavior.ready = 'ok' + await vrowzer.build() + expect(buildWorkers[1]!.buildMessage.files).toMatchObject({ + '/src/a.ts': 'after', + '/src/new.ts': 'new' + }) + }) + + test('sends the options without the signal', async () => { + const vrowzer = await readyInstance() + const options = { + mode: 'staging', + define: { __VERSION__: '"1.0.0"' }, + build: { lib: { entry: '/src/index.ts', fileName: 'my-lib' }, minify: false } + } + + await vrowzer.build({ ...options, signal: new AbortController().signal }) + + expect(buildWorkers[0]!.buildMessage.options).toEqual(options) + }) +}) + +describe('Vrowzer.build() result', () => { + test('resolves with the outputs and the warnings, and terminates the build Worker', async () => { + const binary = new Uint8Array([1]).buffer + buildBehavior.result = { + ok: true, + files: { 'my-lib.js': 'export {}', 'assets/a.bin': binary }, + warnings: [{ message: '[plugin test] careful' }] + } + const vrowzer = await readyInstance() + + await expect(vrowzer.build()).resolves.toEqual({ + files: { 'my-lib.js': 'export {}', 'assets/a.bin': binary }, + warnings: [{ message: '[plugin test] careful' }] + }) + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + expect(webWorkers[0]!.terminate).not.toHaveBeenCalled() + }) + + test('creates a new build Worker for each build', async () => { + const vrowzer = await readyInstance() + + await vrowzer.build() + await vrowzer.build() + + expect(buildWorkers).toHaveLength(2) + expect(buildWorkers.every(worker => worker.terminate.mock.calls.length === 1)).toBe(true) + }) + + test('rejects with a VrowzerBuildError that has the errors of the build', async () => { + const errors = [ + { + code: 'PARSE_ERROR', + message: '[PARSE_ERROR] Unexpected token\n ╭─[ src/index.ts:1:18 ]', + id: '/src/index.ts', + loc: { line: 1, column: 17, file: '/src/index.ts' } + }, + { code: 'PLUGIN_ERROR', message: 'boom', plugin: 'my-plugin' } + ] + buildBehavior.result = { ok: false, errors } + const vrowzer = await readyInstance() + + const error = await vrowzer.build().catch((error: unknown) => error) + + expect(error).toBeInstanceOf(VrowzerBuildError) + expect(error).toMatchObject({ + name: 'VrowzerBuildError', + message: '[Vrowzer] build() failed: [PARSE_ERROR] Unexpected token (and 1 more)', + errors + }) + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + }) + + test('ignores the results of other builds', async () => { + buildBehavior.result = null + const vrowzer = await readyInstance() + const result = vrowzer.build() + await settle() + const worker = buildWorkers[0]! + const { id } = worker.buildMessage + + worker.send({ type: V_BW_RESULT, id: id + 1, ...okResult }) + worker.send({ type: 'OTHER' }) + expect(await isPending(result)).toBe(true) + + worker.send({ type: V_BW_RESULT, id, ...okResult }) + await expect(result).resolves.toEqual({ files: okResult.files, warnings: [] }) + }) + + test('rejects when the build Worker cannot load the builder', async () => { + buildBehavior.ready = 'error' + const vrowzer = await readyInstance() + + await expect(vrowzer.build()).rejects.toThrow( + '[Vrowzer] The build Worker could not load the builder: cannot load the builder' + ) + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + }) + + test('rejects when the build Worker reports an error', async () => { + buildBehavior.result = null + const vrowzer = await readyInstance() + const result = vrowzer.build() + await settle() + + buildWorkers[0]!.onerror?.({ message: 'Uncaught SyntaxError' } as ErrorEvent) + + await expect(result).rejects.toThrow('[Vrowzer] The build Worker failed: Uncaught SyntaxError') + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + }) + + test('rejects when the options cannot be sent', async () => { + const vrowzer = await readyInstance() + buildBehavior.result = null + const result = vrowzer.build() + const worker = buildWorkers[0]! + worker.postMessage = () => { + throw new DOMException('function could not be cloned', 'DataCloneError') + } + + await expect(result).rejects.toThrow( + '[Vrowzer] build() could not send the options to the build Worker: function could not be cloned' + ) + expect(worker.terminate).toHaveBeenCalledOnce() + }) +}) + +describe('Vrowzer.build() timeout, abort and dispose', () => { + test('times out after 120000ms by default', async () => { + vi.useFakeTimers() + buildBehavior.result = null + const vrowzer = await readyInstance() + const result = vrowzer.build() + const assertion = expect(result).rejects.toThrow('[Vrowzer] build() timed out after 120000ms') + + await vi.advanceTimersByTimeAsync(119_999) + expect(buildWorkers[0]!.terminate).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + + await assertion + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + }) + + test('uses buildTimeout, and builds again with a new build Worker after a timeout', async () => { + vi.useFakeTimers() + buildBehavior.result = null + const vrowzer = await readyInstance({ buildTimeout: 5000 }) + const result = vrowzer.build() + const assertion = expect(result).rejects.toThrow('[Vrowzer] build() timed out after 5000ms') + + await vi.advanceTimersByTimeAsync(5000) + await assertion + + buildBehavior.result = okResult + await expect(vrowzer.build()).resolves.toEqual({ files: okResult.files, warnings: [] }) + expect(buildWorkers).toHaveLength(2) + expect(buildWorkers[1]!.terminate).toHaveBeenCalledOnce() + }) + + test('rejects with the reason when the signal aborts, and terminates the build Worker', async () => { + buildBehavior.result = null + const vrowzer = await readyInstance() + const controller = new AbortController() + const result = vrowzer.build({ signal: controller.signal }) + await settle() + const reason = new Error('cancelled by the user') + + controller.abort(reason) + + await expect(result).rejects.toBe(reason) + expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() + }) + + test('rejects a running build on dispose(), and terminates the build Worker', async () => { + buildBehavior.result = null + const vrowzer = await readyInstance() + const result = vrowzer.build() + await settle() + + await vrowzer.dispose() + + await expect(result).rejects.toThrow('[Vrowzer] build() was cancelled by dispose()') + expect(buildWorkers[0]!.terminate).toHaveBeenCalled() + await expect(vrowzer.build()).rejects.toThrow('cannot be called after dispose()') + }) +}) diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index fe63593..c7f5a17 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -55,6 +55,7 @@ import { V_WW_DISCONNECT_PORT } from '@vrowzer/vite-dev-server/messages' import { abortable } from './abort.ts' +import { V_BW_BUILD, V_BW_READY, V_BW_RESULT } from './build-messages.ts' import { getServiceWorker, getController, @@ -68,11 +69,22 @@ import { resolveServiceWorkerVersion, withServiceWorkerVersion } from './service import type { Emittable } from '@kazupon/jts-utils/event/emitter' import type { FileSystemPublisher } from '@vrowzer/fs/watcher' import type { DisconnectWebWorkerPortMessage } from '@vrowzer/vite-dev-server/messages' +import type { BuildWorkerBuildMessage, BuildWorkerResultMessage } from './build-messages.ts' import type { SvcWorkerControllerEventMap } from '@vrowzer/service-worker/controller' const DEFAULT_SERVICE_WORKER_READY_TIMEOUT = 60_000 const DEFAULT_WEB_WORKER_SETUP_TIMEOUT = 90_000 const DEFAULT_FILE_SYNC_TIMEOUT = 10_000 +const DEFAULT_BUILD_TIMEOUT = 120_000 + +/** + * `true` when `@vrowzer/vite-plugin` enables {@link Vrowzer.build} with its `build` option. + */ +declare const __VROWZER_INTERNAL_BUILD__: boolean + +function isBuildEnabled(): boolean { + return typeof __VROWZER_INTERNAL_BUILD__ === 'boolean' && __VROWZER_INTERNAL_BUILD__ +} /** * The `/index.html` used when the files given to `ready()` have none. It loads `/main.js` as a @@ -169,6 +181,207 @@ export interface VrowzerOptions { * @default 10000 */ fileSyncTimeout?: number + /** + * Timeout in milliseconds for {@link Vrowzer.build}, measured from the creation of the build + * Worker until the result is received. + * + * It includes loading the builder in the build Worker. Set to `0` for an immediate timeout. + * + * @default 120000 + */ + buildTimeout?: number +} + +/** + * The library options of {@link VrowzerBuildOptions.build}, a subset of Vite's `build.lib`. + */ +export interface VrowzerBuildLibraryOptions { + /** + * The entry of the library, e.g. `/src/index.ts`. Its exports are the API of the library. + */ + entry: string + /** + * The name of the output file. Without it, the `name` of `/package.json` is used, and the + * build fails when there is none. + */ + fileName?: string + /** + * The name of the CSS file. Defaults to `fileName`. + */ + cssFileName?: string + /** + * The output formats. Only `es` is supported for now. + * + * @default ['es'] + */ + formats?: ['es'] +} + +/** + * Options for {@link Vrowzer.build}: a subset of the Vite config. + * + * They are merged over the Worker config, and sent to the build Worker, so they must be values that + * `postMessage()` can copy, e.g. no functions. + */ +export interface VrowzerBuildOptions { + /** + * The public base path of the outputs (Vite's `base`). + * + * @default '/' + */ + base?: string + /** + * The mode (Vite's `mode`), which `import.meta.env.MODE` returns. + * + * @default 'production' + */ + mode?: string + /** + * Global constants to replace (Vite's `define`), merged over the `define` of the Worker config. + */ + define?: Record + /** + * Build options: a subset of Vite's `build`. + */ + build?: { + /** + * Builds a library. Required for now, because HTML app builds are not supported yet. + */ + lib?: VrowzerBuildLibraryOptions | false + /** + * Minifies the JavaScript with Oxc. The CSS is not minified. + * + * @default true + */ + minify?: boolean + /** + * Generates source maps of the JavaScript. + * + * @default false + */ + sourcemap?: boolean | 'inline' | 'hidden' + /** + * The directory of the assets, relative to the output root. + * + * @default 'assets' + */ + assetsDir?: string + /** + * Assets smaller than this many bytes are inlined as data URLs. + * + * @default 4096 + */ + assetsInlineLimit?: number + /** + * Splits the CSS of async chunks into their own files. Without it, the CSS goes into one file. + * + * @default false for a library, true otherwise + */ + cssCodeSplit?: boolean + /** + * The compatibility target of the JavaScript, e.g. `'es2022'`. + * + * @default 'baseline-widely-available' + */ + target?: string | string[] + /** + * The module preload of the outputs. + */ + modulePreload?: boolean | { polyfill?: boolean } + /** + * Options for rolldown. + */ + rolldownOptions?: { + /** + * The entry of the build, e.g. an HTML file. + */ + input?: string + /** + * Modules to keep as imports instead of bundling them. + */ + external?: (string | RegExp)[] + output?: { + /** + * Set to `false` to put all the code into one file, including dynamically imported modules. + */ + codeSplitting?: boolean + } + } + } + /** + * Cancels the build. The build Worker is terminated, and the promise rejects with the reason. + */ + signal?: AbortSignal +} + +/** + * A log of {@link Vrowzer.build}: an error or a warning. + */ +export interface VrowzerBuildLog { + /** + * The message, without colors. + */ + message: string + /** + * The code of the log, e.g. `PARSE_ERROR` from rolldown, or `VROWZER_UNSUPPORTED_OPTION` for an + * option that the browser build does not support. + */ + code?: string + /** + * The plugin that reported it. + */ + plugin?: string + /** + * The module that it is about. + */ + id?: string + /** + * The location in the module. The column starts at 0. + */ + loc?: { line: number; column: number; file?: string } + /** + * The code around the location. + */ + frame?: string +} + +/** + * The result of {@link Vrowzer.build}. + */ +export interface VrowzerBuildResult { + /** + * The outputs, keyed by the path from the output root, e.g. `my-lib.js`. + * + * JavaScript, CSS, source maps and text assets are strings. Binary assets are ArrayBuffers. + */ + files: Record + /** + * The warnings of the build. + */ + warnings: VrowzerBuildLog[] +} + +function summarizeBuildErrors(errors: readonly VrowzerBuildLog[]): string { + const first = errors[0]?.message.split('\n')[0]?.trim() || 'unknown error' + const more = errors.length > 1 ? ` (and ${errors.length - 1} more)` : '' + return `[Vrowzer] build() failed: ${first}${more}` +} + +/** + * The error of a {@link Vrowzer.build} that failed: an error in the project, or an option that the + * browser build does not support. + */ +export class VrowzerBuildError extends Error { + /** + * The errors of the build. The message summarizes the first one. + */ + readonly errors: readonly VrowzerBuildLog[] + + constructor(errors: readonly VrowzerBuildLog[], options?: ErrorOptions) { + super(summarizeBuildErrors(errors), options) + this.name = 'VrowzerBuildError' + this.errors = errors + } } /** @@ -424,6 +637,30 @@ export interface Vrowzer extends Emittable { * @param filePath - The path of the file to be deleted. */ deleteFile(filePath: string): Promise + /** + * Builds the project for production in a build Worker, e.g. as 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 + * the file methods called before. Changes made later are not included, even before the build + * 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. + * + * It needs the `build` option of `@vrowzer/vite-plugin`. + * + * @param options - The options of the build, merged over the Worker config. + * @returns The outputs and the warnings. + * @throws Rejects before {@link Vrowzer.ready} resolves to `true`, after {@link Vrowzer.dispose}, + * when the `build` option of the plugin is not enabled, and while another build is running. + * Rejects with a {@link VrowzerBuildError} when the build fails, e.g. with an error in the + * project or an unsupported option. Rejects with an `Error` when the build does not finish within + * {@link VrowzerOptions.buildTimeout}, when the build Worker fails, or when {@link Vrowzer.dispose} + * is called first, and with the reason of `signal` when it is aborted. + */ + build(options?: VrowzerBuildOptions): Promise /** * Disposes this instance. * @@ -433,8 +670,9 @@ export interface Vrowzer extends Emittable { * all event handlers are removed right away. The Service Worker registration is kept for other * clients, and answers the requests under {@link Vrowzer.previewBasePath} with 404 from then on. * - * File operations still waiting for the Web Worker reject. After disposal, `ready()` and the file - * methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a + * File operations still waiting for the Web Worker reject, and a running {@link Vrowzer.build} + * rejects with its build Worker terminated. After disposal, `ready()`, the file methods and + * `build()` reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a * new instance to start again. * * @returns A promise that resolves when every resource is released. Calling this method again @@ -455,6 +693,7 @@ interface ResolvedVrowzerOptions { serviceWorkerReadyTimeout: number webWorkerSetupTimeout: number fileSyncTimeout: number + buildTimeout: number } /** @@ -485,7 +724,8 @@ function resolveVrowzerOptions(options: VrowzerOptions): ResolvedVrowzerOptions serviceWorkerReadyTimeout: options.serviceWorkerReadyTimeout ?? DEFAULT_SERVICE_WORKER_READY_TIMEOUT, webWorkerSetupTimeout: options.webWorkerSetupTimeout ?? DEFAULT_WEB_WORKER_SETUP_TIMEOUT, - fileSyncTimeout: options.fileSyncTimeout ?? DEFAULT_FILE_SYNC_TIMEOUT + fileSyncTimeout: options.fileSyncTimeout ?? DEFAULT_FILE_SYNC_TIMEOUT, + buildTimeout: options.buildTimeout ?? DEFAULT_BUILD_TIMEOUT } } @@ -675,6 +915,93 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { let stopListeningServiceWorkerStarts: (() => void) | null = null // The recovery of a restarted Service Worker in progress let recovery: AbortController | null = null + // The project files as the caller gave them, for build(). The Web Worker has its own copy. + const projectFiles = new Map() + // The running build, which dispose() cancels + let runningBuild: { cancel: (error: Error) => void } | null = null + // Identifies the builds, so that the results of other builds are ignored + let buildSequence = 0 + + /** + * Runs a build in a new build Worker, and terminates the Worker when the build ends. + */ + function runBuild( + files: Record, + options: Omit, + signal: AbortSignal | undefined + ): Promise { + const id = ++buildSequence + const worker = new Worker(new URL('./build-worker.ts', import.meta.url), { type: 'module' }) + const close = () => { + worker.onmessage = null + worker.onerror = null + worker.onmessageerror = null + worker.terminate() + } + + const result = new Promise((resolve, reject) => { + const build = { + cancel: (error: Error) => { + reject(error) + close() + } + } + runningBuild = build + worker.onerror = event => { + reject(new Error(`[Vrowzer] The build Worker failed: ${event.message || 'unknown error'}`)) + } + worker.onmessageerror = () => { + reject(new Error('[Vrowzer] The build Worker sent a message that could not be read')) + } + worker.onmessage = (event: MessageEvent) => { + const data = event.data + if (!isRecord(data)) { + return + } + if (data.type === V_BW_READY) { + if (typeof data.error === 'string') { + reject( + new Error(`[Vrowzer] The build Worker could not load the builder: ${data.error}`) + ) + return + } + try { + worker.postMessage({ + type: V_BW_BUILD, + id, + files, + options + } satisfies BuildWorkerBuildMessage) + } catch (error) { + reject( + new Error( + `[Vrowzer] build() could not send the options to the build Worker: ${error instanceof Error ? error.message : String(error)}`, + { cause: error } + ) + ) + } + return + } + if (data.type !== V_BW_RESULT || data.id !== id) { + return + } + const message = data as BuildWorkerResultMessage + if (message.ok) { + resolve({ files: message.files, warnings: message.warnings }) + } else { + reject(new VrowzerBuildError(message.errors)) + } + } + }) + + const current = runningBuild + return withTimeout(result, resolved.buildTimeout, '[Vrowzer] build()', signal).finally(() => { + if (runningBuild === current) { + runningBuild = null + } + close() + }) + } function cleanupWebWorker(): void { const worker = webWorker @@ -1223,6 +1550,10 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { if (!Object.hasOwn(initialFiles, '/index.html')) { initialFiles['/index.html'] = DEFAULT_INDEX_HTML } + // Keep the files for build(). The Web Worker gets copies of them, so they can be shared. + for (const [path, content] of Object.entries(initialFiles)) { + projectFiles.set(path, content) + } // 1. Create Web Worker + add as publisher target webWorker = new Worker(new URL('./web-worker.ts', import.meta.url), { type: 'module' }) @@ -1412,6 +1743,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { ) ) stopRecovery(new Error('[Vrowzer] The instance was disposed')) + const build = runningBuild + if (build) { + attempt(errors, () => build.cancel(new Error('[Vrowzer] build() was cancelled by dispose()'))) + } + projectFiles.clear() disposePromise = (async () => { // An aborted ready() releases what it created and resolves to false @@ -1546,19 +1882,56 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { }, addFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('addFile', filePath, options => + return syncFile('addFile', filePath, options => { + projectFiles.set(filePath, typeof content === 'string' ? content : content.slice(0)) publisher.writeFile(filePath, content, options) - ) + }) }, updateFile(filePath: string, content: string | ArrayBuffer): Promise { - return syncFile('updateFile', filePath, options => + return syncFile('updateFile', filePath, options => { + projectFiles.set(filePath, typeof content === 'string' ? content : content.slice(0)) publisher.writeFile(filePath, content, options) - ) + }) }, deleteFile(filePath: string): Promise { - return syncFile('deleteFile', filePath, options => publisher.unlink(filePath, options)) + return syncFile('deleteFile', filePath, options => { + projectFiles.delete(filePath) + publisher.unlink(filePath, options) + }) + }, + + build(options: VrowzerBuildOptions = {}): Promise { + if (readyState === 'disposed') { + return Promise.reject(new Error('[Vrowzer] build() cannot be called after dispose()')) + } + if (readyState !== 'ready') { + return Promise.reject( + new Error( + `[Vrowzer] build() can only be called after ready() resolves to true (current state: ${readyState})` + ) + ) + } + if (!isBuildEnabled()) { + return Promise.reject( + new Error( + '[Vrowzer] build() is not enabled. Set `build: true` in the options of Vrowzer() from @vrowzer/vite-plugin.' + ) + ) + } + if (runningBuild) { + return Promise.reject( + new Error('[Vrowzer] build() is already running. Call it again after the build ends.') + ) + } + const { signal, ...buildOptions } = options + if (signal?.aborted) { + return Promise.reject(signal.reason) + } + // The snapshot: later file operations do not change the build + const files = Object.fromEntries(projectFiles) + return runBuild(files, buildOptions, signal) }, dispose, diff --git a/packages/vrowzer/vite.config.ts b/packages/vrowzer/vite.config.ts index b7fe182..4b99997 100644 --- a/packages/vrowzer/vite.config.ts +++ b/packages/vrowzer/vite.config.ts @@ -19,6 +19,9 @@ export default defineConfig({ const distDir = resolve(import.meta.dirname, 'dist') cpSync(resolve(srcDir, 'web-worker.ts'), resolve(distDir, 'web-worker.ts')) cpSync(resolve(srcDir, 'web-worker-core.ts'), resolve(distDir, 'web-worker-core.ts')) + cpSync(resolve(srcDir, 'build-worker.ts'), resolve(distDir, 'build-worker.ts')) + cpSync(resolve(srcDir, 'build-worker-core.ts'), resolve(distDir, 'build-worker-core.ts')) + cpSync(resolve(srcDir, 'build-messages.ts'), resolve(distDir, 'build-messages.ts')) cpSync(resolve(srcDir, 'service-worker.ts'), resolve(distDir, 'service-worker.ts')) cpSync(resolve(srcDir, 'preview-base.ts'), resolve(distDir, 'preview-base.ts')) cpSync( From 19e325e5f8224859837697e701ecd1467819a8d8 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:33:59 +0900 Subject: [PATCH 07/11] feat(vite-plugin): add the build option to enable vrowzer.build() `Vrowzer({ build: true })` enables `vrowzer.build()` (#36). It is disabled by default. - The plugin injects `__VROWZER_INTERNAL_BUILD__`, as it does for the other runtime settings, so that `build()` rejects with guidance when the option is off. The value is checked like the other reserved defines. - With the option, the Worker config is also prebundled for builds, with `process.env.NODE_ENV` set to "production", to `node_modules/.vrowzer/config.build.bundled.mjs`. It has the same inputs as the dev config, which the dev watch already covers. - The build Worker entry (`vrowzer/build-worker`) gets that config, both in the host dev server and in host builds. - Without the option, the entry is replaced with a stub, so the host output does not include the builder (about 2.7 MB of rolldown and the Vite build). The e2e projects now emit a stub of 211 bytes. - README: the `build` option. --- packages/vite-plugin/README.md | 9 +++- packages/vite-plugin/src/env.test.ts | 36 ++++++++++++-- packages/vite-plugin/src/env.ts | 12 ++++- packages/vite-plugin/src/index.test.ts | 58 ++++++++++++++++++++++ packages/vite-plugin/src/index.ts | 48 ++++++++++++++++-- packages/vite-plugin/src/options.test.ts | 11 ++++ packages/vite-plugin/src/options.ts | 19 ++++++- packages/vite-plugin/src/prebundle.test.ts | 33 ++++++++++++ packages/vite-plugin/src/prebundle.ts | 25 +++++++--- packages/vite-plugin/src/virtual.test.ts | 57 ++++++++++++++++++--- packages/vite-plugin/src/virtual.ts | 40 +++++++++++++++ 11 files changed, 325 insertions(+), 23 deletions(-) diff --git a/packages/vite-plugin/README.md b/packages/vite-plugin/README.md index 567af58..3d9deed 100644 --- a/packages/vite-plugin/README.md +++ b/packages/vite-plugin/README.md @@ -356,7 +356,11 @@ Vrowzer({ // Default: undefined resolve: { alias: [{ find: 'my-lib', replacement: '/libs/my-lib.js' }] - } + }, + + // Enable vrowzer.build(), the production build in a build Worker + // Default: false + build: true }) ``` @@ -372,6 +376,7 @@ Vrowzer({ | `serviceWorkerVersion` | `string` | `'vrowzer-v1'` | Version shared with the application and Service Worker bundles. | | `serviceWorkerEntry` | `string` | Resolved path to `vrowzer/service-worker` | Explicit Service Worker entry file path. | | `resolve` | `{ alias?: Alias[] }` | `undefined` | Legacy Worker resolve replacement. With extraction disabled, warns to move it to `workerConfig`. | +| `build` | `boolean` | `false` | Enable `vrowzer.build()`. The host output then includes the build Worker and its builder. | ### `VrowzerManifestOptions` @@ -413,6 +418,8 @@ When `auto: true` (default), automatically generates a vrowzer manifest in `conf Selects a dedicated `workerConfig` file, an empty config when extraction is disabled, or supported settings extracted from the host config. It then prebundles that input with Rolldown for the Web Worker. The prebundled config is written to `node_modules/.vrowzer/config.bundled.mjs`. +With `build: true`, the same input is also prebundled for `vrowzer.build()`, with `process.env.NODE_ENV` set to `"production"`, to `node_modules/.vrowzer/config.build.bundled.mjs`. The build Worker entry (`vrowzer/build-worker`) gets that config. Without `build: true`, the entry is replaced with a stub, so the host output does not include the builder. + - Resolves `@vrowzer/*` imports from the plugin's own dependency graph - Inlines `readFileSync()` and `createRequire()` calls for Worker compatibility - Maps `vite` imports to `@vrowzer/vite-dev-server/vite` diff --git a/packages/vite-plugin/src/env.test.ts b/packages/vite-plugin/src/env.test.ts index d42a29f..7758038 100644 --- a/packages/vite-plugin/src/env.test.ts +++ b/packages/vite-plugin/src/env.test.ts @@ -3,7 +3,8 @@ import { envPlugin, VROWZER_PREVIEW_BASE_PATH_DEFINE, VROWZER_SERVICE_WORKER_SCOPE_DEFINE, - VROWZER_SERVICE_WORKER_VERSION_DEFINE + VROWZER_SERVICE_WORKER_VERSION_DEFINE, + VROWZER_BUILD_DEFINE } from './env.ts' import { resolveOptions } from './options.ts' @@ -147,21 +148,50 @@ describe('envPlugin', () => { 'import.meta.env.DEBUG': expect.any(String), [VROWZER_PREVIEW_BASE_PATH_DEFINE]: JSON.stringify('/app/__preview__/'), [VROWZER_SERVICE_WORKER_SCOPE_DEFINE]: JSON.stringify('/app/'), - [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: JSON.stringify('app-v2') + [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: JSON.stringify('app-v2'), + [VROWZER_BUILD_DEFINE]: 'false' }) }) + test.each([ + [{}, 'false'], + [{ build: true }, 'true'] + ])('config hook defines whether build() is enabled (%j)', (options, expected) => { + const plugin = createPlugin(options) + const result = (plugin as any).config({}, { command: 'serve' }) + + expect(result.define[VROWZER_BUILD_DEFINE]).toBe(expected) + }) + test('configResolved accepts all injected reserved values', () => { const plugin = createPlugin() const define = { [VROWZER_PREVIEW_BASE_PATH_DEFINE]: JSON.stringify('/__preview__/'), [VROWZER_SERVICE_WORKER_SCOPE_DEFINE]: JSON.stringify('/'), - [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: JSON.stringify('vrowzer-v1') + [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: JSON.stringify('vrowzer-v1'), + [VROWZER_BUILD_DEFINE]: 'false' } expect(() => (plugin as any).configResolved({ define })).not.toThrow() }) + test.each([['true'], [undefined]])( + 'configResolved rejects an overridden or missing build flag (%j)', + value => { + const plugin = createPlugin() + const define = { + [VROWZER_PREVIEW_BASE_PATH_DEFINE]: JSON.stringify('/__preview__/'), + [VROWZER_SERVICE_WORKER_SCOPE_DEFINE]: JSON.stringify('/'), + [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: JSON.stringify('vrowzer-v1'), + ...(value === undefined ? {} : { [VROWZER_BUILD_DEFINE]: value }) + } + + expect(() => (plugin as any).configResolved({ define })).toThrow( + `Vrowzer reserved define ${VROWZER_BUILD_DEFINE} must be false` + ) + } + ) + test('configResolved rejects an overridden preview base path', () => { const plugin = createPlugin() const define = { diff --git a/packages/vite-plugin/src/env.ts b/packages/vite-plugin/src/env.ts index 957b223..8dca8ce 100644 --- a/packages/vite-plugin/src/env.ts +++ b/packages/vite-plugin/src/env.ts @@ -22,6 +22,7 @@ const debug = createDebug('vite-plugin-vrowzer:env') export const VROWZER_PREVIEW_BASE_PATH_DEFINE = '__VROWZER_INTERNAL_PREVIEW_BASE_PATH__' export const VROWZER_SERVICE_WORKER_SCOPE_DEFINE = '__VROWZER_INTERNAL_SERVICE_WORKER_SCOPE__' export const VROWZER_SERVICE_WORKER_VERSION_DEFINE = '__VROWZER_INTERNAL_SERVICE_WORKER_VERSION__' +export const VROWZER_BUILD_DEFINE = '__VROWZER_INTERNAL_BUILD__' // Resolve picocolors browser version path. // picocolors doesn't export the browser file via package.json exports, @@ -35,6 +36,7 @@ export function envPlugin(options: ResolvedVrowzerOptions): Plugin { const serializedBasePath = JSON.stringify(options.basePath) const serializedServiceWorkerScope = JSON.stringify(options.serviceWorkerScope) const serializedServiceWorkerVersion = JSON.stringify(options.serviceWorkerVersion) + const serializedBuild = JSON.stringify(options.build) return { name: 'vrowzer:env', // Rolldown native inject: inject `process` global for browser/Worker environments. @@ -56,7 +58,8 @@ export function envPlugin(options: ResolvedVrowzerOptions): Plugin { 'import.meta.env.DEBUG': JSON.stringify(process.env.DEBUG || ''), [VROWZER_PREVIEW_BASE_PATH_DEFINE]: serializedBasePath, [VROWZER_SERVICE_WORKER_SCOPE_DEFINE]: serializedServiceWorkerScope, - [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: serializedServiceWorkerVersion + [VROWZER_SERVICE_WORKER_VERSION_DEFINE]: serializedServiceWorkerVersion, + [VROWZER_BUILD_DEFINE]: serializedBuild }, resolve: { alias: resolveAliases({ @@ -109,6 +112,13 @@ export function envPlugin(options: ResolvedVrowzerOptions): Plugin { `Vrowzer reserved define ${VROWZER_SERVICE_WORKER_VERSION_DEFINE} must be ${serializedServiceWorkerVersion}, received ${JSON.stringify(resolvedServiceWorkerVersion)}` ) } + + const resolvedBuild = config.define?.[VROWZER_BUILD_DEFINE] + if (resolvedBuild !== serializedBuild) { + throw new Error( + `Vrowzer reserved define ${VROWZER_BUILD_DEFINE} must be ${serializedBuild}, received ${JSON.stringify(resolvedBuild)}` + ) + } } } } diff --git a/packages/vite-plugin/src/index.test.ts b/packages/vite-plugin/src/index.test.ts index 763a060..f44baa7 100644 --- a/packages/vite-plugin/src/index.test.ts +++ b/packages/vite-plugin/src/index.test.ts @@ -281,6 +281,64 @@ export default defineConfig(({ mode }) => ({ } ) + test('prebundles the Worker config for vrowzer.build() only with build: true', async () => { + await configure(createPlugin({ extract: false })) + expect(prebundleWorkerConfig).toHaveBeenCalledOnce() + + vi.mocked(prebundleWorkerConfig).mockClear() + await configure(createPlugin({ extract: false, build: true })) + + expect(prebundleWorkerConfig).toHaveBeenCalledTimes(2) + expect(vi.mocked(prebundleWorkerConfig).mock.calls[1]![0]).toEqual({ + workerSource: vi.mocked(prebundleWorkerConfig).mock.calls[0]![0].workerSource, + root: '/host', + configDir: '/host', + variant: 'build' + }) + }) + + test.each(['serve', 'build'] as const)( + 'injects the Worker config for builds into the build Worker entry in %s', + async command => { + const buildPath = '/host/node_modules/.vrowzer/config.build.bundled.mjs' + vi.mocked(prebundleWorkerConfig).mockImplementation(async options => ({ + path: options.variant === 'build' ? buildPath : bundledPath, + dependencies: [] + })) + const plugin = createPlugin({ extract: false, build: true }) + await configure(plugin, { command, configFile: false }) + + const config = (plugin.config as () => UserConfig)() + const workerPlugins = (await config.worker!.plugins!()) as Plugin[] + const workerPlugin = workerPlugins.find( + plugin => plugin.name === 'vrowzer:web-worker-config-inject' + )! + + for (const entryPlugin of [plugin, workerPlugin]) { + const transform = entryPlugin.transform as ( + code: string, + id: string + ) => { code: string } | undefined + const result = transform('initBuildWorker()', '/vrowzer/build-worker.ts?worker_file') + expect(result?.code).toContain(`import config from ${JSON.stringify(buildPath)}`) + expect(result?.code).toContain('initBuildWorker(resolved)') + expect(transform('initBuildWorker()', '/vrowzer/build-worker-core.ts')).toBeUndefined() + } + } + ) + + test('replaces the build Worker entry with a stub without build: true', () => { + const plugin = createPlugin({ extract: false }) + + const result = (plugin.transform as (code: string, id: string) => { code: string })( + 'initBuildWorker()', + '/vrowzer/build-worker.ts' + ) + + expect(result.code).toContain("type: 'V_BW_READY'") + expect(result.code).not.toContain('import') + }) + test.each([{}, { extract: true }])('preserves extraction with %j', async options => { await configure(createPlugin({ auto: false, ...options }), { configFile: '/host/config/vite.config.ts', diff --git a/packages/vite-plugin/src/index.ts b/packages/vite-plugin/src/index.ts index f1d7642..99eb3d8 100644 --- a/packages/vite-plugin/src/index.ts +++ b/packages/vite-plugin/src/index.ts @@ -23,7 +23,11 @@ import { resolveOptions } from './options.ts' import { cleanOutputDir, prebundleWorkerConfig } from './prebundle.ts' import { rolldownPlugin, rolldownWorkerAssetPlugin } from './rolldown.ts' import { serverMiddlewarePlugin } from './server.ts' -import { generateWebWorkerEntry } from './virtual.ts' +import { + generateBuildWorkerEntry, + generateDisabledBuildWorkerEntry, + generateWebWorkerEntry +} from './virtual.ts' import { beginWorkerConfigWatch, closeWorkerConfigWatch } from './worker-config-watch.ts' import type { Plugin, ResolvedConfig, UserConfig } from 'vite' @@ -41,14 +45,33 @@ export function Vrowzer(options: VrowzerOptions = {}): Plugin[] { // Path to bundled Worker config (set by configResolved) let bundledConfigPath: string | null = null + // Path to the Worker config bundled for vrowzer.build() (set by configResolved when enabled) + let bundledBuildConfigPath: string | null = null let isBuild = false let configWatch: ReturnType | undefined function workerEntryTransform(code: string, id: string) { + const cleanId = id.split('?')[0] + if ( + cleanId?.endsWith('build-worker.ts') && + !cleanId.endsWith('build-worker-core.ts') && + code.includes('initBuildWorker()') + ) { + // Without the option, keep the builder out of the host output + if (!resolvedOptions.build) { + return { code: generateDisabledBuildWorkerEntry(), map: null } + } + if (bundledBuildConfigPath) { + return { + code: generateBuildWorkerEntry(bundledBuildConfigPath, resolvedOptions.resolve), + map: null + } + } + return + } if (!bundledConfigPath) { return } - const cleanId = id.split('?')[0] if ( cleanId?.endsWith('web-worker.ts') && !cleanId.endsWith('web-worker-core.ts') && @@ -156,19 +179,34 @@ export function Vrowzer(options: VrowzerOptions = {}): Plugin[] { !isBuild && resolvedOptions.workerConfig !== undefined ? beginWorkerConfigWatch(config) : undefined - const bundled = await prebundleWorkerConfig({ - ...(resolvedOptions.workerConfig !== undefined + const source = + resolvedOptions.workerConfig !== undefined ? { workerConfig: resolve(configDir, resolvedOptions.workerConfig) } : { workerSource, ...(resolvedOptions.extract && viteConfigPath ? { sourcePath: viteConfigPath } : {}) - }), + } + const bundled = await prebundleWorkerConfig({ + ...source, root: config.root, configDir, ...(configWatch ? { onDependency: configWatch.onDependency } : {}) }).finally(() => configWatch?.finish()) bundledConfigPath = bundled.path + // vrowzer.build() bundles the same Worker config for production. It has the same inputs, so + // the watch of the dev config covers it. + if (resolvedOptions.build) { + const bundledBuild = await prebundleWorkerConfig({ + ...source, + root: config.root, + configDir, + variant: 'build' + }) + bundledBuildConfigPath = bundledBuild.path + debug('bundled build config path:', bundledBuildConfigPath) + } + debug('bundled config path:', bundledConfigPath) }, configureServer(server) { diff --git a/packages/vite-plugin/src/options.test.ts b/packages/vite-plugin/src/options.test.ts index 0d94827..35f4bde 100644 --- a/packages/vite-plugin/src/options.test.ts +++ b/packages/vite-plugin/src/options.test.ts @@ -10,6 +10,7 @@ describe('resolveOptions', () => { expect(resolved.basePath).toBe('/__preview__/') expect(resolved.serviceWorkerScope).toBe('/') expect(resolved.serviceWorkerVersion).toBe('vrowzer-v1') + expect(resolved.build).toBe(false) }) test('respects auto: false', () => { @@ -87,4 +88,14 @@ describe('resolveOptions', () => { test.each(['', ' app v2 ', 'version/β+二'])('preserves opaque serviceWorkerVersion %j', value => { expect(resolveOptions({ serviceWorkerVersion: value }).serviceWorkerVersion).toBe(value) }) + + test.each([true, false])('respects build: %s', build => { + expect(resolveOptions({ build }).build).toBe(build) + }) + + test.each([1, 'true', null])('rejects a build option that is not a boolean (%j)', build => { + expect(() => resolveOptions({ build: build as unknown as boolean })).toThrow( + 'Vrowzer build must be a boolean' + ) + }) }) diff --git a/packages/vite-plugin/src/options.ts b/packages/vite-plugin/src/options.ts index 19870e1..fd60bd3 100644 --- a/packages/vite-plugin/src/options.ts +++ b/packages/vite-plugin/src/options.ts @@ -158,6 +158,16 @@ export interface VrowzerOptions { * @default undefined */ resolve?: { alias?: Alias[] } + /** + * Enable `vrowzer.build()`, which builds the project for production in a build Worker. + * + * When enabled, the plugin also bundles the Worker config for production builds, and the host + * includes the build Worker and its builder (rolldown and the Vite build) in its output. When + * disabled, `build()` rejects, and the host output has no builder. + * + * @default false + */ + build?: boolean /** * Experimental features. */ @@ -181,6 +191,7 @@ export interface ResolvedVrowzerOptions { serviceWorkerVersion: string serviceWorkerEntry: string resolve: { alias?: Alias[] } | undefined + build: boolean } function resolveDefaultServiceWorkerEntry(): string { @@ -221,6 +232,11 @@ export function resolveOptions(options: VrowzerOptions): ResolvedVrowzerOptions throw new TypeError('Vrowzer workerConfig cannot be combined with explicit extract: true') } } + if (options.build !== undefined && typeof options.build !== 'boolean') { + throw new TypeError( + `Vrowzer build must be a boolean, received ${JSON.stringify(options.build)}` + ) + } const ide = options.experimental?.ide return { auto: options.auto ?? true, @@ -236,6 +252,7 @@ export function resolveOptions(options: VrowzerOptions): ResolvedVrowzerOptions serviceWorkerScope: options.serviceWorkerScope ?? '/', serviceWorkerVersion: options.serviceWorkerVersion ?? DEFAULT_SERVICE_WORKER_VERSION, serviceWorkerEntry: options.serviceWorkerEntry ?? resolveDefaultServiceWorkerEntry(), - resolve: options.resolve + resolve: options.resolve, + build: options.build ?? false } } diff --git a/packages/vite-plugin/src/prebundle.test.ts b/packages/vite-plugin/src/prebundle.test.ts index c8648e2..f01740e 100644 --- a/packages/vite-plugin/src/prebundle.test.ts +++ b/packages/vite-plugin/src/prebundle.test.ts @@ -206,6 +206,39 @@ describe('real worker config prebundle', () => { }) }) + test('bundles the config of vrowzer.build() for production next to the dev config', async () => { + const { root, entry } = fixture({ + 'config/worker.ts': `export default { define: { mode: process.env.NODE_ENV } }` + }) + const dev = await prebundleWorkerConfig({ root, configDir: root, workerConfig: entry }) + const build = await prebundleWorkerConfig({ + root, + configDir: root, + workerConfig: entry, + variant: 'build' + }) + + expect(dev.path).toBe(join(resolveOutputDir(root), 'config.bundled.mjs')) + expect(build.path).toBe(join(resolveOutputDir(root), 'config.build.bundled.mjs')) + expect((await import(pathToFileURL(dev.path).href)).default.define.mode).toBe('development') + expect((await import(pathToFileURL(build.path).href)).default.define.mode).toBe('production') + expect(build.dependencies).toEqual(dev.dependencies) + }) + + test('writes the generated source of vrowzer.build() to its own entry', async () => { + const { root } = fixture({}) + const result = await prebundleWorkerConfig({ + root, + configDir: root, + workerSource: 'export default { define: { mode: process.env.NODE_ENV } }', + variant: 'build' + }) + + expect(existsSync(join(resolveOutputDir(root), '_entry.build.mts'))).toBe(true) + expect(existsSync(join(resolveOutputDir(root), '_entry.mts'))).toBe(false) + expect((await import(pathToFileURL(result.path).href)).default.define.mode).toBe('production') + }) + test('uses the host directory for generated source imports and file reads', async () => { const { root } = fixture({ 'config/helper.ts': 'export const value = 42', diff --git a/packages/vite-plugin/src/prebundle.ts b/packages/vite-plugin/src/prebundle.ts index 4e86939..ab98323 100644 --- a/packages/vite-plugin/src/prebundle.ts +++ b/packages/vite-plugin/src/prebundle.ts @@ -30,6 +30,13 @@ export type PrebundleOptions = { configDir: string /** Reports dependencies even if a later load or validation fails. */ onDependency?: (filename: string) => void + /** + * `build` writes the Worker config of `vrowzer.build()`, bundled with `process.env.NODE_ENV` set + * to `"production"`, next to the config of the dev Web Worker. + * + * @default 'dev' + */ + variant?: 'dev' | 'build' } & ( | { workerSource: string; sourcePath?: string; workerConfig?: never } | { workerConfig: string; workerSource?: never } @@ -41,6 +48,7 @@ export interface PrebundleResult { } const BUNDLED_FILENAME = 'config.bundled.mjs' +const BUILD_BUNDLED_FILENAME = 'config.build.bundled.mjs' export function resolveOutputDir(root: string): string { return resolve(root, 'node_modules', '.vrowzer') @@ -58,6 +66,8 @@ function forbiddenConfigImport(id: string): boolean { export async function prebundleWorkerConfig(options: PrebundleOptions): Promise { const { root, configDir } = options + const isBuildVariant = options.variant === 'build' + const bundledFilename = isBuildVariant ? BUILD_BUNDLED_FILENAME : BUNDLED_FILENAME const outputDir = resolveOutputDir(root) const realOutputDir = existsSync(root) ? resolveOutputDir(realpathSync(root)) : outputDir const strict = options.workerConfig !== undefined @@ -80,7 +90,7 @@ export async function prebundleWorkerConfig(options: PrebundleOptions): Promise< validateWorkerConfigFile(entryPath) } else { mkdirSync(outputDir, { recursive: true }) - entryPath = resolve(outputDir, '_entry.mts') + entryPath = resolve(outputDir, isBuildVariant ? '_entry.build.mts' : '_entry.mts') writeFileSync(entryPath, options.workerSource) } @@ -170,7 +180,10 @@ export async function prebundleWorkerConfig(options: PrebundleOptions): Promise< return id.startsWith('@vrowzer/') || id === 'assert' || id === 'v8' }, transform: { - define: { 'process.env.NODE_ENV': JSON.stringify('development'), global: 'globalThis' }, + define: { + 'process.env.NODE_ENV': JSON.stringify(isBuildVariant ? 'production' : 'development'), + global: 'globalThis' + }, inject: { process: '@vrowzer/node-polyfill/process' } }, resolve: { @@ -186,7 +199,7 @@ export async function prebundleWorkerConfig(options: PrebundleOptions): Promise< try { return await bundle.generate({ format: 'esm', - entryFileNames: BUNDLED_FILENAME, + entryFileNames: bundledFilename, chunkFileNames: 'chunks/[name]-[hash].mjs', assetFileNames: 'assets/[name]-[hash][extname]', minify: false @@ -206,19 +219,19 @@ export async function prebundleWorkerConfig(options: PrebundleOptions): Promise< } // Immutable chunk names keep existing Workers valid until the new entry is published. const files = output.output.map(file => file.fileName) - for (const filename of files.filter(filename => filename !== BUNDLED_FILENAME)) { + for (const filename of files.filter(filename => filename !== bundledFilename)) { const destination = resolve(outputDir, filename) mkdirSync(dirname(destination), { recursive: true }) if (!existsSync(destination)) { renameSync(resolve(temporary, filename), destination) } } - renameSync(resolve(temporary, BUNDLED_FILENAME), resolve(outputDir, BUNDLED_FILENAME)) + renameSync(resolve(temporary, bundledFilename), resolve(outputDir, bundledFilename)) } finally { rmSync(temporary, { recursive: true, force: true }) } - const path = resolve(outputDir, BUNDLED_FILENAME) + const path = resolve(outputDir, bundledFilename) debug('prebundle complete:', path) return { path, dependencies: [...dependencies].sort() } } diff --git a/packages/vite-plugin/src/virtual.test.ts b/packages/vite-plugin/src/virtual.test.ts index 320a922..97a3e4b 100644 --- a/packages/vite-plugin/src/virtual.test.ts +++ b/packages/vite-plugin/src/virtual.test.ts @@ -1,12 +1,21 @@ import { rolldown } from 'rolldown' -import { describe, expect, test } from 'vite-plus/test' -import { generateWebWorkerEntry } from './virtual.ts' +import { describe, expect, test, vi } from 'vite-plus/test' +import { + generateBuildWorkerEntry, + generateDisabledBuildWorkerEntry, + generateWebWorkerEntry +} from './virtual.ts' async function loadEntry( configSource: string, - legacyResolve?: Parameters[1] + legacyResolve?: Parameters[1], + kind: 'web' | 'build' = 'web' ) { const configPath = "/config with spaces/worker's config.mjs" + const [generate, core, init] = + kind === 'web' + ? [generateWebWorkerEntry, 'vrowzer/web-worker-core', 'initWebWorker'] + : [generateBuildWorkerEntry, 'vrowzer/build-worker-core', 'initBuildWorker'] const bundle = await rolldown({ input: 'entry', plugins: [ @@ -15,14 +24,14 @@ async function loadEntry( resolveId: id => id, load(id) { if (id === 'entry') { - return `${generateWebWorkerEntry(configPath, legacyResolve)} - export { captured } from 'vrowzer/web-worker-core' + return `${generate(configPath, legacyResolve)} + export { captured } from '${core}' export { default as original } from ${JSON.stringify(configPath)}` } if (id === configPath) { return configSource } - return 'export let captured; export function initWebWorker(config) { captured = config }' + return `export let captured; export function ${init}(config) { captured = config }` } } ] @@ -69,3 +78,39 @@ describe('generated Worker entry config', () => { expect(captured).toEqual({ plugins: [], resolve: legacy }) }) }) + +describe('generated build Worker entry', () => { + const source = `export default Object.freeze({ + plugins: [{ name: 'worker', transform: code => code + '!' }], + define: { __PREVIEW__: 'true' } + })` + + test('passes a copy of the Worker config to initBuildWorker()', async () => { + const { captured, original } = await loadEntry(source, undefined, 'build') + expect(captured).not.toBe(original) + expect(captured).toEqual(original) + expect(captured.plugins[0].transform('code')).toBe('code!') + }) + + test('replaces the resolve object with the legacy option', async () => { + const legacy = { alias: [{ find: 'legacy', replacement: '/legacy.js' }] } + const { captured } = await loadEntry(source, legacy, 'build') + expect(captured.resolve).toEqual(legacy) + }) + + test('rejects a config that is not an object', async () => { + await expect(loadEntry('export default []', undefined, 'build')).rejects.toThrow( + '[vrowzer] Worker config must export a config object' + ) + }) + + test('reports that vrowzer.build() is not enabled when disabled', () => { + const postMessage = vi.fn<(message: unknown) => void>() + new Function('self', generateDisabledBuildWorkerEntry())({ postMessage }) + + expect(postMessage).toHaveBeenCalledWith({ + type: 'V_BW_READY', + error: expect.stringContaining('vrowzer.build() is not enabled') + }) + }) +}) diff --git a/packages/vite-plugin/src/virtual.ts b/packages/vite-plugin/src/virtual.ts index 8e261c5..8bfaa67 100644 --- a/packages/vite-plugin/src/virtual.ts +++ b/packages/vite-plugin/src/virtual.ts @@ -31,3 +31,43 @@ ${resolveBlock} initWebWorker(resolved) ` } + +/** + * Generates the entry of the build Worker of `vrowzer.build()`, which passes the Worker config + * bundled for production builds. + */ +export function generateBuildWorkerEntry( + configPath: string, + resolve?: { alias?: Alias[] } +): string { + const resolveBlock = resolve + ? `\nconst workerResolve = ${JSON.stringify(resolve)}\nObject.assign(resolved, { resolve: workerResolve })` + : '' + + return ` +import { initBuildWorker } from 'vrowzer/build-worker-core' +import config from ${JSON.stringify(configPath.replaceAll('\\', '/'))} +const workerConfig = config?.default ?? config +if (!workerConfig || typeof workerConfig !== 'object' || Array.isArray(workerConfig)) { + throw new Error('[vrowzer] Worker config must export a config object') +} +const resolved = { ...workerConfig } +${resolveBlock} +initBuildWorker(resolved) +` +} + +/** + * Generates the entry of the build Worker when `vrowzer.build()` is not enabled. + * + * The runtime does not create the build Worker then. The stub keeps the builder out of the host + * output, and reports `V_BW_READY` with an error in case the Worker is created anyway. + */ +export function generateDisabledBuildWorkerEntry(): string { + return ` +self.postMessage({ + type: 'V_BW_READY', + error: '[vrowzer] vrowzer.build() is not enabled. Set build: true in the options of the Vrowzer plugin.' +}) +` +} From f649da08f60b06b65640f469483738bad0aabd4f Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:40:37 +0900 Subject: [PATCH 08/11] fix(vite-dev-server): report the plugin and the module of native plugin errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native plugins of rolldown, e.g. the transform of TypeScript (`builtin:vite-transform`), report an error without `plugin` and `id`. The plugin is only the prefix of the message, and the module is only in the header of the code frame ("╭─[ src/index.ts:1:23 ]"). A syntax error in a TypeScript file is reported this way, so `vrowzer.build()` could not tell which file and which plugin failed. The builder now reads them from the message when rolldown does not set them: the plugin of a `PLUGIN_ERROR`, and the module of the code frame, whose path is relative to the root of the build Worker. --- .../src/node/builderUtils.test.ts | 37 +++++++++++++++++++ .../vite-dev-server/src/node/builderUtils.ts | 19 ++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/vite-dev-server/src/node/builderUtils.test.ts b/packages/vite-dev-server/src/node/builderUtils.test.ts index 51111a0..1220e58 100644 --- a/packages/vite-dev-server/src/node/builderUtils.test.ts +++ b/packages/vite-dev-server/src/node/builderUtils.test.ts @@ -327,6 +327,43 @@ describe('toBuildProjectError', () => { ]) }) + test('reads the plugin and the module of a native plugin from the message', () => { + const message = [ + `${red('[builtin:vite-transform]')} Unexpected token`, + ' ╭─[ src/math.ts:1:23 ]', + ' 1 │ export const broken = ;', + ].join('\n') + const error = Object.assign(new Error('Build failed with 1 error'), { + errors: [{ code: 'PLUGIN_ERROR', message, id: undefined, loc: { line: 1, column: 22 } }], + }) + + expect(toBuildProjectError(error).errors[0]).toEqual({ + code: 'PLUGIN_ERROR', + message: stripAnsi(message), + plugin: 'builtin:vite-transform', + id: '/src/math.ts', + loc: { line: 1, column: 22 }, + }) + }) + + test('keeps the plugin and the module that rolldown reports', () => { + const error = Object.assign(new Error('Build failed with 1 error'), { + errors: [ + { + code: 'PLUGIN_ERROR', + message: '[other] boom\n ╭─[ src/other.ts:1:1 ]', + plugin: 'my-plugin', + id: '/src/a.ts', + }, + ], + }) + + expect(toBuildProjectError(error).errors[0]).toMatchObject({ + plugin: 'my-plugin', + id: '/src/a.ts', + }) + }) + test('converts other failures into one log', () => { expect(toBuildProjectError(new Error(red('failed'))).errors).toEqual([{ message: 'failed' }]) expect(toBuildProjectError('failed').errors).toEqual([{ message: 'failed' }]) diff --git a/packages/vite-dev-server/src/node/builderUtils.ts b/packages/vite-dev-server/src/node/builderUtils.ts index 89bfc70..0fef669 100644 --- a/packages/vite-dev-server/src/node/builderUtils.ts +++ b/packages/vite-dev-server/src/node/builderUtils.ts @@ -305,9 +305,28 @@ function toBuildLog(error: unknown): BuildProjectLog { if (typeof e.frame === 'string') { log.frame = stripAnsi(e.frame) } + // The native plugins of rolldown report the plugin and the module in the message only, e.g. + // "[builtin:vite-transform] Unexpected token" with the frame header "╭─[ src/index.ts:1:23 ]". + // The path is relative to the working directory of the Worker, which is the root. Other errors + // start with their code instead, e.g. "[PARSE_ERROR]". + if (log.plugin === undefined && log.code === 'PLUGIN_ERROR') { + const plugin = nativePluginPattern.exec(log.message)?.[1] + if (plugin) { + log.plugin = plugin + } + } + if (log.id === undefined) { + const file = frameHeaderPattern.exec(log.message)?.[1] + if (file) { + log.id = file.startsWith('/') ? file : `/${file}` + } + } return log } +const nativePluginPattern = /^\[([^\]\s]+)\] / +const frameHeaderPattern = /╭─\[ ?([^\]\n]+?):\d+:\d+ ?\]/ + /** * Convert a failure of a build into a {@link BuildProjectError}. * From 9b20062127e8ed9bf3a8f93743d329a4234844b0 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:40:51 +0900 Subject: [PATCH 09/11] test(vrowzer): build libraries in the browser The new fixture `integration/build/` enables the `build` option of the plugin, and has two test plugins. One adds exports that show the plugin instance and the `process.env.NODE_ENV` of the Worker config. The other never finishes the build of a module that contains `HANG_BUILD`. `build.integration-test.ts` builds a library in the browser, and imports the output in Node: - the outputs (the entry, a lazy chunk, the CSS and the source maps), the exports, JSON, an inlined SVG, `define`, `import.meta.env`, tree shaking and a dynamic import. The plugin hooks run in production mode, with new plugin instances in a new build Worker for each build. - one file (`codeSplitting: false`), and with and without minification - the snapshot: a change made while a build runs is not built, and added, updated and deleted files reach the next build - a syntax error reports the module, the plugin and the location, and the build works again once it is fixed - a second build is rejected while one runs - the timeout, an aborted signal and `dispose()` reject a build and terminate the build Worker. The next build works with a new one. - the build Workers and the Workers that they start are released - unsupported options are rejected: HTML app builds, the `umd` format and `cssMinify` - the previews keep working after successful, failed and timed-out builds The restart fixture does not enable the option, so its test checks that `build()` rejects without creating a Worker. --- .../integration/build.integration-test.ts | 452 ++++++++++++++++++ packages/vrowzer/integration/build/index.html | 12 + packages/vrowzer/integration/build/main.ts | 116 +++++ .../vrowzer/integration/build/vite.config.ts | 65 +++ .../integration/restart.integration-test.ts | 26 + packages/vrowzer/integration/restart/main.ts | 1 + 6 files changed, 672 insertions(+) create mode 100644 packages/vrowzer/integration/build.integration-test.ts create mode 100644 packages/vrowzer/integration/build/index.html create mode 100644 packages/vrowzer/integration/build/main.ts create mode 100644 packages/vrowzer/integration/build/vite.config.ts diff --git a/packages/vrowzer/integration/build.integration-test.ts b/packages/vrowzer/integration/build.integration-test.ts new file mode 100644 index 0000000..508f7b6 --- /dev/null +++ b/packages/vrowzer/integration/build.integration-test.ts @@ -0,0 +1,452 @@ +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 { fileURLToPath, pathToFileURL } from 'node:url' +import { build, preview } from 'vite' +import { afterAll, beforeAll, describe, expect, test } from 'vite-plus/test' + +import type { Browser, BrowserContext, Page } from '@playwright/test' +import type { PreviewServer } from 'vite' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const FIXTURE_DIR = join(__dirname, 'build') + +let browser: Browser +let server: PreviewServer +let origin: string +const outputDirs: string[] = [] + +interface Fixture { + context: BrowserContext + page: Page +} + +type SerializedContent = string | { bytes: number[] } + +interface BuildLog { + message: string + code?: string + plugin?: string + id?: string + loc?: { line: number; column: number; file?: string } + frame?: string +} + +type BuildOutcome = + | { ok: true; files: Record; warnings: BuildLog[] } + | { + ok: false + error: { name: string; message: string; isBuildError: boolean; errors: BuildLog[] } + } + +const LIBRARY_OPTIONS = { + define: { __APP_FLAG__: JSON.stringify('on') }, + build: { + lib: { entry: '/src/index.ts' }, + minify: false, + sourcemap: true + } +} + +/** + * 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. + */ +async function openFixture(options: Record = {}): 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) + expect(ready).toBe(true) + await expectPreviewText(page, 'preview v1') + return { context, page } +} + +async function runBuild(page: Page, options?: unknown): Promise { + return page.evaluate(buildOptions => (window as any).__runBuild__(buildOptions), options) +} + +async function expectBuilt(page: Page, options?: unknown) { + const outcome = await runBuild(page, options) + if (!outcome.ok) { + throw new Error(`build() failed: ${JSON.stringify(outcome.error)}`) + } + return outcome +} + +async function expectBuildError(page: Page, options?: unknown) { + const outcome = await runBuild(page, options) + if (outcome.ok) { + throw new Error(`build() did not fail: ${Object.keys(outcome.files).join(', ')}`) + } + return outcome.error +} + +async function writeFile(page: Page, path: string, content: string): Promise { + await page.evaluate( + async ([filePath, fileContent]) => { + await (window as any).__vrowzer__.updateFile(filePath, fileContent) + }, + [path, content] as const + ) +} + +/** + * Writes the outputs to a new directory, and imports the entry in Node. + */ +async function importLibrary(files: Record, entry: string) { + const dir = mkdtempSync(join(tmpdir(), 'vrowzer-build-output-')) + outputDirs.push(dir) + for (const [file, content] of Object.entries(files)) { + const path = join(dir, file) + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, typeof content === 'string' ? content : Uint8Array.from(content.bytes)) + } + return import(pathToFileURL(join(dir, entry)).href) +} + +async function previewText(page: Page): Promise { + return page.evaluate( + () => + ( + document.querySelector('#preview-container iframe') as HTMLIFrameElement | null + )?.contentDocument?.querySelector('#app')?.textContent ?? '' + ) +} + +async function expectPreviewText(page: Page, text: string): Promise { + await expect.poll(() => previewText(page), { timeout: 30_000 }).toBe(text) +} + +/** + * Checks that the preview still works: HMR updates it after a change. + */ +async function expectPreviewUpdates(page: Page, text: string): Promise { + await page.evaluate(async content => { + const fixture = window as any + await fixture.__vrowzer__.updateFile('/main.js', fixture.__mainSource__(content)) + }, text) + await expectPreviewText(page, text) +} + +async function liveWorkerCount(page: Page): Promise { + return page.evaluate(() => (window as any).__liveWorkerCount__()) +} + +beforeAll(async () => { + await build({ root: FIXTURE_DIR, logLevel: 'silent' }) + server = await preview({ + root: FIXTURE_DIR, + logLevel: 'silent', + preview: { port: 0, strictPort: false } + }) + const address = server.httpServer?.address() + if (typeof address !== 'object' || !address) { + throw new Error('Failed to get build integration server address') + } + origin = `http://localhost:${address.port}` + browser = await chromium.launch({ headless: true }) +}, 180_000) + +afterAll(async () => { + await browser?.close() + await server?.close() + for (const dir of outputDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +describe('vrowzer.build()', () => { + test('builds a library that runs in Node, with a new build Worker each time', async () => { + const { context, page } = await openFixture() + try { + const { files, warnings } = await expectBuilt(page, LIBRARY_OPTIONS) + + expect(Object.keys(files)).toEqual( + expect.arrayContaining(['my-lib.js', 'my-lib.js.map', 'my-lib.css']) + ) + expect(Object.keys(files).some(file => /^lazy-[\w-]+\.js$/.test(file))).toBe(true) + expect(files['my-lib.css']).toContain('.lib-title') + expect(files['my-lib.js']).not.toContain('tree-shaken-away') + expect(Array.isArray(warnings)).toBe(true) + + const lib = await importLibrary(files, 'my-lib.js') + expect(lib.default('vrowzer')).toBe('Hello, vrowzer') + expect(lib.add(1, 2)).toBe(3) + expect(lib.version).toBe('1.2.3') + expect(await lib.loadLazy()).toBe('lazy-chunk-value') + expect(lib.logoUrl).toMatch(/^data:image\/svg\+xml/) + expect([lib.mode, lib.prod, lib.dev]).toEqual(['production', true, false]) + expect(lib.flag).toBe('on') + // The plugins of the Worker config, bundled for production + expect(lib.markerTransforms).toBe(1) + expect(lib.markerNodeEnv).toBe('production') + + // The next build gets new plugin instances in a new build Worker + const again = await importLibrary( + (await expectBuilt(page, LIBRARY_OPTIONS)).files, + 'my-lib.js' + ) + expect(again.markerTransforms).toBe(1) + expect(again.markerInstance).not.toBe(lib.markerInstance) + + // Each build terminates its build Worker: only the Web Worker of the preview stays + expect(await liveWorkerCount(page)).toBe(1) + expect(await page.evaluate(() => (window as any).__createdWorkerCount__())).toBe(3) + await expectPreviewUpdates(page, 'preview after builds') + } finally { + await context.close() + } + }) + + test('builds into one file, and with and without minification', async () => { + const { context, page } = await openFixture() + try { + const single = await expectBuilt(page, { + define: LIBRARY_OPTIONS.define, + build: { + lib: { entry: '/src/index.ts' }, + minify: false, + rolldownOptions: { output: { codeSplitting: false } } + } + }) + expect(Object.keys(single.files).filter(file => file.endsWith('.js'))).toEqual(['my-lib.js']) + expect(single.files['my-lib.js']).toContain('lazy-chunk-value') + expect(Object.keys(single.files).some(file => file.endsWith('.map'))).toBe(false) + + const plain = await expectBuilt(page, { + define: LIBRARY_OPTIONS.define, + build: { lib: { entry: '/src/index.ts' }, minify: false, sourcemap: false } + }) + const minified = await expectBuilt(page, { + define: LIBRARY_OPTIONS.define, + build: { lib: { entry: '/src/index.ts' } } + }) + expect(plain.files['my-lib.js']).toContain('function add(a, b)') + expect((minified.files['my-lib.js'] as string).length).toBeLessThan( + (plain.files['my-lib.js'] as string).length + ) + expect((await importLibrary(minified.files, 'my-lib.js')).add(2, 3)).toBe(5) + } finally { + await context.close() + } + }) + + test('builds the files as they are when build() is called', async () => { + const { context, page } = await openFixture() + try { + const outcome = await page.evaluate(async options => { + const fixture = window as any + const building = fixture.__runBuild__(options) + await fixture.__vrowzer__.updateFile( + '/src/math.ts', + 'export function add(a: number, b: number): number { return a - b }\n' + ) + return building + }, LIBRARY_OPTIONS) + expect(outcome.ok).toBe(true) + expect((await importLibrary(outcome.files, 'my-lib.js')).add(1, 2)).toBe(3) + + // Added, updated and deleted files reach the next build + await page.evaluate(async () => { + const vrowzer = (window as any).__vrowzer__ + await vrowzer.addFile('/src/extra.ts', "export const extra = 'added'\n") + await vrowzer.deleteFile('/src/lazy.ts') + await vrowzer.updateFile( + '/src/index.ts', + "export { add } from './math'\nexport { extra } from './extra'\n" + ) + }) + const next = await expectBuilt(page, LIBRARY_OPTIONS) + const lib = await importLibrary(next.files, 'my-lib.js') + expect(lib.add(1, 2)).toBe(-1) + expect(lib.extra).toBe('added') + expect(Object.keys(next.files).some(file => file.startsWith('lazy-'))).toBe(false) + } finally { + await context.close() + } + }) + + test('reports a syntax error, and builds again once it is fixed', async () => { + const { context, page } = await openFixture() + try { + await writeFile(page, '/src/math.ts', 'export const broken = ;\n') + const error = await expectBuildError(page, LIBRARY_OPTIONS) + + expect(error.isBuildError).toBe(true) + expect(error.name).toBe('VrowzerBuildError') + expect(error.message).toMatch(/^\[Vrowzer\] build\(\) failed: /) + // The transform of TypeScript reports it + expect(error.errors[0]).toMatchObject({ + id: '/src/math.ts', + plugin: expect.stringContaining('transform'), + loc: { line: 1 }, + message: expect.stringContaining('Unexpected token') + }) + // No ANSI colors + expect(error.errors[0]!.message).not.toContain(String.fromCharCode(27)) + + await writeFile(page, '/src/math.ts', 'export const add = (a: number, b: number) => a + b\n') + await expectBuilt(page, LIBRARY_OPTIONS) + await expectPreviewUpdates(page, 'preview after a failed build') + } finally { + await context.close() + } + }) + + test('rejects a build while another one is running', async () => { + const { context, page } = await openFixture() + try { + const [first, second] = await page.evaluate(async options => { + const run = (window as any).__runBuild__ + const firstBuild = run(options) + const secondBuild = run(options) + return Promise.all([firstBuild, secondBuild]) + }, LIBRARY_OPTIONS) + + expect(first.ok).toBe(true) + expect(second).toMatchObject({ + ok: false, + error: { isBuildError: false, message: expect.stringContaining('already running') } + }) + } finally { + await context.close() + } + }) + + test('times out, and builds again with a new build Worker', async () => { + const { context, page } = await openFixture({ buildTimeout: 5000 }) + try { + await writeFile( + page, + '/src/index.ts', + `export const hang = 'HANG_BUILD'\n${await indexSource(page)}` + ) + const error = await expectBuildError(page, LIBRARY_OPTIONS) + expect(error).toMatchObject({ + isBuildError: false, + message: '[Vrowzer] build() timed out after 5000ms' + }) + expect(await liveWorkerCount(page)).toBe(1) + await expectPreviewUpdates(page, 'preview after a timeout') + + await writeFile(page, '/src/index.ts', await indexSource(page)) + await expectBuilt(page, LIBRARY_OPTIONS) + expect(await liveWorkerCount(page)).toBe(1) + } finally { + await context.close() + } + }) + + test('rejects with the reason of the signal, and terminates the build Worker', async () => { + const { context, page } = await openFixture() + try { + await writeFile( + page, + '/src/index.ts', + `export const hang = 'HANG_BUILD'\n${await indexSource(page)}` + ) + const outcome = await page.evaluate(async options => { + const controller = new AbortController() + const building = (window as any).__vrowzer__.build({ + ...options, + signal: controller.signal + }) + await new Promise(resolve => setTimeout(resolve, 500)) + controller.abort(new Error('stopped by the test')) + return building.then( + () => 'resolved', + (error: Error) => error.message + ) + }, LIBRARY_OPTIONS) + + expect(outcome).toBe('stopped by the test') + expect(await liveWorkerCount(page)).toBe(1) + } finally { + await context.close() + } + }) + + test('rejects a running build on dispose()', async () => { + const { context, page } = await openFixture() + try { + await writeFile( + page, + '/src/index.ts', + `export const hang = 'HANG_BUILD'\n${await indexSource(page)}` + ) + const outcome = await page.evaluate(async options => { + const vrowzer = (window as any).__vrowzer__ + const building = vrowzer.build(options).then( + () => 'resolved', + (error: Error) => error.message + ) + await new Promise(resolve => setTimeout(resolve, 500)) + await vrowzer.dispose() + const after = await vrowzer.build(options).then( + () => 'resolved', + (error: Error) => error.message + ) + return { during: await building, after } + }, LIBRARY_OPTIONS) + + expect(outcome).toEqual({ + during: '[Vrowzer] build() was cancelled by dispose()', + after: '[Vrowzer] build() cannot be called after dispose()' + }) + expect(await liveWorkerCount(page)).toBe(0) + } finally { + await context.close() + } + }) + + test('releases the Workers that a build Worker starts', async () => { + const { context, page } = await openFixture() + try { + const baseline = page.workers().length + await expectBuilt(page, LIBRARY_OPTIONS) + await expectBuilt(page, LIBRARY_OPTIONS) + + // page.workers() also lists the threads of rolldown in the build Worker + await expect.poll(() => page.workers().length, { timeout: 10_000 }).toBe(baseline) + } finally { + await context.close() + } + }) + + test.each([ + [{}, 'build.lib'], + [{ 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) => { + const { context, page } = await openFixture() + try { + const error = await expectBuildError(page, options) + + expect(error.isBuildError).toBe(true) + expect(error.errors).toEqual([ + expect.objectContaining({ + code: 'VROWZER_UNSUPPORTED_OPTION', + message: expect.stringContaining(`[vrowzer] ${option}:`) + }) + ]) + } finally { + await context.close() + } + }) +}) + +async function indexSource(page: Page): Promise { + return page.evaluate(() => (window as any).__indexSource__) +} diff --git a/packages/vrowzer/integration/build/index.html b/packages/vrowzer/integration/build/index.html new file mode 100644 index 0000000..cd41012 --- /dev/null +++ b/packages/vrowzer/integration/build/index.html @@ -0,0 +1,12 @@ + + + + + + Vrowzer build integration + + +
+ + + diff --git a/packages/vrowzer/integration/build/main.ts b/packages/vrowzer/integration/build/main.ts new file mode 100644 index 0000000..9b663f0 --- /dev/null +++ b/packages/vrowzer/integration/build/main.ts @@ -0,0 +1,116 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Vrowzer, VrowzerBuildError } from 'vrowzer' + +import type { VrowzerBuildOptions, VrowzerOptions } from 'vrowzer' + +// Count the live Workers that the page creates: Vrowzer's Web Worker and the build Workers. The +// Workers that they start themselves (e.g. the threads of rolldown) are not counted. +// Worker is replaced before any Vrowzer instance creates one. +const liveWorkers = new Set() +let createdWorkers = 0 + +class TrackedWorker extends Worker { + constructor(scriptURL: string | URL, options?: WorkerOptions) { + super(scriptURL, options) + createdWorkers++ + liveWorkers.add(this) + } + + override terminate(): void { + liveWorkers.delete(this) + super.terminate() + } +} + +window.Worker = TrackedWorker + +function mainSource(text: string): string { + return `document.querySelector('#app').innerHTML = '

${text}

' +if (import.meta.hot) { import.meta.hot.accept() } +` +} + +const indexSource = [ + "import { add } from './math'", + "import data from './data.json'", + "import logoUrl from './logo.svg'", + "import './style.css'", + 'export { add, logoUrl }', + 'export const version: string = data.version', + 'export default function greet(name: string): string {', + ' return `Hello, ${name}`', + '}', + 'export async function loadLazy(): Promise {', + " const m = await import('./lazy')", + ' return m.lazyValue', + '}', + 'export const mode: string = import.meta.env.MODE', + 'export const prod: boolean = import.meta.env.PROD', + 'export const dev: boolean = import.meta.env.DEV', + 'declare const __APP_FLAG__: string', + 'export const flag: string = __APP_FLAG__', + '' +].join('\n') + +// A library, and the preview of the project +const projectFiles: Record = { + '/package.json': JSON.stringify({ name: 'my-lib', type: 'module' }), + '/src/index.ts': indexSource, + '/src/math.ts': [ + 'export function add(a: number, b: number): number {', + ' return a + b', + '}', + "export function unused(): string { return 'tree-shaken-away' }", + '' + ].join('\n'), + '/src/lazy.ts': "export const lazyValue: string = 'lazy-chunk-value'\n", + '/src/data.json': '{"version":"1.2.3"}', + '/src/style.css': '.lib-title { color: rgb(255, 0, 0); }\n', + '/src/logo.svg': + '', + '/main.js': mainSource('preview v1') +} + +type SerializedContent = string | { bytes: number[] } + +/** + * Runs `build()` of the current instance, and returns the result or the error as plain data. + */ +async function runBuild(options?: VrowzerBuildOptions) { + const vrowzer = (window as unknown as { __vrowzer__: ReturnType }).__vrowzer__ + try { + const result = await vrowzer.build(options) + const files: Record = {} + for (const [name, content] of Object.entries(result.files)) { + files[name] = + typeof content === 'string' ? content : { bytes: Array.from(new Uint8Array(content)) } + } + return { ok: true as const, files, warnings: result.warnings } + } catch (error) { + return { + ok: false as const, + error: { + name: (error as Error).name, + message: (error as Error).message, + isBuildError: error instanceof VrowzerBuildError, + errors: error instanceof VrowzerBuildError ? [...error.errors] : [] + } + } + } +} + +Object.assign(window, { + __createVrowzer__: (options?: VrowzerOptions) => Vrowzer(options), + __projectFiles__: projectFiles, + __indexSource__: indexSource, + __mainSource__: mainSource, + __runBuild__: runBuild, + __liveWorkerCount__: () => liveWorkers.size, + __createdWorkerCount__: () => createdWorkers +}) + +document.body.dataset.fixtureReady = 'true' diff --git a/packages/vrowzer/integration/build/vite.config.ts b/packages/vrowzer/integration/build/vite.config.ts new file mode 100644 index 0000000..e0d6893 --- /dev/null +++ b/packages/vrowzer/integration/build/vite.config.ts @@ -0,0 +1,65 @@ +/** + * @author kazuya kawaguchi (a.k.a. kazupon) + * @license MIT + */ + +import { Vrowzer as VrowzerPlugin } from '@vrowzer/vite-plugin' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { defineConfig } from 'vite-plus' + +import type { Plugin } from 'vite-plus' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +/** + * Adds exports to `/src/index.ts` that show the plugin instance: how many times it transformed the + * module, an ID of the instance, and `process.env.NODE_ENV` when the config was evaluated. + */ +function markerPlugin(): Plugin { + let transforms = 0 + const instance = Math.random().toString(36).slice(2) + const nodeEnvAtConfig = process.env.NODE_ENV + return { + name: 'vrowzer-test:marker', + transform(code, id) { + if (!id.endsWith('/src/index.ts')) { + return + } + transforms++ + return `${code} +export const markerTransforms = ${transforms} +export const markerInstance = ${JSON.stringify(instance)} +export const markerNodeEnv = ${JSON.stringify(nodeEnvAtConfig ?? null)} +` + } + } +} + +/** + * Never finishes a build whose `/src/index.ts` contains `HANG_BUILD`, for the tests of the timeout, + * the abort and `dispose()`. + */ +function hangPlugin(): Plugin { + return { + name: 'vrowzer-test:hang', + apply: 'build', + transform(code, id) { + if (id.endsWith('/src/index.ts') && code.includes('HANG_BUILD')) { + return new Promise(() => {}) + } + } + } +} + +export default defineConfig({ + plugins: [ + markerPlugin(), + hangPlugin(), + VrowzerPlugin({ + auto: false, + serviceWorkerEntry: resolve(__dirname, '../../dist/service-worker.ts'), + build: true + }) + ] +}) diff --git a/packages/vrowzer/integration/restart.integration-test.ts b/packages/vrowzer/integration/restart.integration-test.ts index d08774c..decbbc6 100644 --- a/packages/vrowzer/integration/restart.integration-test.ts +++ b/packages/vrowzer/integration/restart.integration-test.ts @@ -474,3 +474,29 @@ describe('Vrowzer Service Worker restart', () => { } }, 90_000) }) + +// The fixture's plugin does not enable vrowzer.build() +describe('vrowzer.build() without the build option', () => { + test('rejects without creating a build Worker', async () => { + const { context, page } = await openFixture() + try { + const outcome = await page.evaluate(async () => { + const fixture = window as any + const before = fixture.__workerCount__() + const message = await fixture.__vrowzer__.build().then( + () => 'resolved', + (error: Error) => error.message + ) + return { message, created: fixture.__workerCount__() - before } + }) + + expect(outcome).toEqual({ + message: + '[Vrowzer] build() is not enabled. Set `build: true` in the options of Vrowzer() from @vrowzer/vite-plugin.', + created: 0 + }) + } finally { + await context.close() + } + }) +}) diff --git a/packages/vrowzer/integration/restart/main.ts b/packages/vrowzer/integration/restart/main.ts index 05de298..eec15d0 100644 --- a/packages/vrowzer/integration/restart/main.ts +++ b/packages/vrowzer/integration/restart/main.ts @@ -81,6 +81,7 @@ Object.assign(window, { __previewFiles__: previewFiles, __events__: events, __serviceWorkerMessages__: serviceWorkerMessages, + __workerCount__: () => webWorkers.size, __terminateWebWorkers__: () => { for (const worker of webWorkers) { worker.terminate() From 72a7baac3862a33d5196afb6a9a84b97a5954f42 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Fri, 2 Oct 2026 23:40:58 +0900 Subject: [PATCH 10/11] docs(vrowzer): document vrowzer.build() - README: `build()` and its options, results and errors, `buildTimeout`, what the build does not support yet, what `dispose()` does to a build, and the build Worker in the architecture. - The API references are generated again for the new types, and the constructor of `VrowzerBuildError` documents its parameters. --- packages/vrowzer/README.md | 47 ++++++++++++++++++- .../docs/default/classes/VrowzerBuildError.md | 39 +++++++++++++++ .../docs/default/interfaces/Vrowzer.md | 40 +++++++++++++++- .../interfaces/VrowzerBuildLibraryOptions.md | 18 +++++++ .../default/interfaces/VrowzerBuildLog.md | 20 ++++++++ .../default/interfaces/VrowzerBuildOptions.md | 22 +++++++++ .../default/interfaces/VrowzerBuildResult.md | 16 +++++++ .../docs/default/interfaces/VrowzerOptions.md | 1 + packages/vrowzer/docs/index.md | 10 ++++ packages/vrowzer/src/index.ts | 4 ++ 10 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 packages/vrowzer/docs/default/classes/VrowzerBuildError.md create mode 100644 packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md create mode 100644 packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md create mode 100644 packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md create mode 100644 packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index e0df3c8..a215391 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -156,6 +156,12 @@ const vrowzer = Vrowzer({ fileSyncTimeout: 30000 }) The same timeout limits how long Vrowzer takes to connect a restarted Service Worker to the Web Worker again. See [Service Worker restarts](#service-worker-restarts). +`buildTimeout` controls how long [`build()`](#buildoptions-promisevrowzerbuildresult) waits for a build, from the creation of the build Worker until the result is received. It defaults to 120000 milliseconds and includes loading the builder: + +```ts +const vrowzer = Vrowzer({ buildTimeout: 300000 }) +``` + **Options:** | Option | Type | Default | Description | @@ -166,6 +172,7 @@ The same timeout limits how long Vrowzer takes to connect a restarted Service Wo | `serviceWorkerReadyTimeout` | `number` | `60000` | Milliseconds to wait for the Service Worker page controller | | `webWorkerSetupTimeout` | `number` | `90000` | Milliseconds from Web Worker creation through setup completion | | `fileSyncTimeout` | `number` | `10000` | Milliseconds to wait for the Web Worker to apply a file change, or to reconnect a restarted Service Worker | +| `buildTimeout` | `number` | `120000` | Milliseconds from the creation of the build Worker until the result of `build()` | ### Instance Properties @@ -286,6 +293,42 @@ vrowzer.reloadPreview() > [!NOTE] > Up to vrowzer 0.4.x, these methods returned `void` without waiting for the Workers, and calls made before `ready()` completed were dropped or reached only the Web Worker. +#### `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: + +```ts +// vite.config.ts +VrowzerPlugin({ build: true }) + +// application.ts +const { files, warnings } = 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': '...' } +``` + +- 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). +- 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`. +- `files` is keyed by the path from the output root. JavaScript, CSS, source maps and text assets are strings, and binary assets are `ArrayBuffer`s. The files of `/public` are added unless `build.copyPublicDir` is `false`. +- One build at a time: a call while another build is running rejects. +- 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: + +- HTML app builds, i.e. builds without `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. + #### `dispose(): Promise` Disposes the instance when the host application stops using it. The promise resolves after these resources are released: @@ -314,7 +357,7 @@ With `await using`, the instance is disposed at the end of the scope. - If `ready()` is still in progress, it is aborted and resolves to `false`. - If Vrowzer is connecting a restarted Service Worker to the Web Worker again, it stops. -- File operations still waiting for the Web Worker reject. After disposal, `ready()`, `addFile()`, `updateFile()` and `deleteFile()` reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a new instance to start again. +- File operations still waiting for the Web Worker reject, and a running `build()` rejects with its build Worker terminated. After disposal, `ready()`, `addFile()`, `updateFile()`, `deleteFile()` and `build()` reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a new instance to start again. - Calling `dispose()` again returns the same promise. If some resources cannot be released, the remaining ones are still released, and the promise rejects with an `AggregateError`. > [!NOTE] @@ -391,6 +434,8 @@ The Web Worker runs the Vite dev server: it has the project files, the module gr Give Vite plugins to the Web Worker, as `@vrowzer/vite-plugin` does with the plugins of `vite.config.ts`. The Service Worker runs no plugins. +[`build()`](#buildoptions-promisevrowzerbuildresult) runs in a separate build Worker, which `build()` creates for each build with a copy of the project files, and terminates when the build ends. It runs the Vite build with the same plugins, so builds do not affect the previews. + > [!NOTE] > Up to vrowzer 0.4.x, the Service Worker ran the Vite middlewares on its own copy of the project files, and `initServiceWorker()` of `vrowzer/service-worker-core` took `plugins` for its dev server. The Web Worker's dev server had no `middlewares`, so the middlewares that plugins add in `configureServer` ran only in the Service Worker, for the plugins given to it. diff --git a/packages/vrowzer/docs/default/classes/VrowzerBuildError.md b/packages/vrowzer/docs/default/classes/VrowzerBuildError.md new file mode 100644 index 0000000..23cb8aa --- /dev/null +++ b/packages/vrowzer/docs/default/classes/VrowzerBuildError.md @@ -0,0 +1,39 @@ +# Class: VrowzerBuildError + +The error of a [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build) that failed: an error in the project, or an option that the +browser build does not support. + +## Extends + +- `Error` + +## Signature + +```ts +export class VrowzerBuildError extends Error +``` + +## Constructors + +### Constructor + +```ts +new VrowzerBuildError(errors: readonly VrowzerBuildLog[], options?: ErrorOptions): VrowzerBuildError; +``` + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| `errors` | `readonly` [`VrowzerBuildLog`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md)\[\] | The errors of the build. | +| `options` | `ErrorOptions` | The options of `Error`, e.g. `cause`. _(optional)_ | + +#### Returns + +[`VrowzerBuildError`](/packages/vrowzer/docs/default/classes/VrowzerBuildError.md) + +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `errors` _(readonly)_ | `readonly` [`VrowzerBuildLog`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md)\[\] | The errors of the build. The message summarizes the first one. | diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index 1929e55..cef79a7 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -51,6 +51,41 @@ be partly applied then; write the file again to resynchronize. *** +### build() + +```ts +build(options?: VrowzerBuildOptions): Promise; +``` + +Builds the project for production in a build Worker, e.g. as 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 +the file methods called before. Changes made later are not included, even before the build +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. + +It needs the `build` option of `@vrowzer/vite-plugin`. + +#### Parameters + +| Name | Type | Description | +| --- | --- | --- | +| `options` | [`VrowzerBuildOptions`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md) | The options of the build, merged over the Worker config. _(optional)_ | + +#### Returns + +`Promise`\<[`VrowzerBuildResult`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md)\> — The outputs and the warnings. + +#### Throws + +- Rejects before [Vrowzer.ready](#method-ready) resolves to `true`, after [Vrowzer.dispose](#method-dispose), when the `build` option of the plugin is not enabled, and while another build is running. Rejects with a [VrowzerBuildError](/packages/vrowzer/docs/default/classes/VrowzerBuildError.md) when the build fails, e.g. with an error in the project or an unsupported option. Rejects with an `Error` when the build does not finish within [VrowzerOptions.buildTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-buildtimeout), when the build Worker fails, or when [Vrowzer.dispose](#method-dispose) is called first, and with the reason of `signal` when it is aborted. + +*** + ### deleteFile() ```ts @@ -88,8 +123,9 @@ terminated with the project files, Service Worker controller events are no longe all event handlers are removed right away. The Service Worker registration is kept for other clients, and answers the requests under [Vrowzer.previewBasePath](#property-previewbasepath) with 404 from then on. -File operations still waiting for the Web Worker reject. After disposal, `ready()` and the file -methods reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a +File operations still waiting for the Web Worker reject, and a running [Vrowzer.build](#method-build) +rejects with its build Worker terminated. After disposal, `ready()`, the file methods and +`build()` reject, `mount()` throws, and `unmount()` and `reloadPreview()` do nothing. Create a new instance to start again. #### Returns diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md b/packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md new file mode 100644 index 0000000..9684901 --- /dev/null +++ b/packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md @@ -0,0 +1,18 @@ +# Interface: VrowzerBuildLibraryOptions + +The library options of [VrowzerBuildOptions.build](/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md#property-build), a subset of Vite's `build.lib`. + +## Signature + +```ts +export interface VrowzerBuildLibraryOptions +``` + +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `cssFileName` _(optional)_ | `string` | The name of the CSS file. Defaults to `fileName`. | +| `entry` | `string` | The entry of the library, e.g. `/src/index.ts`. Its exports are the API of the library. | +| `fileName` _(optional)_ | `string` | The name of the output file. Without it, the `name` of `/package.json` is used, and the build fails when there is none. | +| `formats` _(optional)_ | `["es"]` | The output formats. Only `es` is supported for now. **Default:** `['es']` | diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md b/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md new file mode 100644 index 0000000..891e3da --- /dev/null +++ b/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md @@ -0,0 +1,20 @@ +# Interface: VrowzerBuildLog + +A log of [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build): an error or a warning. + +## Signature + +```ts +export interface VrowzerBuildLog +``` + +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `code` _(optional)_ | `string` | The code of the log, e.g. `PARSE_ERROR` from rolldown, or `VROWZER_UNSUPPORTED_OPTION` for an option that the browser build does not support. | +| `frame` _(optional)_ | `string` | The code around the location. | +| `id` _(optional)_ | `string` | The module that it is about. | +| `loc` _(optional)_ | `{ line: number; column: number; file?: string }` | The location in the module. The column starts at 0. | +| `message` | `string` | The message, without colors. | +| `plugin` _(optional)_ | `string` | The plugin that reported it. | diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md b/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md new file mode 100644 index 0000000..2cc3102 --- /dev/null +++ b/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md @@ -0,0 +1,22 @@ +# Interface: VrowzerBuildOptions + +Options for [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build): a subset of the Vite config. + +They are merged over the Worker config, and sent to the build Worker, so they must be values that +`postMessage()` can copy, e.g. no functions. + +## Signature + +```ts +export interface VrowzerBuildOptions +``` + +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `base` _(optional)_ | `string` | The public base path of the outputs (Vite's `base`). **Default:** `'/'` | +| `build` _(optional)_ | { `lib`?: [`VrowzerBuildLibraryOptions`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md) \| `false`; `minify`?: `boolean`; `sourcemap`?: `boolean` \| "inline" \| "hidden"; `assetsDir`?: `string`; `assetsInlineLimit`?: `number`; `cssCodeSplit`?: `boolean`; `target`?: `string` \| `string`\[\]; `modulePreload`?: `boolean` \| { `polyfill`?: `boolean` }; `rolldownOptions`?: { `input`?: `string`; `external`?: (`string` \| `RegExp`)\[\]; `output`?: { `codeSplitting`?: `boolean` } } } | Build options: a subset of Vite's `build`. | +| `define` _(optional)_ | `Record` | Global constants to replace (Vite's `define`), merged over the `define` of the Worker config. | +| `mode` _(optional)_ | `string` | The mode (Vite's `mode`), which `import.meta.env.MODE` returns. **Default:** `'production'` | +| `signal` _(optional)_ | `AbortSignal` | Cancels the build. The build Worker is terminated, and the promise rejects with the reason. | diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md b/packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md new file mode 100644 index 0000000..3f1b346 --- /dev/null +++ b/packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md @@ -0,0 +1,16 @@ +# Interface: VrowzerBuildResult + +The result of [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build). + +## Signature + +```ts +export interface VrowzerBuildResult +``` + +## Properties + +| Name | Type | Description | +| --- | --- | --- | +| `files` | `Record` | The outputs, keyed by the path from the output root, e.g. `my-lib.js`. JavaScript, CSS, source maps and text assets are strings. Binary assets are ArrayBuffers. | +| `warnings` | [`VrowzerBuildLog`](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md)\[\] | The warnings of the build. | diff --git a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md index 8a50a4c..05f3934 100644 --- a/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md +++ b/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md @@ -13,6 +13,7 @@ export interface VrowzerOptions | Name | Type | Description | | --- | --- | --- | | `basePath` _(optional)_ | `string` | The pathname that the preview URLs start with. The previews of each instance load from its own path under it, e.g. `/__preview__/0123456789ab/`, which [Vrowzer.previewBasePath](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#property-previewbasepath) returns. The Service Worker answers the requests within it. When `@vrowzer/vite-plugin` is used, its `basePath` is injected and this option can be omitted. If both are provided, their canonical values must match. Without the plugin, this option defaults to `'/__preview__/'`. | +| `buildTimeout` _(optional)_ | `number` | Timeout in milliseconds for [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build), measured from the creation of the build Worker until the result is received. It includes loading the builder in the build Worker. Set to `0` for an immediate timeout. **Default:** `120000` | | `fileSyncTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Web Worker to apply a change made with [Vrowzer.addFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-addfile), [Vrowzer.updateFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-updatefile) or [Vrowzer.deleteFile](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-deletefile). The Web Worker applies a change after the plugins' `watchChange` hooks finish. This timeout is shorter than the 30 seconds that the Service Worker waits for the Web Worker to answer a request, so with slow plugins or heavy transforms an operation can reject although the change is applied later. Increase it in such environments. Writing the file again resynchronizes it. It also limits how long Vrowzer takes to connect a restarted Service Worker to the Web Worker again. See [VrowzerEventMap.serviceWorkerRecoveryError](/packages/vrowzer/docs/default/type-aliases/VrowzerEventMap.md#property-serviceworkerrecoveryerror). **Default:** `10000` | | `serviceWorkerReadyTimeout` _(optional)_ | `number` | Timeout in milliseconds for the Service Worker to become the page controller. This timeout does not apply to Service Worker listen readiness or Web Worker setup. **Default:** `60000` | | `serviceWorkerScope` _(optional)_ | `string` | Service Worker registration scope, independent of `basePath`. When `@vrowzer/vite-plugin` is used, its `serviceWorkerScope` is injected and this option can be omitted. If both are provided, their values must match. Without the plugin, this option defaults to `'/'`. | diff --git a/packages/vrowzer/docs/index.md b/packages/vrowzer/docs/index.md index 8faec19..0af339d 100644 --- a/packages/vrowzer/docs/index.md +++ b/packages/vrowzer/docs/index.md @@ -40,6 +40,12 @@ await vrowzer.updateFile( | ------ | ------ | | [Vrowzer](/packages/vrowzer/docs/default/functions/Vrowzer.md) | Factory function to create a [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md) instance. | +## Classes + +| Class | Description | +| ------ | ------ | +| [VrowzerBuildError](/packages/vrowzer/docs/default/classes/VrowzerBuildError.md) | The error of a [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build) that failed: an error in the project, or an option that the browser build does not support. | + ## Interfaces | Interface | Description | @@ -49,6 +55,10 @@ await vrowzer.updateFile( | [PreviewMountOptions](/packages/vrowzer/docs/default/interfaces/PreviewMountOptions.md) | Options for mounting a preview session. | | [PreviewSession](/packages/vrowzer/docs/default/interfaces/PreviewSession.md) | A mounted preview iframe managed by a [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md) instance. | | [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md) | The main interface for the Vrowzer preview environment. | +| [VrowzerBuildLibraryOptions](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLibraryOptions.md) | The library options of [VrowzerBuildOptions.build](/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md#property-build), a subset of Vite's `build.lib`. | +| [VrowzerBuildLog](/packages/vrowzer/docs/default/interfaces/VrowzerBuildLog.md) | A log of [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build): an error or a warning. | +| [VrowzerBuildOptions](/packages/vrowzer/docs/default/interfaces/VrowzerBuildOptions.md) | Options for [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build): a subset of the Vite config. | +| [VrowzerBuildResult](/packages/vrowzer/docs/default/interfaces/VrowzerBuildResult.md) | The result of [Vrowzer.build](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-build). | | [VrowzerConfig](/packages/vrowzer/docs/default/interfaces/VrowzerConfig.md) | VrowzerConfig defines the configuration options for [`Vrowzer.ready`](/packages/vrowzer/docs/default/interfaces/Vrowzer.md#method-ready) | | [VrowzerOptions](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md) | VrowzerOptions defines the configuration options for [Vrowzer](/packages/vrowzer/docs/default/interfaces/Vrowzer.md). | diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index c7f5a17..ec764bc 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -377,6 +377,10 @@ export class VrowzerBuildError extends Error { */ readonly errors: readonly VrowzerBuildLog[] + /** + * @param errors - The errors of the build. + * @param options - The options of `Error`, e.g. `cause`. + */ constructor(errors: readonly VrowzerBuildLog[], options?: ErrorOptions) { super(summarizeBuildErrors(errors), options) this.name = 'VrowzerBuildError' From 076f565190be9e3610b43801e8690d216170f493 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Sat, 3 Oct 2026 00:06:25 +0900 Subject: [PATCH 11/11] fix(vrowzer): pace the build Workers so that the closed ones can stop Short builds one after another stalled. In Chromium, the twelfth build in a row never loaded the builder, and waited for `buildTimeout`. A terminated build Worker stays for about 2 seconds with one thread of rolldown, which Chromium stops by force only after 2 seconds. A build of a small library took about 0.14 seconds, so the closed build Workers piled up, and with about 11 of them the WebAssembly of a new build Worker did not start. Disposing the WASI binding of rolldown before the Worker is terminated did not help. The runtime now remembers when it closed each build Worker. When 4 of them closed within the last 2.5 seconds, a build waits until the oldest of them has had that long to stop, before it creates its build Worker. Longer builds do not wait. The wait does not count toward `buildTimeout`, and the signal and `dispose()` cancel it. The timeout tests no longer keep a pending assertion while they advance the fake timers. --- packages/vrowzer/README.md | 1 + .../docs/default/interfaces/Vrowzer.md | 4 + .../integration/build.integration-test.ts | 21 ++++ packages/vrowzer/src/index-build.test.ts | 86 +++++++++++++- packages/vrowzer/src/index.ts | 109 +++++++++++++++--- 5 files changed, 199 insertions(+), 22 deletions(-) diff --git a/packages/vrowzer/README.md b/packages/vrowzer/README.md index a215391..40e9167 100644 --- a/packages/vrowzer/README.md +++ b/packages/vrowzer/README.md @@ -318,6 +318,7 @@ const { files, warnings } = await vrowzer.build({ - 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`. - `files` is keyed by the path from the output root. JavaScript, CSS, source maps and text assets are strings, and binary assets are `ArrayBuffer`s. The files of `/public` are added unless `build.copyPublicDir` is `false`. - One build at a time: a call while another build is running rejects. +- 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: diff --git a/packages/vrowzer/docs/default/interfaces/Vrowzer.md b/packages/vrowzer/docs/default/interfaces/Vrowzer.md index cef79a7..dd3c68c 100644 --- a/packages/vrowzer/docs/default/interfaces/Vrowzer.md +++ b/packages/vrowzer/docs/default/interfaces/Vrowzer.md @@ -68,6 +68,10 @@ Each build runs in a new build Worker, with the Worker config bundled for produc 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. +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 +creates its build Worker. The wait does not count toward [VrowzerOptions.buildTimeout](/packages/vrowzer/docs/default/interfaces/VrowzerOptions.md#property-buildtimeout). + It needs the `build` option of `@vrowzer/vite-plugin`. #### Parameters diff --git a/packages/vrowzer/integration/build.integration-test.ts b/packages/vrowzer/integration/build.integration-test.ts index 508f7b6..f4eee56 100644 --- a/packages/vrowzer/integration/build.integration-test.ts +++ b/packages/vrowzer/integration/build.integration-test.ts @@ -411,6 +411,27 @@ describe('vrowzer.build()', () => { } }) + test('runs short builds one after another, pacing the build Workers', async () => { + // Each closed build Worker takes about 2 seconds to stop. Without the pacing, the WebAssembly of + // a new build Worker stopped starting at about the twelfth short build in a row. + const { context, page } = await openFixture({ buildTimeout: 30_000 }) + try { + const outcomes = await page.evaluate(async options => { + const run = (window as any).__runBuild__ + const results: boolean[] = [] + for (let index = 0; index < 12; index++) { + results.push((await run(options)).ok) + } + return results + }, LIBRARY_OPTIONS) + + expect(outcomes).toEqual(Array.from({ length: 12 }, () => true)) + await expectPreviewUpdates(page, 'preview after many builds') + } finally { + await context.close() + } + }, 120_000) + test('releases the Workers that a build Worker starts', async () => { const { context, page } = await openFixture() try { diff --git a/packages/vrowzer/src/index-build.test.ts b/packages/vrowzer/src/index-build.test.ts index 9393940..086cb42 100644 --- a/packages/vrowzer/src/index-build.test.ts +++ b/packages/vrowzer/src/index-build.test.ts @@ -437,14 +437,16 @@ describe('Vrowzer.build() timeout, abort and dispose', () => { vi.useFakeTimers() buildBehavior.result = null const vrowzer = await readyInstance() - const result = vrowzer.build() - const assertion = expect(result).rejects.toThrow('[Vrowzer] build() timed out after 120000ms') + const error = vrowzer.build().then( + () => undefined, + (error: unknown) => error + ) await vi.advanceTimersByTimeAsync(119_999) expect(buildWorkers[0]!.terminate).not.toHaveBeenCalled() await vi.advanceTimersByTimeAsync(1) - await assertion + expect(await error).toMatchObject({ message: '[Vrowzer] build() timed out after 120000ms' }) expect(buildWorkers[0]!.terminate).toHaveBeenCalledOnce() }) @@ -452,11 +454,13 @@ describe('Vrowzer.build() timeout, abort and dispose', () => { vi.useFakeTimers() buildBehavior.result = null const vrowzer = await readyInstance({ buildTimeout: 5000 }) - const result = vrowzer.build() - const assertion = expect(result).rejects.toThrow('[Vrowzer] build() timed out after 5000ms') + const error = vrowzer.build().then( + () => undefined, + (error: unknown) => error + ) await vi.advanceTimersByTimeAsync(5000) - await assertion + expect(await error).toMatchObject({ message: '[Vrowzer] build() timed out after 5000ms' }) buildBehavior.result = okResult await expect(vrowzer.build()).resolves.toEqual({ files: okResult.files, warnings: [] }) @@ -491,3 +495,73 @@ describe('Vrowzer.build() timeout, abort and dispose', () => { await expect(vrowzer.build()).rejects.toThrow('cannot be called after dispose()') }) }) + +describe('Vrowzer.build() pacing', () => { + async function buildFourTimes(vrowzer: Awaited>) { + for (let index = 0; index < 4; index++) { + await expect(vrowzer.build()).resolves.toEqual({ files: okResult.files, warnings: [] }) + } + expect(buildWorkers).toHaveLength(4) + } + + test('waits before creating a build Worker when 4 closed within 2.5 seconds', async () => { + vi.useFakeTimers() + const vrowzer = await readyInstance({ buildTimeout: 1000 }) + await buildFourTimes(vrowzer) + + const fifth = vrowzer.build() + await vi.advanceTimersByTimeAsync(2499) + expect(buildWorkers).toHaveLength(4) + await vi.advanceTimersByTimeAsync(1) + + // The wait does not count toward buildTimeout + await expect(fifth).resolves.toEqual({ files: okResult.files, warnings: [] }) + expect(buildWorkers).toHaveLength(5) + }) + + test('does not wait when the closed build Workers had time to stop', async () => { + vi.useFakeTimers() + const vrowzer = await readyInstance() + await buildFourTimes(vrowzer) + await vi.advanceTimersByTimeAsync(2500) + + const fifth = vrowzer.build() + await settle() + + expect(buildWorkers).toHaveLength(5) + await expect(fifth).resolves.toEqual({ files: okResult.files, warnings: [] }) + }) + + test('rejects a build while another one waits', async () => { + vi.useFakeTimers() + const vrowzer = await readyInstance() + await buildFourTimes(vrowzer) + const waiting = vrowzer.build() + + await expect(vrowzer.build()).rejects.toThrow('[Vrowzer] build() is already running.') + await vi.advanceTimersByTimeAsync(2500) + await expect(waiting).resolves.toEqual({ files: okResult.files, warnings: [] }) + }) + + test('cancels a waiting build with the signal or dispose(), without a build Worker', async () => { + vi.useFakeTimers() + const vrowzer = await readyInstance() + await buildFourTimes(vrowzer) + const controller = new AbortController() + const reason = new Error('cancelled while waiting') + + const aborted = vrowzer.build({ signal: controller.signal }) + await settle() + controller.abort(reason) + await expect(aborted).rejects.toBe(reason) + + const disposed = vrowzer.build() + await settle() + const disposal = vrowzer.dispose() + await expect(disposed).rejects.toThrow('[Vrowzer] build() was cancelled by dispose()') + await disposal + + await vi.advanceTimersByTimeAsync(2500) + expect(buildWorkers).toHaveLength(4) + }) +}) diff --git a/packages/vrowzer/src/index.ts b/packages/vrowzer/src/index.ts index ec764bc..355dbdd 100644 --- a/packages/vrowzer/src/index.ts +++ b/packages/vrowzer/src/index.ts @@ -76,6 +76,16 @@ const DEFAULT_SERVICE_WORKER_READY_TIMEOUT = 60_000 const DEFAULT_WEB_WORKER_SETUP_TIMEOUT = 90_000 const DEFAULT_FILE_SYNC_TIMEOUT = 10_000 const DEFAULT_BUILD_TIMEOUT = 120_000 +/** + * How long a closed build Worker may take to stop. Chromium stops a Worker thread that does not + * answer `terminate()` after 2 seconds, and a build Worker keeps a thread of rolldown that long. + */ +const BUILD_WORKER_STOP_TIME = 2500 +/** + * How many closed build Workers may be stopping at once. When too many of them stay, the + * WebAssembly of a new build Worker cannot start, and the build never ends. + */ +const MAX_STOPPING_BUILD_WORKERS = 4 /** * `true` when `@vrowzer/vite-plugin` enables {@link Vrowzer.build} with its `build` option. @@ -653,6 +663,10 @@ export interface Vrowzer extends Emittable { * 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. * + * 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 + * creates its build Worker. The wait does not count toward {@link VrowzerOptions.buildTimeout}. + * * It needs the `build` option of `@vrowzer/vite-plugin`. * * @param options - The options of the build, merged over the Worker config. @@ -770,6 +784,27 @@ function withTimeout( }) } +/** + * Resolves after `ms` milliseconds, or rejects with the reason of `signal` when it is aborted first. + */ +function wait(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason) + return + } + const onAbort = () => { + clearTimeout(timer) + reject(signal.reason) + } + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + signal.addEventListener('abort', onAbort, { once: true }) + }) +} + function serializeInlineScriptValue(value: unknown): string { const serialized = JSON.stringify(value) if (serialized === undefined) { @@ -925,14 +960,67 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { let runningBuild: { cancel: (error: Error) => void } | null = null // Identifies the builds, so that the results of other builds are ignored let buildSequence = 0 + // When the recent build Workers were closed, oldest first + const closedBuildWorkers: number[] = [] /** - * Runs a build in a new build Worker, and terminates the Worker when the build ends. + * How long to wait before creating a build Worker, so that at most + * {@link MAX_STOPPING_BUILD_WORKERS} closed ones are stopping when it starts. + */ + function buildWorkerDelay(): number { + const now = Date.now() + while ( + closedBuildWorkers.length > 0 && + now - closedBuildWorkers[0]! >= BUILD_WORKER_STOP_TIME + ) { + closedBuildWorkers.shift() + } + if (closedBuildWorkers.length < MAX_STOPPING_BUILD_WORKERS) { + return 0 + } + const closedAt = closedBuildWorkers[closedBuildWorkers.length - MAX_STOPPING_BUILD_WORKERS]! + return closedAt + BUILD_WORKER_STOP_TIME - now + } + + /** + * Runs a build: waits until a build Worker can start when the recent builds were short, then + * runs the build in a new build Worker. */ - function runBuild( + async function runBuild( files: Record, options: Omit, signal: AbortSignal | undefined + ): Promise { + // dispose() aborts it + const cancellation = new AbortController() + const build = { cancel: (error: Error) => cancellation.abort(error) } + runningBuild = build + try { + const delay = buildWorkerDelay() + if (delay > 0) { + await wait( + delay, + signal ? AbortSignal.any([cancellation.signal, signal]) : cancellation.signal + ) + } + cancellation.signal.throwIfAborted() + signal?.throwIfAborted() + return await runBuildWorker(files, options, signal, cancellation.signal) + } finally { + if (runningBuild === build) { + runningBuild = null + } + } + } + + /** + * Runs a build in a new build Worker, and terminates the Worker when the build ends. + */ + function runBuildWorker( + files: Record, + options: Omit, + signal: AbortSignal | undefined, + cancellation: AbortSignal ): Promise { const id = ++buildSequence const worker = new Worker(new URL('./build-worker.ts', import.meta.url), { type: 'module' }) @@ -941,16 +1029,11 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { worker.onerror = null worker.onmessageerror = null worker.terminate() + closedBuildWorkers.push(Date.now()) } const result = new Promise((resolve, reject) => { - const build = { - cancel: (error: Error) => { - reject(error) - close() - } - } - runningBuild = build + cancellation.addEventListener('abort', () => reject(cancellation.reason), { once: true }) worker.onerror = event => { reject(new Error(`[Vrowzer] The build Worker failed: ${event.message || 'unknown error'}`)) } @@ -998,13 +1081,7 @@ export function Vrowzer(options: VrowzerOptions = {}): Readonly { } }) - const current = runningBuild - return withTimeout(result, resolved.buildTimeout, '[Vrowzer] build()', signal).finally(() => { - if (runningBuild === current) { - runningBuild = null - } - close() - }) + return withTimeout(result, resolved.buildTimeout, '[Vrowzer] build()', signal).finally(close) } function cleanupWebWorker(): void {