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:
co-authored by
Claude Opus 5
parent
6d44f9e7b1
commit
580417e952
@@ -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.
|
||||
Reference in New Issue
Block a user