# 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 dev.relism flash-ext-vite-maven-plugin ${flash.version} build ``` 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 ` run dev --host 127.0.0.1 --port --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 ` 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.