Skip to content

feat(vrowzer): build HTML apps in the browser with vrowzer.build() - #77

Merged
kazupon merged 6 commits into
feat/build-libraryfrom
feat/build-html-app
Oct 2, 2026
Merged

kazupon merged 6 commits into
feat/build-libraryfrom
feat/build-html-app

Conversation

@kazupon

@kazupon kazupon commented Oct 2, 2026

Copy link
Copy Markdown
Owner

Part of #36. Stacked on #75 (stage 1, library builds).

vrowzer.build() now builds HTML apps. This PR is stage 2 of #36: the HTML build plugin, the preload helpers and new URL(..., import.meta.url) of upstream Vite, and the builder accepts an app with one HTML entry.

// vite.config.ts
VrowzerPlugin({ build: true })

// application.ts
const { files } = await vrowzer.build({ base: './' })
// files: { 'index.html': '...', 'assets/index-[hash].js': '...', 'assets/index-[hash].css': '...', ... }

What it does

  • Without build.lib, build() builds an app from one HTML entry: /index.html by default (the default one of ready() when the project has none), or another HTML file with build.rolldownOptions.input, e.g. /nested/index.html.
  • As in Vite:
    • the HTML is rewritten to the outputs, and the CSS and the assets are emitted
    • ?raw, ?url, new URL('./file', import.meta.url) and /public work
    • dynamic imports become chunks, which are preloaded with their CSS (modulepreload, with its polyfill by default)
    • source maps
  • base can be /, /app/ or ./. The outputs run on another origin as static files, without the Service Worker, the dev server, the HMR client or the preview URLs.
  • An app build is rejected with VrowzerBuildError (VROWZER_UNSUPPORTED_OPTION) when its entry is an array or an object, or is not an HTML file. A JavaScript entry is built with build.lib.

Ported from upstream Vite

From the same commit as the fork's asset.ts, css.ts and build.ts (cf5c0288d, 23 commits after 8.3.1):

  • html.ts: buildHtmlPlugin(), getCssFilesForChunk(), getLinkShouldInline(), isEntirelyImport() and the import map hooks
  • modulePreloadPolyfill.ts
  • importAnalysisBuild.ts: the preload helpers (with #23510 and #23611)
  • assetImportMetaUrl.ts

They are registered only for builds, so the previews do not change.

With NOTE(kazupon):

  • The preload code declares isCssPreloadUrl() under the name it has at runtime (isCssPreloadUrl.name).
    • The build inlines preload() into the output with toString().
    • The host build of an app that uses vrowzer minifies the builder, which renames isCssPreloadUrl, also where preload() calls it. In the fixture of the browser tests it becomes qG.
    • Upstream's code declares it under the original name, so the preload of every lazy chunk would fail.
    • A new bundle check of the builder stops the build of vite-dev-server unless:
      • the preload code declares the helper with .name
      • preload() calls it
      • preload() calls no other function of the bundle
  • postImportMapHook() is called in the fork's form, which does not support the chunk import map (Vite 8.3) yet, and build.chunkImportMap is read without the type.
  • The dev client of bundled dev mode is not injected (bundled dev was removed in refactor(vite-dev-server): remove legacy bundled dev client code #41).

Differences from the plan

  • import.meta.glob is not included.
    • The plan of this stage registered importGlobPlugin() for builds, assuming the fork had its native path ready. It does not: the whole plugin is commented out, and is older than upstream (no caseSensitive).
    • The previews do not transform import.meta.glob either, so an app that uses it could be built but not previewed.
    • As decided for Add vrowzer.build() for browser-side production builds #36 (it is excluded by the issue), it stays out. The README lists it as not supported.
  • The CSS of lazy chunks moves from stage 3 to this stage. The preload works for it, and the browser tests check it.

Not supported yet

Rejected with VROWZER_UNSUPPORTED_OPTION:

  • an app with more than one entry, or with an entry that is not HTML
  • the options rejected by stage 1 (library formats other than es and libraries with more than one entry, CSS minification, the terser and esbuild minifiers, build.watch, SSR, manifests and license files)

Not checked, and documented:

  • import.meta.glob(), and variables in the paths of dynamic imports and of new URL(..., import.meta.url). The build leaves them in the output without the files, so they fail when they run.
    • vite:asset-import-meta-url turns a template string with variables into import.meta.glob(), as upstream does.
  • the Workers of the project (new Worker(new URL(...)) and ?worker)
  • .env files, tsconfig, and the CommonJS files of dependencies for production (stage 3)

CSS Modules work in a Node build, but are not tested in the browser yet (stage 3).

Other changes

  • Vitest gives the original module to the second of two concurrent dynamic imports of a mocked module (reproduced with picocolors too).
    • The unit tests map @vrowzer/rolldown to the Node rolldown with vi.mock(), so the ported upstream tests that run two builds with Promise.all build one after another.
  • html.ts now imports the build code, so the unit tests that load it use the Node build of rolldown too.

There are no breaking changes.

Measurements

macOS arm64, Chromium, the app of the fixture:

  • build() of the app: 181–224 ms with builds spaced out, about 150 ms in a row. A library takes 168–208 ms in the same run. Each includes creating the build Worker and loading the builder.
  • 12 app builds in a row do not stall. Two of them wait about 2.2 s for the closed build Workers (stage 1's pacing); 6.0 s in total.
  • The output of the app: an entry of 2,746 bytes (minified, with the polyfill and the preload helper), and an HTML of 362 bytes.
  • Host output:
    • With build: true, the builder (web-worker-builder-*.js) grows by 18,778 bytes to 1,389,952 bytes.
    • The transformer of the previews' Web Worker (web-worker-transformer-*.js, also with build: false) grows by about 16.8 KB: the new plugins are in its bundle, though only builds register them. This is estimated by minifying the bundles of stage 1 and stage 2 with Oxc (1,430,547 → 1,447,333 bytes); the host output is 1,448,581 bytes.

Tests

All on macOS (arm64):

  • vp run build, the type checks, oxlint, knip and the format check pass.

  • Unit tests: 2144 passed, 3 skipped (46 more than stage 1).

    • ported from upstream:
      • getCssFilesForChunk(), the module preload polyfill, matchImportsToPreloadMarkers(), isCssPreloadUrl() and assetImportMetaUrlPlugin()
      • the builds of build.spec.ts that use the HTML entry: the hashes of CSS chunks, renderBuiltUrl, the tree shaking of JSON and the copy of the public directory
      • onRollupLog builds the HTML entry again, as upstream does
    • new:
      • the plugins that resolveConfig() registers for builds and leaves out of the previews
      • an HTML entry: the output HTML and the polyfill
      • the preload of lazy chunks, with their shared chunk and CSS
      • new URL() in builds
      • the entries that the builder accepts and rejects
  • Integration tests:

    Project Result
    fs 3 passed
    node-polyfill 769 passed, 6 skipped
    rolldown 4 passed
    service-worker-server 10 passed
    service-worker 27 passed
    unplugin-service-worker 63 passed, 1 skipped
    vrowzer 106 passed, 1 skipped (7 new)

    build.integration-test.ts builds an HTML app in the browser, and serves the outputs from another origin with Playwright's routing, as a static server does. It covers:

    • the bases /, /app/ and ./ (served under /sub/dir/). The app runs without the Service Worker, and every URL of the outputs is served:
      • the CSS of the HTML, of the entry and of the lazy chunk
      • an inlined SVG, and the bytes of the images (<img>, ?url and new URL())
      • ?raw and /public
      • nothing of the dev server in the outputs
    • the rewritten HTML, the lazy chunk and its CSS, the preload and the polyfill, and the source maps
    • a nested HTML entry, and the default /index.html of the runtime
    • an app with two HTML entries is rejected

    The fixture's builder is minified by the host build, so the lazy CSS also checks the preload helper with the renamed isCssPreloadUrl.

  • e2e, with the Vite caches cleared first:

    • dev mode: 46 passed, 6 skipped
    • host build mode: 39 passed, 13 skipped

…yfill

Builds can now take an HTML entry. `buildHtmlPlugin()` and its helpers
(`getCssFilesForChunk()`, `getLinkShouldInline()`, `isEntirelyImport()`
and the import map hooks) come from upstream's `html.ts`, at the same
commit as the fork's `asset.ts`, `css.ts` and `build.ts` (`cf5c0288d`,
23 commits after 8.3.1). The HTML plugin imports the id of the module
preload polyfill, so `modulePreloadPolyfill.ts` comes with it.

Both plugins are registered only for builds, so the dev Web Worker does
not change: it has no bundled environment, and it did not register the
polyfill before. The Worker aggregates import the polyfill plugin
eagerly, like the other plugins.

With `NOTE(kazupon)`:

- `postImportMapHook()` is called in the fork's form, which does not
  support the chunk import map (Vite 8.3) yet.
- The dev client of bundled dev mode is not injected (bundled dev was
  removed in #41).

Tests:

- ported from upstream: `getCssFilesForChunk()`, the module preload
  polyfill, and the builds of `build.spec.ts` that use the HTML entry
  (the hashes of CSS chunks, `renderBuiltUrl`, the tree shaking of JSON
  and the copy of the public directory). `onRollupLog` builds the HTML
  entry again, as upstream does.
- `resolveConfig()` registers the plugins for builds, and leaves them
  out of the dev pipeline.
- an HTML entry: the script and the style of the output HTML, and the
  polyfill with and without `modulePreload.polyfill`
- Vitest gives the original module to the second of two concurrent
  dynamic imports of a mocked module, so the ported tests build one
  after another.
- `html.ts` now imports the build code, so the tests that load it use
  the Node build of rolldown.
Builds now preload the CSS and the imports of lazy chunks.
`importAnalysisBuild.ts` comes from upstream, at the same commit as
`html.ts` (`cf5c0288d`, with #23510 and #23611 after 8.3.1), and
`resolveBuildPlugins()` registers `buildImportAnalysisPlugin()` again.
Library builds do not insert the preload, as upstream.

With `NOTE(kazupon)`:

- The preload code declares `isCssPreloadUrl()` under the name it has at
  runtime (`isCssPreloadUrl.name`). The build inlines `preload()` into
  the output with `toString()`. The host build of an app that uses
  vrowzer may minify the builder, which renames `isCssPreloadUrl`, also
  where `preload()` calls it, while upstream's code declares it under
  the original name.
- `build.chunkImportMap` (Vite 8.3) is read without the type, as in
  `build.ts`.

A new bundle check of the builder stops the build of vite-dev-server
unless the preload code declares the helper with `.name`, `preload()`
calls it, and `preload()` calls no other function of the bundle.

Tests:

- ported from upstream: `matchImportsToPreloadMarkers()` and
  `isCssPreloadUrl()`
- an HTML entry with lazy chunks: each preload has the chunk, its shared
  chunk and its CSS, and the helper comes with `isCssPreloadUrl`
- `resolveConfig()` registers the preload plugins for builds
`new URL('./file', import.meta.url)` now emits the file in builds, and
points to its output URL. `assetImportMetaUrl.ts` comes from upstream,
changed only by the `curly` lint style of the fork. It is registered
only for builds, as the dev Web Worker did not register it, and the
Worker aggregates import it eagerly.

Tests:

- ported from upstream: the template strings with variables, which
  become `import.meta.glob()`. They parse with rolldown's `parseAst`, as
  the fork does not depend on rollup.
- a build in Node: an image is emitted and its URL is replaced, a public
  file keeps its URL, and a missing file is left for the runtime
- `resolveConfig()` registers the plugin for builds only
The builder no longer requires `build.lib`. Without it, a build builds
an app from one HTML entry: `build.rolldownOptions.input`, then the
top-level `input`, and `/index.html` without them, as
`resolveRolldownOptions()` takes them.

An app build is rejected with `VROWZER_UNSUPPORTED_OPTION` when its
entry is an array or an object, as libraries with more than one entry
are, or when the entry is not an HTML file. A JavaScript entry is built
with `build.lib`.

The descriptions of `build()` and of its `lib` and `rolldownOptions.input`
options cover app builds. The browser test of unsupported options
rejects an app entry that is not HTML, instead of a build without
`build.lib`.
The fixture `integration/build/` gets the files of an HTML app: an
`index.html` with a stylesheet, an image and a module script, an entry
with CSS, a small SVG, `?url`, `?raw`, `new URL(..., import.meta.url)`
and a lazy chunk with its CSS, two images larger than the inline limit,
a public file, and a nested HTML entry.

`build.integration-test.ts` builds the app in the browser, and serves the
outputs from another origin with Playwright's routing, as a static
server does:

- with the bases `/`, `/app/` and `./` (served under a nested path), the
  app runs without the Service Worker, and every URL of the outputs is
  served. The CSS of the HTML, of the entry and of the lazy chunk
  applies, the images keep their bytes, and the outputs have nothing of
  the dev server.
- the rewritten HTML, the lazy chunk and its CSS, the preload and the
  module preload polyfill, and the source maps
- a nested HTML entry
- the default `/index.html` that the runtime gives a project without
  one, and the preview after the build

The fixture serves the builder minified by the host build, so the lazy
CSS also checks the preload helper with the renamed `isCssPreloadUrl`.
An app build with two HTML entries is rejected.
- README: `build()` builds an app from its HTML entry, or a library with
  `build.lib`. The entry of an app, what the build of an app does, the
  three kinds of `base`, and the outputs that run on a static server.
- The list of what is not supported yet is split into the options that
  the build rejects (now including an app with more than one entry or
  with an entry that is not HTML), and what it does not check:
  `import.meta.glob()`, variables in the paths of dynamic imports and of
  `new URL()`, the Workers of the project, `.env` files, tsconfig and
  the CommonJS files of dependencies. CSS Modules are not tested yet.
- The API reference of `build()` is generated again.
@kazupon
kazupon added this pull request to stack #76 October 2, 2026 16:23
@kazupon
kazupon merged commit af11daa into main Oct 2, 2026
4 checks passed
@kazupon
kazupon deleted the feat/build-html-app branch October 2, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant