feat(ext-vite): replace the web bundler with a Vite extension and its Maven plugin

flash-ext-vite runs Vite's dev server in DEV and otherwise serves the build from the
classpath, read straight from the directory or jar with no manifest. The Maven plugin
flash-ext-vite-maven-plugin builds the frontend at prepare-package and packages it
there, so mvn package makes a jar that serves its own frontend and mvn test needs no
Node. Three overrides remain (root, devPort, basePath); the package manager is read
from the nearest lockfile.

Serving fixes what the bundler got wrong: Vite's hashed files under assets/ are
cached as immutable instead of revalidated, HEAD reports the real Content-Length, a
missing asset is a 404 instead of the index, 304s carry ETag and Cache-Control, and
gzip respects q=0 and is prepared at boot. Every response header is pre-encoded, so
serving allocates nothing, which is what Response.type(byte[]) is for. Vite stops
with the app through onClose, and a lockfile change reinstalls before restarting.

The modes, strategies, logging and command-safety options, the asset-source
abstraction, the manifest and the Jackson dependency are gone: 1,535 lines of main
code become 480, plus 84 for the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 15:47:28 +00:00
co-authored by Claude Opus 5
parent 6d44f9e7b1
commit 580417e952
82 changed files with 1150 additions and 2197 deletions
@@ -0,0 +1,134 @@
# flash-ext-vite
A Vite frontend for a Flash app, in two artifacts:
- **`flash-ext-vite`**, the extension. In DEV it runs Vite's dev server beside the app. Anywhere
else it serves the built frontend from the classpath as a single-page app.
- **`flash-ext-vite-maven-plugin`**, the build. It builds the frontend during `mvn package` and puts
the result where the extension reads it, so the jar runs on its own.
## Quick start
A Vite project in `web/` with the template's `dev` and `build` scripts, and:
```java
FlashApp.create(8080)
.install(new ViteExtension())
.get("/api/hello", (req, res) -> "hi")
.start();
```
```xml
<plugin>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-vite-maven-plugin</artifactId>
<version>${flash.version}</version>
<executions>
<execution>
<goals><goal>build</goal></goals>
</execution>
</executions>
</plugin>
```
That is the whole setup. `FLASH_ENV=dev` gives you Vite with hot reload, and `mvn package` gives you
a jar that serves the frontend.
## Conventions
These are fixed on purpose: each one is the Vite default, or the thing that keeps the two artifacts
in agreement.
| | |
|---|---|
| Project | a Vite project whose `package.json` has a `dev` and a `build` script |
| Build output | `dist/` inside the project (Vite's `build.outDir` default) |
| Inside the jar | `flash-vite/` (`ViteExtension.CLASSPATH`) |
| Content-hashed files | everything under `assets/` (Vite's `build.assetsDir` default) |
| Package manager | the one whose lockfile is nearest at or above the project: `pnpm-lock.yaml`, `yarn.lock`, `bun.lock`, `package-lock.json`. With none, npm. A project inside a workspace therefore uses the workspace's lockfile. |
| Node | on the `PATH`, with the package manager. Nothing is downloaded. |
## What you can override
| Extension | Default | |
|---|---|---|
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
| `devPort(int)` | `5173` | Vite's port in DEV. |
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
| Plugin parameter | Default | |
|---|---|---|
| `root` / `-Dflash.vite.root` | `${project.basedir}/web` | The Vite project. |
| `skip` / `-Dflash.vite.skip` | `false` | Leaves the frontend out, for a backend-only build. |
There is nothing else to set. If a convention above does not fit, that is a change to this module,
not a configuration option.
## DEV
`Flash.DEV` (`FLASH_ENV=dev` or `-Dflash.env=dev`) decides the mode:
1. The dependencies are installed with the lockfile pinned (`pnpm install --frozen-lockfile`,
`npm ci`, and so on). This is skipped when `node_modules` was already installed from that same
lockfile: its hash is kept in `node_modules/.flash-vite`, so deleting `node_modules` resets it.
2. The extension runs `<pm> run dev --host 127.0.0.1 --port <devPort> --strictPort` and waits up to
30 s for the port to answer. A port already in use, a script that exits, or a timeout fails the
boot with the reason. Vite's output is logged at INFO, prefixed `[vite]`.
3. While the app runs, a change to the lockfile (`pnpm add …`) reinstalls and restarts Vite. Vite
handles changes to its own config itself.
4. When the app stops (`ctx.onClose`), Vite and its whole process tree stop with it.
The extension registers no routes in DEV. The browser opens Vite's port, and Vite forwards the
backend's paths to Flash, so `vite.config` needs a `server.proxy` for them:
```ts
server: {
proxy: { '^/(api|auth)(/|$)': 'http://localhost:8080' },
},
```
## Production
Anywhere `Flash.DEV` is false, the build is read once at boot from `flash-vite/` on the classpath,
whether that is a directory (`target/classes`) or a jar. A missing build fails the boot and names
the plugin. Everything a response carries is prepared at boot, headers included, so serving
allocates nothing.
`GET` and `HEAD` on `basePath/**` answer as follows. Backend routes still win, because Flash
prefers specific routes over the wildcard.
| Request | Answer |
|---|---|
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable` |
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
| a path with no file extension (`/content/2`) | `index.html`, so the client-side router takes it |
| a path with an extension that matches no file | `404` |
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
| `HEAD` | the `GET` headers, `Content-Length` included, no body |
Text formats of 1 KB and more (html, js, css, json, svg, fonts, wasm, …) are gzipped once at boot
and sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
`Vary: Accept-Encoding`. Images and woff are sent as they are, because they are compressed already.
Known limits:
- A client-side route whose last segment has a dot (`/users/ada.lovelace`) is treated as a missing
file. Keep dots out of client routes, or route them under `basePath` differently.
- Every file is held in memory, gzip included. That suits an app's own frontend. Large media belongs
on a CDN or behind its own route.
## The plugin
The `build` goal is bound to `prepare-package`, so `mvn test` never needs Node, and `mvn package`,
`verify` and `install` always produce a jar with its frontend. It:
1. installs the dependencies with the lockfile pinned;
2. runs `<pm> run build` in the project;
3. replaces `target/classes/flash-vite/` with the project's `dist/`.
Missing Node or package manager, a failing install or build, or a build that leaves no
`dist/index.html` fails the Maven build with the reason. The package manager's own output shows in
the Maven log.
A Docker image therefore needs one build stage with both a JDK and Node. The frontend does not need
its own stage.