The Maven plugin now writes a maximally compressed .gz beside every text file of 1 KB and more, so the server compresses nothing and boot only reads the files: about 50 ms for Glossa's 500. Hashed files under assets/ skip the ETag, which nothing ever asks for. A path that is no file gets index.html only when the request's Accept names text/html, as a browser navigation does. Everything else, an API call to a missing route included, gets the app's own 404 instead of the index, and a dot in a client route no longer matters. The base path itself always serves the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
6.3 KiB
Markdown
140 lines
6.3 KiB
Markdown
# 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. Compression already happened in the build, so boot only reads files and hashes the few
|
|
that revalidate: about 50 ms for a 500-file app. 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 |
|
|
|---|---|
|
|
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
|
|
| a built file under `assets/` | `Cache-Control: public, max-age=31536000, immutable`, no `ETag` (it is never asked for again) |
|
|
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
|
|
| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
|
|
| no such file otherwise (a `fetch` to `/api/typo`, a missing script) | the app's own `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 |
|
|
|
|
A `.gz` beside a file is its gzipped form: the plugin writes one for every text file of 1 KB and
|
|
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
|
|
`Vary: Accept-Encoding`.
|
|
|
|
Known limits:
|
|
|
|
- A browser navigating straight to an API path that does not exist gets the app, which shows its
|
|
own not-found screen. Only an HTML navigation falls back, so API clients always get the 404.
|
|
- 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/`;
|
|
4. writes a gzip `.gz` at maximum compression beside every html, js, css, json, map, svg, txt,
|
|
xml, webmanifest, font and wasm file of 1 KB and more, and keeps it only when it is smaller.
|
|
Images and woff are left alone, because they are compressed already.
|
|
|
|
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.
|