diff --git a/.idea/encodings.xml b/.idea/encodings.xml index 278fb63..aac5826 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -31,8 +31,8 @@ - - + + diff --git a/AGENTS.md b/AGENTS.md index cb99dad..d949708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Format: `(): ` | `ci` | Changes to GitHub Actions workflows | Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`, -`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`, +`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`, `ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`, `ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`. diff --git a/README.md b/README.md index 6c1c367..a724514 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | +| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise | +| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` | | `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type | | `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads | | `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` | diff --git a/flash-extensions/flash-ext-data-core/docs/README.md b/flash-extensions/flash-ext-data-core/docs/README.md index df53379..77025c7 100644 --- a/flash-extensions/flash-ext-data-core/docs/README.md +++ b/flash-extensions/flash-ext-data-core/docs/README.md @@ -112,6 +112,8 @@ public abstract class Repository { - `Tx` in the `FlashContext` - `TxManager` in the `FlashContext` - an annotation processor for `@Transactional` +- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's + session factory and connection pool; give the manager a pool you want closed with the app This makes the data layer composable with Flash's extension system without global state. diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java index 017308b..870c61b 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/DataExtension.java @@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.onClose(txManager::close); ctx.provide(Tx.class, tx); ctx.provide(TxManager.class, txManager); if (data != null) ctx.provide(Data.class, data); diff --git a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java index 6a1b1c4..ac02baa 100644 --- a/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java +++ b/flash-extensions/flash-ext-data-core/src/main/java/dev/relism/flash/ext/data/core/TxManager.java @@ -1,7 +1,11 @@ package dev.relism.flash.ext.data.core; -public interface TxManager { +public interface TxManager extends AutoCloseable { TxStatus begin(TxDefinition definition); void commit(TxStatus status); void rollback(TxStatus status); + + /** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */ + @Override + void close(); } diff --git a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java index 537893c..d0e053b 100644 --- a/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java +++ b/flash-extensions/flash-ext-data-hibernate/src/main/java/dev/relism/flash/ext/data/hibernate/HibernateTxManager.java @@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate; import dev.relism.flash.ext.data.core.*; import org.hibernate.Session; import org.hibernate.SessionFactory; +import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider; +import org.hibernate.engine.spi.SessionFactoryImplementor; + +import javax.sql.DataSource; import java.util.Objects; @@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager { this.sf = Objects.requireNonNull(sessionFactory); } + /** + * Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource} + * or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it. + */ + @Override + public void close() { + ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class); + DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null; + sf.close(); + if (ds instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception e) { + throw new IllegalStateException("Failed to close the data source", e); + } + } + } + @Override public TxStatus begin(TxDefinition definition) { return switch (definition.propagation()) { diff --git a/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java b/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java new file mode 100644 index 0000000..ae839e3 --- /dev/null +++ b/flash-extensions/flash-ext-data-hibernate/src/test/java/dev/relism/flash/ext/data/hibernate/HibernateTxManagerCloseTest.java @@ -0,0 +1,48 @@ +package dev.relism.flash.ext.data.hibernate; + +import org.hibernate.SessionFactory; +import org.hibernate.boot.MetadataSources; +import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import org.hibernate.cfg.AvailableSettings; +import org.junit.jupiter.api.Test; + +import javax.sql.DataSource; +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.logging.Logger; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class HibernateTxManagerCloseTest { + + /** Stands in for a pool: closeable, and it records being closed. */ + static final class Pool implements DataSource, AutoCloseable { + boolean closed; + @Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); } + @Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); } + @Override public void close() { closed = true; } + @Override public T unwrap(Class type) { throw new UnsupportedOperationException(); } + @Override public boolean isWrapperFor(Class type) { return false; } + @Override public PrintWriter getLogWriter() { return null; } + @Override public void setLogWriter(PrintWriter out) {} + @Override public void setLoginTimeout(int seconds) {} + @Override public int getLoginTimeout() { return 0; } + @Override public Logger getParentLogger() { throw new UnsupportedOperationException(); } + } + + @Test + void closingTheManagerClosesTheDataSourceHibernateWasGiven() { + Pool pool = new Pool(); + SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder() + .applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool) + .applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect") + .build()).buildMetadata().buildSessionFactory(); + + new HibernateTxManager(sf).close(); + + assertTrue(sf.isClosed()); + assertTrue(pool.closed); + } +} diff --git a/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java b/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java index 08ad9b1..c61595d 100644 --- a/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java +++ b/flash-extensions/flash-ext-data-jdbc/src/main/java/dev/relism/flash/ext/data/jdbc/JdbcTxManager.java @@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager { this.ds = Objects.requireNonNull(ds); } + /** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */ + @Override + public void close() { + if (ds instanceof AutoCloseable closeable) { + try { + closeable.close(); + } catch (Exception e) { + throw new IllegalStateException("Failed to close the data source", e); + } + } + } + @Override public TxStatus begin(TxDefinition definition) { return switch (definition.propagation()) { diff --git a/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java b/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java new file mode 100644 index 0000000..ddf5844 --- /dev/null +++ b/flash-extensions/flash-ext-data-jdbc/src/test/java/dev/relism/flash/ext/data/jdbc/JdbcTxManagerCloseTest.java @@ -0,0 +1,20 @@ +package dev.relism.flash.ext.data.jdbc; + +import com.zaxxer.hikari.HikariDataSource; +import dev.relism.flash.ext.data.DataExtension; +import dev.relism.flash.extension.FlashApp; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JdbcTxManagerCloseTest { + + /** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */ + @Test + void stoppingTheAppClosesThePool() { + HikariDataSource pool = new HikariDataSource(); + pool.setJdbcUrl(TestDataSource.URL); + FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join(); + assertTrue(pool.isClosed()); + } +} diff --git a/flash-extensions/flash-ext-vite-maven-plugin/pom.xml b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml new file mode 100644 index 0000000..908fdb4 --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-vite-maven-plugin + maven-plugin + + + + dev.relism + flash-ext-vite + + + org.apache.maven + maven-plugin-api + 3.9.9 + provided + + + org.apache.maven.plugin-tools + maven-plugin-annotations + 3.15.1 + provided + + + org.junit.jupiter + junit-jupiter + + + + + + + org.apache.maven.plugins + maven-plugin-plugin + 3.15.1 + + flash-vite + + + + + diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java new file mode 100644 index 0000000..944ee9b --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/main/java/dev/relism/flash/ext/vite/maven/BuildMojo.java @@ -0,0 +1,108 @@ +package dev.relism.flash.ext.vite.maven; + +import dev.relism.flash.ext.vite.PackageManager; +import dev.relism.flash.ext.vite.ViteExtension; +import org.apache.maven.plugin.AbstractMojo; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugins.annotations.LifecyclePhase; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; +import java.util.zip.Deflater; +import java.util.zip.GZIPOutputStream; + +/** + * Builds the Vite project and packages the result where {@code ViteExtension} serves it from in + * production, with a maximally gzipped {@code .gz} beside each text file, so the server compresses + * nothing. Bound to {@code prepare-package}: tests never need Node, a packaged jar always has its + * frontend. + */ +@Mojo(name = "build", defaultPhase = LifecyclePhase.PREPARE_PACKAGE, threadSafe = true) +public final class BuildMojo extends AbstractMojo { + + /** Formats that are not compressed already; images and woff gain nothing from gzip. */ + private static final Set COMPRESSIBLE = Set.of("html", "js", "mjs", "css", "json", "map", "webmanifest", "txt", "xml", "svg", "ttf", "otf", "wasm"); + + /** The Vite project. */ + @Parameter(property = "flash.vite.root", defaultValue = "${project.basedir}/web") + File root; + + @Parameter(defaultValue = "${project.build.outputDirectory}", readonly = true, required = true) + File classes; + + /** Leaves the frontend out, for a build that only needs the backend. */ + @Parameter(property = "flash.vite.skip", defaultValue = "false") + boolean skip; + + @Override + public void execute() throws MojoExecutionException { + if (skip) { + getLog().info("Skipping the frontend build"); + return; + } + Path project = root.toPath(); + if (!Files.isRegularFile(project.resolve("package.json"))) { + throw new MojoExecutionException("No package.json in " + project + ": set to the Vite project."); + } + PackageManager.Found packages = PackageManager.of(project); + run(project, packages.install()); + run(project, packages.run("build")); + Path dist = project.resolve("dist"); + if (!Files.isRegularFile(dist.resolve("index.html"))) { + throw new MojoExecutionException("The build left no dist/index.html in " + project + "."); + } + Path target = classes.toPath().resolve(ViteExtension.CLASSPATH); + try { + if (Files.exists(target)) { + try (Stream old = Files.walk(target)) { + for (Path path : old.sorted(Comparator.reverseOrder()).toList()) Files.delete(path); + } + } + Files.createDirectories(target.getParent()); + try (Stream built = Files.walk(dist)) { + for (Path from : built.toList()) { + Path to = target.resolve(dist.relativize(from).toString()); + Files.copy(from, to); + if (Files.isRegularFile(to)) gzip(to); + } + } + } catch (IOException e) { + throw new MojoExecutionException("Cannot copy " + dist + " to " + target, e); + } + getLog().info("Packaged " + dist + " as " + ViteExtension.CLASSPATH + "/"); + } + + /** Only worth it from 1 KB, and only kept when it is smaller. */ + private static void gzip(Path file) throws IOException { + String name = file.getFileName().toString(); + if (!COMPRESSIBLE.contains(name.substring(name.lastIndexOf('.') + 1).toLowerCase()) || Files.size(file) < 1024) return; + byte[] raw = Files.readAllBytes(file); + ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2); + try (GZIPOutputStream zip = new GZIPOutputStream(out) {{ def.setLevel(Deflater.BEST_COMPRESSION); }}) { + zip.write(raw); + } + if (out.size() < raw.length) Files.write(file.resolveSibling(name + ".gz"), out.toByteArray()); + } + + private static void run(Path project, List command) throws MojoExecutionException { + int exit; + try { + exit = new ProcessBuilder(command).directory(project.toFile()).inheritIO().start().waitFor(); + } catch (IOException e) { + throw new MojoExecutionException("Cannot run " + command.getFirst() + ": is Node installed and " + command.getFirst() + " on the PATH?", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MojoExecutionException("Interrupted running " + String.join(" ", command), e); + } + if (exit != 0) throw new MojoExecutionException(String.join(" ", command) + " failed with " + exit + " (its output is above)."); + } +} diff --git a/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java new file mode 100644 index 0000000..f447882 --- /dev/null +++ b/flash-extensions/flash-ext-vite-maven-plugin/src/test/java/dev/relism/flash/ext/vite/maven/BuildMojoTest.java @@ -0,0 +1,63 @@ +package dev.relism.flash.ext.vite.maven; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** A package.json whose build script stands in for Vite's. Needs npm on the PATH. */ +class BuildMojoTest { + + @TempDir + Path dir; + + @Test + void theBuildLandsWhereTheExtensionServesItFrom() throws Exception { + assumeTrue(onPath(), "npm is not on the PATH"); + Path web = Files.createDirectories(dir.resolve("web")); + Files.writeString(web.resolve("package.json"), """ + {"name":"t","private":true,"scripts":{"build":"node -e \\"const f=require('fs');f.mkdirSync('dist/assets',{recursive:true});f.writeFileSync('dist/index.html','built');f.writeFileSync('dist/assets/a-12345678.js','1');f.writeFileSync('dist/assets/b-12345678.js','x'.repeat(4096))\\""}} + """); + Path classes = dir.resolve("classes"); + Files.createDirectories(classes.resolve("flash-vite")); + Files.writeString(classes.resolve("flash-vite/stale.js"), "from the last build"); + + mojo(web, classes, false).execute(); + + assertEquals("built", Files.readString(classes.resolve("flash-vite/index.html"))); + assertEquals("1", Files.readString(classes.resolve("flash-vite/assets/a-12345678.js"))); + assertFalse(Files.exists(classes.resolve("flash-vite/stale.js"))); + // Big enough to gzip, and it shrinks; the 1-byte file is left alone. + assertTrue(Files.size(classes.resolve("flash-vite/assets/b-12345678.js.gz")) < 4096); + assertFalse(Files.exists(classes.resolve("flash-vite/assets/a-12345678.js.gz"))); + } + + @Test + void skipBuildsNothing() throws Exception { + mojo(dir.resolve("nowhere"), dir.resolve("classes"), true).execute(); + assertFalse(Files.exists(dir.resolve("classes"))); + } + + private static BuildMojo mojo(Path root, Path classes, boolean skip) { + BuildMojo mojo = new BuildMojo(); + mojo.root = root.toFile(); + mojo.classes = classes.toFile(); + mojo.skip = skip; + return mojo; + } + + private static boolean onPath() { + try { + return new ProcessBuilder("npm", "--version").start().waitFor() == 0; + } catch (IOException | InterruptedException e) { + return false; + } + } +} diff --git a/flash-extensions/flash-ext-vite/docs/README.md b/flash-extensions/flash-ext-vite/docs/README.md new file mode 100644 index 0000000..1490851 --- /dev/null +++ b/flash-extensions/flash-ext-vite/docs/README.md @@ -0,0 +1,147 @@ +# 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. | +| `navigationOnly(boolean)` | `true` | Only browser navigations fall back to `index.html` (see below). `false` gives every `GET` that matches no file the page, as nginx's `try_files` does, which also turns API 404s into the page. Rarely wanted. | + +| 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 the request is a navigation | `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 navigation is what a browser sends when a person opens a URL: `Sec-Fetch-Mode: navigate`, which +every current browser sets for exactly this purpose, or an `Accept` that names `text/html`, which +covers older browsers, crawlers and `curl -H 'Accept: text/html'`. The check reads the header bytes +in place and allocates nothing. This is stricter than Vite's own dev-server fallback, which also +takes `Accept: */*` and so gives `fetch('/api/typo')` the page. It is the same rule service +workers use for their navigation fallback. With `navigationOnly(false)` the check is skipped. + +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. diff --git a/flash-extensions/flash-ext-web-bundler/pom.xml b/flash-extensions/flash-ext-vite/pom.xml similarity index 75% rename from flash-extensions/flash-ext-web-bundler/pom.xml rename to flash-extensions/flash-ext-vite/pom.xml index 45ffc5a..fc375a2 100644 --- a/flash-extensions/flash-ext-web-bundler/pom.xml +++ b/flash-extensions/flash-ext-vite/pom.xml @@ -10,25 +10,17 @@ 2.1.0-SNAPSHOT - flash-ext-web-bundler + flash-ext-vite dev.relism flash - - org.projectlombok - lombok - org.slf4j slf4j-api - - com.fasterxml.jackson.core - jackson-databind - org.junit.jupiter junit-jupiter diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java new file mode 100644 index 0000000..245e232 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/Assets.java @@ -0,0 +1,210 @@ +package dev.relism.flash.ext.vite; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.http.HttpStatus; +import dev.relism.flash.models.PreEncodedHeader; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.Response; +import dev.relism.fpr.core.ByteView; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemAlreadyExistsException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.Map; +import java.util.stream.Stream; + +/** + * The built frontend, read once from the classpath at boot and served from memory: every byte a + * response carries, headers included, is prepared here, so serving allocates nothing. Compression + * happened at build time: a {@code .gz} beside a file is its gzipped form. + */ +final class Assets { + + private static final PreEncodedHeader IMMUTABLE = new PreEncodedHeader("Cache-Control", "public, max-age=31536000, immutable"); + private static final PreEncodedHeader REVALIDATE = new PreEncodedHeader("Cache-Control", "no-cache"); + private static final PreEncodedHeader VARY = new PreEncodedHeader("Vary", "Accept-Encoding"); + private static final PreEncodedHeader GZIP = new PreEncodedHeader("Content-Encoding", "gzip"); + + private static final Map TYPES = Map.ofEntries( + Map.entry("html", "text/html; charset=utf-8"), + Map.entry("js", "text/javascript; charset=utf-8"), + Map.entry("mjs", "text/javascript; charset=utf-8"), + Map.entry("css", "text/css; charset=utf-8"), + Map.entry("json", "application/json"), + Map.entry("map", "application/json"), + Map.entry("webmanifest", "application/manifest+json"), + Map.entry("txt", "text/plain; charset=utf-8"), + Map.entry("xml", "application/xml"), + Map.entry("svg", "image/svg+xml"), + Map.entry("png", "image/png"), + Map.entry("jpg", "image/jpeg"), + Map.entry("jpeg", "image/jpeg"), + Map.entry("gif", "image/gif"), + Map.entry("webp", "image/webp"), + Map.entry("avif", "image/avif"), + Map.entry("ico", "image/x-icon"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("ttf", "font/ttf"), + Map.entry("otf", "font/otf"), + Map.entry("wasm", "application/wasm")); + + private static final byte[] HTML = ascii("text/html"); + private static final byte[] GZIP_TOKEN = ascii("gzip"); + private static final byte[] Q = ascii("q="); + + record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, byte[] tag) {} + + final Map byPath; + private final Asset index; + private final boolean navigationOnly; + + /** + * Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under + * {@code basePath}; {@code navigationOnly} as in {@link ViteExtension#navigationOnly}. + */ + Assets(ClassLoader loader, String basePath, boolean navigationOnly) { + this.navigationOnly = navigationOnly; + URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html"); + if (index == null) { + throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): " + + "package the application with flash-ext-vite-maven-plugin, or run it with FLASH_ENV=dev."); + } + String prefix = "/".equals(basePath) ? "" : basePath; + Map assets = new HashMap<>(); + try { + URI uri = index.toURI(); + FileSystem opened = null; + if ("jar".equals(uri.getScheme())) { + try { + opened = FileSystems.newFileSystem(uri, Map.of()); + } catch (FileSystemAlreadyExistsException alreadyOpen) { + // Path.of below resolves against the one already open. + } + } + Path root = Path.of(uri).getParent(); + try (Stream files = Files.walk(root)) { + for (Path file : files.filter(Files::isRegularFile).toList()) { + String relative = root.relativize(file).toString().replace('\\', '/'); + if (relative.endsWith(".gz")) continue; + Path gzip = file.resolveSibling(file.getFileName() + ".gz"); + assets.put(prefix + "/" + relative, asset(relative, Files.readAllBytes(file), + Files.isRegularFile(gzip) ? Files.readAllBytes(gzip) : null)); + } + } finally { + if (opened != null) opened.close(); + } + } catch (IOException | URISyntaxException e) { + throw new IllegalStateException("Cannot read the built frontend at " + index, e); + } + // The root is the app itself, whatever the client accepts. + Asset root = assets.get(prefix + "/index.html"); + assets.put(prefix + "/", root); + if (!prefix.isEmpty()) assets.put(prefix, root); + this.byPath = Map.copyOf(assets); + this.index = root; + } + + /** + * The asset at the request's path. A path that is none gets {@code index.html} when a browser + * navigates to it, so the client-side router takes it; anything else, an API call or a missing + * script, gets the app's own 404. Headers are read as bytes, so nothing here allocates. + */ + void serve(Request req, Response res) { + Asset asset = byPath.get(req.path()); + if (asset == null) { + if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path()); + asset = index; + } + res.header(asset.cache); + if (asset.etag != null) { + res.header(asset.etag); + if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) { + res.status(HttpStatus.NOT_MODIFIED); + return; + } + } + res.type(asset.type); + if (asset.gzip != null) { + res.header(VARY); + if (acceptsGzip(req.headerView("Accept-Encoding"))) { + res.header(GZIP).body(asset.gzip); + return; + } + } + res.body(asset.raw); + } + + /** What browsers send when a person opens a URL: {@code Sec-Fetch-Mode}, or {@code Accept} naming HTML for older clients. */ + static boolean navigation(Request req) { + return req.headerEquals("Sec-Fetch-Mode", "navigate") || indexOf(req.headerView("Accept"), HTML, 0) >= 0; + } + + /** Listed in {@code Accept-Encoding}, and not with a q of zero. */ + static boolean acceptsGzip(ByteView accept) { + int at = indexOf(accept, GZIP_TOKEN, 0); + if (at < 0) return false; + int end = at; + while (end < accept.length() && accept.byteAt(end) != ',') end++; + int q = indexOf(accept, Q, at); + if (q < 0 || q > end) return true; + for (int i = q + 2; i < end; i++) { + byte c = accept.byteAt(i); + if (c >= '1' && c <= '9') return true; + if (c != '0' && c != '.') break; + } + return false; + } + + /** + * Where lowercase {@code needle} starts in {@code view} at or after {@code from}, ASCII case + * ignored, or -1 (also for a missing header). Folding with {@code | 0x20} leaves digits, quotes + * and slashes as they are, which is all the needles here contain besides letters. + */ + static int indexOf(ByteView view, byte[] needle, int from) { + if (view == null) return -1; + outer: + for (int i = from, last = view.length() - needle.length; i <= last; i++) { + for (int j = 0; j < needle.length; j++) { + if ((view.byteAt(i + j) | 0x20) != needle[j]) continue outer; + } + return i; + } + return -1; + } + + /** + * Vite puts every content-hashed file under {@code assets/}: those never change, so they are + * cached for good and never revalidated, and need no ETag. The rest revalidate by one. + */ + private static Asset asset(String path, byte[] raw, byte[] gzip) { + String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase(); + byte[] type = ascii(TYPES.getOrDefault(extension, "application/octet-stream")); + if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null); + String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\""; + return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), ascii(tag)); + } + + private static byte[] ascii(String text) { + return text.getBytes(StandardCharsets.US_ASCII); + } + + private static byte[] sha1(byte[] raw) { + try { + return MessageDigest.getInstance("SHA-1").digest(raw); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java new file mode 100644 index 0000000..85f900d --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/DevServer.java @@ -0,0 +1,173 @@ +package dev.relism.flash.ext.vite; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Vite's dev server as a child process of the app: dependencies installed whenever the lockfile + * differs from the one they were installed from, the server restarted when it changes under it, + * and the whole process tree gone when the app stops. + */ +final class DevServer implements AutoCloseable { + private static final Logger log = LoggerFactory.getLogger(DevServer.class); + + private final Path root; + private final int port; + private final PackageManager.Found packages; + private final ScheduledExecutorService watch = Executors.newSingleThreadScheduledExecutor( + Thread.ofPlatform().daemon().name("flash-vite-watch").factory()); + private Process process; + private FileTime lockfileTime; + + DevServer(Path root, int port) { + if (!Files.isRegularFile(root.resolve("package.json"))) { + throw new IllegalStateException("No package.json in " + root.toAbsolutePath() + ": point ViteExtension.root(...) at the Vite project."); + } + this.root = root; + this.port = port; + this.packages = PackageManager.of(root); + lockfileTime = modified(); + try { + install(); + start(); + } catch (RuntimeException failed) { + close(); + throw failed; + } + if (packages.lockfile() != null) watch.scheduleWithFixedDelay(this::restartIfLockfileChanged, 2, 2, TimeUnit.SECONDS); + } + + @Override + public synchronized void close() { + watch.shutdownNow(); + stop(); + } + + private synchronized void restartIfLockfileChanged() { + FileTime now = modified(); + if (now.equals(lockfileTime)) return; + lockfileTime = now; + log.info("{} changed, reinstalling and restarting Vite", packages.lockfile().getFileName()); + try { + stop(); + install(); + start(); + } catch (RuntimeException failed) { + log.error("Vite did not come back; fix the cause and touch the lockfile to retry", failed); + } + } + + /** Skipped when {@code node_modules} was installed from this very lockfile. */ + private void install() { + Path stamp = root.resolve("node_modules/.flash-vite"); + String lockfile = packages.lockfile() == null ? "" : sha256(packages.lockfile()); + try { + if (Files.isRegularFile(stamp) && Files.readString(stamp).equals(lockfile)) return; + Process install = spawn(packages.install()); + if (install.waitFor() != 0) throw new IllegalStateException(String.join(" ", packages.install()) + " failed with " + install.exitValue()); + Files.createDirectories(stamp.getParent()); + Files.writeString(stamp, lockfile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted installing the frontend's dependencies", e); + } + } + + private void start() { + if (answers()) throw new IllegalStateException("Port " + port + " is taken: stop what uses it or set ViteExtension.devPort(...)."); + process = spawn(packages.run("dev", "--host", "127.0.0.1", "--port", String.valueOf(port), "--strictPort")); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30); + while (!answers()) { + if (!process.isAlive()) throw new IllegalStateException("Vite exited with " + process.exitValue() + " before serving on port " + port + " (its output is logged above)."); + if (System.nanoTime() > deadline) throw new IllegalStateException("Vite did not answer on port " + port + " within 30 seconds."); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted waiting for Vite", e); + } + } + log.info("Vite is serving http://127.0.0.1:{}", port); + } + + /** npm, pnpm and friends are wrappers: their children have to go as well, gracefully first. */ + private void stop() { + if (process == null) return; + List tree = process.descendants().toList(); + tree.forEach(ProcessHandle::destroy); + process.destroy(); + try { + process.onExit().get(5, TimeUnit.SECONDS); + } catch (Exception gone) { + // Forced below. + } + tree.stream().filter(ProcessHandle::isAlive).forEach(ProcessHandle::destroyForcibly); + if (process.isAlive()) process.destroyForcibly(); + process = null; + } + + /** Starts {@code command} in the project, its output logged line by line. */ + private Process spawn(List command) { + Process started; + try { + started = new ProcessBuilder(command).directory(root.toFile()).redirectErrorStream(true).start(); + } catch (IOException e) { + throw new IllegalStateException("Cannot run " + command.getFirst() + ": is it installed and on the PATH?", e); + } + Thread.ofVirtual().start(() -> { + try (BufferedReader out = new BufferedReader(new InputStreamReader(started.getInputStream(), StandardCharsets.UTF_8))) { + for (String line; (line = out.readLine()) != null; ) log.info("[vite] {}", line); + } catch (IOException closed) { + // The process ended. + } + }); + return started; + } + + private boolean answers() { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", port), 200); + return true; + } catch (IOException e) { + return false; + } + } + + private FileTime modified() { + try { + return packages.lockfile() == null ? FileTime.fromMillis(0) : Files.getLastModifiedTime(packages.lockfile()); + } catch (IOException e) { + return FileTime.fromMillis(0); + } + } + + private static String sha256(Path file) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java new file mode 100644 index 0000000..7ab11b5 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/PackageManager.java @@ -0,0 +1,63 @@ +package dev.relism.flash.ext.vite; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** The package manager a frontend uses, told apart by its lockfile. Shared with the Maven plugin. */ +public enum PackageManager { + PNPM("pnpm-lock.yaml", "install", "--frozen-lockfile"), + YARN("yarn.lock", "install", "--frozen-lockfile"), + BUN("bun.lock", "install", "--frozen-lockfile"), + NPM("package-lock.json", "ci"); + + private static final boolean WINDOWS = System.getProperty("os.name", "").startsWith("Windows"); + + private final String lockfile; + private final String[] install; + + PackageManager(String lockfile, String... install) { + this.lockfile = lockfile; + this.install = install; + } + + /** A package manager and the lockfile it was found by, {@code null} when there is none. */ + public record Found(PackageManager manager, Path lockfile) { + + /** Installs exactly what the lockfile pins; without one, npm resolves afresh. */ + public List install() { + if (lockfile == null) return List.of(manager.binary(), "install"); + List command = new ArrayList<>(List.of(manager.binary())); + command.addAll(List.of(manager.install)); + return command; + } + + /** Runs a {@code package.json} script, passing {@code args} through to it. */ + public List run(String script, String... args) { + List command = new ArrayList<>(List.of(manager.binary(), "run", script)); + if (manager == NPM && args.length > 0) command.add("--"); + command.addAll(List.of(args)); + return command; + } + } + + /** + * The nearest lockfile at or above {@code root}, so a project inside a workspace finds the + * workspace's. None at all is npm without a lockfile. + */ + public static Found of(Path root) { + for (Path dir = root.toAbsolutePath().normalize(); dir != null; dir = dir.getParent()) { + for (PackageManager manager : values()) { + Path lockfile = dir.resolve(manager.lockfile); + if (Files.isRegularFile(lockfile)) return new Found(manager, lockfile); + } + } + return new Found(NPM, null); + } + + private String binary() { + String name = name().toLowerCase(); + return WINDOWS ? name + ".cmd" : name; + } +} diff --git a/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java new file mode 100644 index 0000000..897663d --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/main/java/dev/relism/flash/ext/vite/ViteExtension.java @@ -0,0 +1,73 @@ +package dev.relism.flash.ext.vite; + +import dev.relism.flash.Flash; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +import java.nio.file.Path; + +/** + * A Vite frontend for a Flash app. In DEV it runs Vite's dev server beside the app, which the + * browser talks to and which proxies the backend's paths to Flash. Otherwise it serves the build + * that {@code flash-ext-vite-maven-plugin} packaged into the jar, as a single-page app. + */ +public final class ViteExtension implements FlashExtension { + + /** Where the build lives on the classpath: the Maven plugin puts it there, production reads it from there. */ + public static final String CLASSPATH = "flash-vite"; + + private Path root = Path.of("web"); + private int devPort = 5173; + private String basePath = "/"; + private boolean navigationOnly = true; + + /** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */ + public ViteExtension root(Path root) { + this.root = root; + return this; + } + + /** Vite's port in DEV, 5173 by default. */ + public ViteExtension devPort(int devPort) { + this.devPort = devPort; + return this; + } + + /** Where the frontend is served from, {@code /} by default; Vite's {@code base} has to match it. */ + public ViteExtension basePath(String basePath) { + String trimmed = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath; + this.basePath = trimmed.isEmpty() ? "/" : trimmed.startsWith("/") ? trimmed : "/" + trimmed; + return this; + } + + /** + * Whether only a browser navigation to a path that is no file gets {@code index.html}, {@code + * true} by default, so an API call to a missing route gets a 404 rather than the page. {@code + * false} serves the page for every such {@code GET}, as nginx's {@code try_files} would. + */ + public ViteExtension navigationOnly(boolean navigationOnly) { + this.navigationOnly = navigationOnly; + return this; + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + if (Flash.DEV) { + ctx.onClose(new DevServer(root, devPort)::close); + return; + } + Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly); + String everything = "/".equals(basePath) ? "/**" : basePath + "/**"; + ctx.onReady(() -> { + app.get(everything, (req, res) -> { + assets.serve(req, res); + return null; + }); + app.head(everything, (req, res) -> { + assets.serve(req, res); + return null; + }); + }); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java new file mode 100644 index 0000000..4e0552e --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/AssetsTest.java @@ -0,0 +1,74 @@ +package dev.relism.flash.ext.vite; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AssetsTest { + + @TempDir + Path dir; + + /** What production runs from: the build inside a jar, not a directory. */ + @Test + void theBuildIsReadFromInsideAJar() throws IOException { + Path jar = dir.resolve("app.jar"); + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) { + for (String name : new String[]{"flash-vite/index.html", "flash-vite/assets/a-12345678.css"}) { + out.putNextEntry(new JarEntry(name)); + out.write("x".getBytes()); + } + } + try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) { + assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/", true).byPath.keySet()); + } + } + + @Test + void noBuildFailsTheBootNamingThePlugin() throws IOException { + try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) { + assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).getMessage().contains("flash-ext-vite-maven-plugin")); + } + } + + @Test + void gzipIsAcceptedUnlessRefused() { + assertTrue(Assets.acceptsGzip(view("gzip, deflate, br"))); + assertTrue(Assets.acceptsGzip(view("br;q=1.0, GZIP;q=0.5"))); + assertFalse(Assets.acceptsGzip(view("gzip;q=0, br"))); + assertFalse(Assets.acceptsGzip(view("gzip;q=0.000"))); + assertFalse(Assets.acceptsGzip(view("br"))); + assertFalse(Assets.acceptsGzip(null)); + } + + @Test + void searchingIgnoresAsciiCaseAndFindsTheFirstMatch() { + byte[] html = "text/html".getBytes(); + assertEquals(0, Assets.indexOf(view("Text/HTML,*/*"), html, 0)); + assertEquals(12, Assets.indexOf(view("application/text/html"), html, 0)); + assertEquals(-1, Assets.indexOf(view("text/htm"), html, 0)); + assertEquals(-1, Assets.indexOf(null, html, 0)); + } + + private static ByteView view(String text) { + byte[] bytes = text.getBytes(); + return new ByteView() { + @Override public int length() { return bytes.length; } + @Override public byte byteAt(int i) { return bytes[i]; } + }; + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java new file mode 100644 index 0000000..5017b81 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/DevServerTest.java @@ -0,0 +1,56 @@ +package dev.relism.flash.ext.vite; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** A real child process: a package.json whose dev script stands in for Vite. Needs npm on the PATH. */ +class DevServerTest { + + @TempDir + Path project; + + @Test + void startsTheDevScriptWaitsForItAndTakesItDownOnClose() throws Exception { + assumeTrue(onPath("npm"), "npm is not on the PATH"); + int port; + try (ServerSocket free = new ServerSocket(0)) { + port = free.getLocalPort(); + } + Files.writeString(project.resolve("package.json"), """ + {"name":"t","private":true,"scripts":{"dev":"node -e \\"require('http').createServer((q,s)=>s.end('ok')).listen(+process.argv[process.argv.indexOf('--port')+1],'127.0.0.1')\\" --"}} + """); + DevServer server = new DevServer(project, port); + assertTrue(answers(port)); + assertTrue(Files.isRegularFile(project.resolve("node_modules/.flash-vite"))); + server.close(); + assertFalse(answers(port)); + } + + private static boolean answers(int port) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", port), 200); + return true; + } catch (IOException e) { + return false; + } + } + + private static boolean onPath(String command) { + try { + return new ProcessBuilder(command, "--version").start().waitFor() == 0; + } catch (IOException | InterruptedException e) { + return false; + } + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java new file mode 100644 index 0000000..243efcb --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/PackageManagerTest.java @@ -0,0 +1,41 @@ +package dev.relism.flash.ext.vite; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PackageManagerTest { + + @TempDir + Path workspace; + + /** A project inside a workspace installs from the workspace's lockfile; its own one wins when it has one. */ + @Test + void theNearestLockfileDecides() throws IOException { + Path project = Files.createDirectories(workspace.resolve("apps/web")); + Files.writeString(workspace.resolve("pnpm-lock.yaml"), ""); + PackageManager.Found found = PackageManager.of(project); + assertEquals(PackageManager.PNPM, found.manager()); + assertTrue(found.install().getFirst().startsWith("pnpm")); + assertEquals(List.of("install", "--frozen-lockfile"), found.install().subList(1, 3)); + + Files.writeString(project.resolve("package-lock.json"), ""); + assertEquals(PackageManager.NPM, PackageManager.of(project).manager()); + assertEquals("ci", PackageManager.of(project).install().get(1)); + } + + @Test + void npmNeedsTheSeparatorBeforeScriptArguments() { + PackageManager.Found npm = new PackageManager.Found(PackageManager.NPM, null); + assertEquals(List.of("run", "dev", "--", "--port", "1"), npm.run("dev", "--port", "1").subList(1, 6)); + assertEquals("install", npm.install().get(1)); + assertEquals(List.of("run", "dev", "--port", "1"), new PackageManager.Found(PackageManager.PNPM, null).run("dev", "--port", "1").subList(1, 5)); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java new file mode 100644 index 0000000..de3ab29 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/java/dev/relism/flash/ext/vite/ViteExtensionTest.java @@ -0,0 +1,83 @@ +package dev.relism.flash.ext.vite; + +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** The packaged build (src/test/resources/flash-vite) served by a real server, outside DEV. */ +class ViteExtensionTest { + + private static final String JS = "/assets/app-AbCd1234.js"; + private static final String NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"; + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension())); + + @RegisterExtension + static final FlashTest everything = FlashTest.of(flash -> flash.install(new ViteExtension().navigationOnly(false))); + + @RegisterExtension + static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/"))); + + @Test + void hashedAssetsAreCachedForeverTheRestRevalidates() { + app.get(JS).expectStatus(200) + .expectHeader("Cache-Control", "public, max-age=31536000, immutable") + .expectHeader("Content-Type", "text/javascript; charset=utf-8") + .expectBodyContains("built"); + app.get("/favicon.svg").expectStatus(200).expectHeader("Cache-Control", "no-cache"); + } + + @Test + void gzipIsServedOnlyWhenAccepted() { + app.request().header("Accept-Encoding", "br, gzip").get(JS).expectHeader("Content-Encoding", "gzip").expectHeader("Vary", "Accept-Encoding"); + assertNull(app.request().header("Accept-Encoding", "gzip;q=0").get(JS).header("Content-Encoding")); + assertNull(app.get(JS).header("Content-Encoding")); + } + + /** A browser navigating gets the app, dots and all; a fetch or a script tag for nothing gets a 404. */ + @Test + void onlyNavigationsFallBackToTheIndex() { + for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) { + app.request().header("Accept", NAVIGATION).get(route).expectStatus(200).expectHeader("Cache-Control", "no-cache").expectBodyContains("spa"); + } + app.request().header("Sec-Fetch-Mode", "navigate").get("/content/2").expectStatus(200).expectBodyContains("spa"); + app.request().header("Accept", "application/json").get("/api/nope").expectStatus(404); + app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404); + app.get("/content/2").expectStatus(404); + app.get("/").expectStatus(200).expectBodyContains("spa"); + nested.get("/app").expectStatus(200).expectBodyContains("spa"); + } + + /** Opted out of the navigation check, every GET that is no file gets the page, as nginx's try_files would. */ + @Test + void withoutTheNavigationCheckEveryMissGetsTheIndex() { + everything.request().header("Accept", "application/json").get("/api/nope").expectStatus(200).expectBodyContains("spa"); + everything.get("/assets/missing.js").expectStatus(200).expectBodyContains("spa"); + } + + @Test + void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() { + String etag = app.get("/favicon.svg").header("ETag"); + app.request().header("If-None-Match", etag).get("/favicon.svg").expectStatus(304) + .expectHeader("ETag", etag).expectHeader("Cache-Control", "no-cache"); + assertNull(app.get(JS).header("ETag")); + } + + @Test + void headDescribesTheBodyWithoutSendingIt() { + String length = app.get(JS).header("Content-Length"); + var head = app.request().head(JS).expectStatus(200).expectHeader("Content-Length", length); + assertEquals("", head.body()); + } + + @Test + void aBasePathPrefixesEveryRoute() { + nested.get("/app" + JS).expectStatus(200); + nested.request().header("Accept", NAVIGATION).get("/app/settings").expectStatus(200).expectBodyContains("spa"); + nested.get(JS).expectStatus(404); + } +} diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js new file mode 100644 index 0000000..ac2f8d1 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js @@ -0,0 +1,100 @@ +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); +console.log("built"); diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz new file mode 100644 index 0000000..65101b6 Binary files /dev/null and b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/assets/app-AbCd1234.js.gz differ diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg new file mode 100644 index 0000000..0c7be4e --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/favicon.svg @@ -0,0 +1 @@ + diff --git a/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html new file mode 100644 index 0000000..6d37619 --- /dev/null +++ b/flash-extensions/flash-ext-vite/src/test/resources/flash-vite/index.html @@ -0,0 +1 @@ +
spa
diff --git a/flash-extensions/flash-ext-web-bundler/docs/README.md b/flash-extensions/flash-ext-web-bundler/docs/README.md deleted file mode 100644 index 9e3db83..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# flash-web-bundler - -`flash-web-bundler` integrates frontend tooling lifecycle into Flash with explicit, policy-driven behavior. - -## Quick Start - -```java -FlashApp.create(8080) - .install(new WebBundlerExtension( - WebBundlerConfig.builder() - .webRoot(Path.of("web")) - .basePath("/") - .build() - )) - .start(); -``` - -## Operating Model - -- Dev: orchestrates frontend process lifecycle and health checks. -- Prod: serves prebuilt assets from `assetsSource` with ETag/cache/compression support. -- Route precedence: backend first, SPA fallback second (`GET`/`HEAD`). - -See also: `asset-sources.md`. diff --git a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md b/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md deleted file mode 100644 index a0a82bf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/asset-sources.md +++ /dev/null @@ -1,29 +0,0 @@ -# Asset Sources - -`flash-web-bundler` uses explicit source objects for production assets. - -Supported sources: - -- `FilesystemAssetsSource.of(Path)` -- `ClasspathAssetsSource.of(String rootPrefix)` - -Builder shortcuts: - -- `.assetsFromFilesystem(Path.of("dist"))` -- `.assetsFromClasspath("web/dist")` - -Classpath source requires `asset-manifest.json` generated at build time. -The developer does not maintain this file manually — generate it with `WebBundlerBuild` -(`dev.relism.flash.ext.webbundler.WebBundlerBuild`), which scans a prebuilt directory (a Vite -`dist/` or a `STATIC` asset folder) and writes the manifest into it, ready to be picked up as a -classpath resource once that directory lands under `target/classes`. See `build-time.md`. - -`WebBundlerBuild` reuses the exact same etag/mimeType/immutable computation `FilesystemAssetsSource` -uses at runtime, so a file served from disk in dev and the same file served from the classpath in -prod get identical cache semantics. - -Production startup is fail-fast if: - -- manifest is missing -- manifest is invalid -- manifest points to missing resources diff --git a/flash-extensions/flash-ext-web-bundler/docs/build-time.md b/flash-extensions/flash-ext-web-bundler/docs/build-time.md deleted file mode 100644 index 8276599..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/build-time.md +++ /dev/null @@ -1,43 +0,0 @@ -# Build-Time Manifest Generation - -`WebBundlerBuild` turns an already-built directory into the `asset-manifest.json` that -`ClasspathAssetsSource` needs (see `asset-sources.md`). It does not run a frontend build itself — -it only scans a directory that already contains the final files: - -- `VITE`: point it at whatever `dist/` the existing frontend build tooling already produces. -- `STATIC`: point it directly at the static asset folder — there's no separate build step. - -It's meant to run once per build, from the consumer project's own build, not from the running -application (`ClasspathAssetsSource` is explicitly unsupported in DEV — see `dev-lifecycle.md`). - -## Wiring it into a Maven build - -No dedicated Flash5 Maven plugin — `WebBundlerBuild` is a plain class with a `main`, invoked via -the standard `exec-maven-plugin`, bound to run before the resources are packaged: - -```xml - - org.codehaus.mojo - exec-maven-plugin - - - web-bundler-manifest - process-classes - java - - dev.relism.flash.ext.webbundler.WebBundlerBuild - - ${project.build.outputDirectory}/web/dist - - - - - -``` - -This assumes the built frontend (`web/dist/`, or a static folder) is already copied under -`target/classes/web/dist` by that point — e.g. via `maven-resources-plugin`'s `copy-resources` -goal, or by running the frontend build with an output directory that points there directly. Once -the manifest is written alongside those files, they're just classpath resources: a plain `mvn -package` (or `maven-shade-plugin` for a fat jar) picks them up with no further configuration, and -the app can then be configured with `.assetsFromClasspath("web/dist")`. diff --git a/flash-extensions/flash-ext-web-bundler/docs/configuration.md b/flash-extensions/flash-ext-web-bundler/docs/configuration.md deleted file mode 100644 index 1c8b8f6..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/configuration.md +++ /dev/null @@ -1,36 +0,0 @@ -# Configuration - -`WebBundlerConfig` is immutable and built with `WebBundlerConfig.builder()`. - -Key fields: - -- `runtimeMode`: `PROD`, `ENV`, `AUTODETECT` -- `operationMode`: `ORCHESTRATE_ONLY`, `MANAGED` -- `frontendType`: `VITE`, `STATIC` — see `frontend-selection.md` -- `packageManager`: `NPM`, `PNPM`, `YARN`, `BUN` -- `installPolicy`: `AUTO_IF_LOCK_HASH_CHANGED`, `NEVER` -- `loggingMode`: `MERGED`, `SEPARATE`, `QUIET`, `VERBOSE` -- `commandSafetyMode`: `WARN`, `BLOCK`, `ALLOW` -- `webRoot`, `assetsSource`, `basePath`, `devHost`, `devPort`, `watchList` - -Asset source is explicit and object-based: - -- `assetsFromFilesystem(Path.of("dist"))` -- `assetsFromClasspath("web/dist")` - -Or by direct source object: - -- `assetsSource(FilesystemAssetsSource.of(Path.of("dist")))` -- `assetsSource(ClasspathAssetsSource.of("web/dist"))` - -Validation is fail-fast: - -- invalid `devPort` (only when `frontendType` requires orchestration — skipped for `STATIC`) -- blank/invalid watch entries (same — skipped for `STATIC`) -- invalid `basePath` - -`frontendType(...)` has side effects on other defaults, same pattern as `packageManager(...)` -resetting `watchList`: it also resets `operationMode` (`MANAGED` for `STATIC`, `ORCHESTRATE_ONLY` -otherwise) and `assetsSource` (`webRoot` itself for `STATIC`, `webRoot/dist` otherwise). Call -`.frontendType(...)` before any explicit `.operationMode(...)`/`.assetsSource(...)`/`.assetsFrom*(...)` -override, or the later call wins. diff --git a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md b/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md deleted file mode 100644 index c86aebf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/dev-lifecycle.md +++ /dev/null @@ -1,16 +0,0 @@ -# Dev Lifecycle - -Boot flow in dev mode: - -1. Resolve runtime mode. -2. Validate configuration and command safety. -3. Optionally install dependencies (hash-based lockfile cache). -4. Start dev server process. -5. Run healthcheck. -6. Start watchlist loop and restart dev process when tracked files change. - -Failure conditions are fail-fast: - -- occupied dev port -- command launch failure -- healthcheck timeout diff --git a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md b/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md deleted file mode 100644 index e72fdb4..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/frontend-selection.md +++ /dev/null @@ -1,34 +0,0 @@ -# Frontend Selection - -Frontend integration is explicit through `frontendType`. - -- No heuristic detection in v1. -- Deterministic mapping: `FrontendType -> FrontendStrategy`. -- Built-in strategies: `VITE`, `STATIC`. - -## VITE - -Orchestrates a dev server process in DEV, serves a prebuilt directory in PROD. See `dev-lifecycle.md`. - -## STATIC - -For files served as-is — no dev server, no package manager, no build step, no watch loop. -`STATIC` never orchestrates, in DEV or PROD: it always loads `assetsSource` directly and serves it, -the same code path `VITE` only uses in PROD. Editing a file during a running dev session requires a -restart to be picked up (assets are preloaded once, same as `VITE`'s prod serving — no hot reload). - -Setting `.frontendType(FrontendType.STATIC)` also switches two other defaults (see `configuration.md`): -`operationMode` becomes `MANAGED` and `assetsSource` defaults to the `webRoot` itself instead of a -`dist` subdirectory — a minimal STATIC config is just: - -```java -WebBundlerConfig.builder() - .frontendType(FrontendType.STATIC) - .webRoot(Path.of("public")) - .build() -``` - -Extension points: - -- register custom `FrontendStrategy` implementations in the resolver. -- override default commands per config. diff --git a/flash-extensions/flash-ext-web-bundler/docs/modes.md b/flash-extensions/flash-ext-web-bundler/docs/modes.md deleted file mode 100644 index 11723b1..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/modes.md +++ /dev/null @@ -1,21 +0,0 @@ -# Modes - -## Runtime Mode - -- `PROD`: production static serving only. -- `AUTODETECT`: uses `Flash.DEV`. -- `ENV`: uses `FLASH_WEB_BUNDLER_MODE` (`dev` => dev mode, else prod). - -## Operation Mode - -- `ORCHESTRATE_ONLY`: only orchestrates dev tooling. -- `MANAGED`: enables production serving + SPA fallback routes. - -`FrontendType.STATIC` defaults `operationMode` to `MANAGED` (see `frontend-selection.md`) — `STATIC` -has no dev tooling to orchestrate, so `ORCHESTRATE_ONLY` would make the extension a no-op for it. - -## Orchestration - -Whether DEV mode spawns a dev-server process at all is a separate axis from Runtime Mode: it also -depends on `frontendType`. `VITE` orchestrates in DEV; `STATIC` never does, in DEV or PROD — it -always loads and serves `assetsSource` directly, the same path `VITE` only takes in PROD. diff --git a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md b/flash-extensions/flash-ext-web-bundler/docs/package-managers.md deleted file mode 100644 index de3a7e6..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/package-managers.md +++ /dev/null @@ -1,19 +0,0 @@ -# Package Managers - -Package manager selection is explicit via `packageManager`. - -- `NPM` -> `package-lock.json` -- `PNPM` -> `pnpm-lock.yaml` -- `YARN` -> `yarn.lock` -- `BUN` -> `bun.lockb` - -Install policy: - -- `AUTO_IF_LOCK_HASH_CHANGED`: install only when lock hash changes. -- `NEVER`: never auto-install. - -Custom commands can override defaults via: - -- `installCommand` -- `devCommand` -- `buildCommand` diff --git a/flash-extensions/flash-ext-web-bundler/docs/performance.md b/flash-extensions/flash-ext-web-bundler/docs/performance.md deleted file mode 100644 index 84ce194..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/performance.md +++ /dev/null @@ -1,13 +0,0 @@ -# Performance Notes - -Current v1 optimizations: - -- startup preloading of static assets -- precomputed ETags and MIME lookup -- compressed payload selection based on `Accept-Encoding` -- lockfile-hash install gate to avoid redundant installs - -Future performance work: - -- streaming/sliced file serving for large assets -- benchmark suite with p50/p95/p99 latency and allocation profiling diff --git a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md b/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md deleted file mode 100644 index 71db8bc..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/prod-serving.md +++ /dev/null @@ -1,17 +0,0 @@ -# Production Serving - -In production mode, `flash-web-bundler` serves only prebuilt assets. - -Behavior: - -- preloads files from `assetsSource` (filesystem or classpath) -- resolves MIME by extension -- supports precompressed siblings (`.br`, `.gz`) -- sets `Cache-Control` and `ETag` -- responds `304 Not Modified` when `If-None-Match` matches - -Classpath mode details: - -- reads entries from `asset-manifest.json` in classpath root -- validates referenced resources at startup -- fails fast in production if manifest or resources are inconsistent diff --git a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md b/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md deleted file mode 100644 index 943762e..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/routing-fallback.md +++ /dev/null @@ -1,10 +0,0 @@ -# Routing Fallback - -Fallback policy: - -- backend routes always have precedence -- fallback route is wildcard under configured `basePath` -- fallback applies to `GET` and `HEAD` only -- if no static asset matches, serve SPA `indexFile` - -Namespace collisions should be avoided at app design level (for example, keep API under `/api`). diff --git a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md b/flash-extensions/flash-ext-web-bundler/docs/security-policies.md deleted file mode 100644 index 5a06ccf..0000000 --- a/flash-extensions/flash-ext-web-bundler/docs/security-policies.md +++ /dev/null @@ -1,11 +0,0 @@ -# Security Policies - -Command safety is enforced before process execution. - -Modes: - -- `WARN`: allow unknown binary and log warning. -- `BLOCK`: reject unknown binary. -- `ALLOW`: skip safe-registry checks. - -Safe registry defaults include common package-manager executables and is configurable. diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java deleted file mode 100644 index d5dd346..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetCatalog.java +++ /dev/null @@ -1,33 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.Map; - -public final class AssetCatalog { - private final Map byRoutePath; - private final AssetEntry index; - - AssetCatalog(Map byRoutePath, AssetEntry index) { - this.byRoutePath = Map.copyOf(byRoutePath); - this.index = index; - } - - AssetEntry find(String routePath) { - return byRoutePath.get(routePath); - } - - AssetEntry index() { - return index; - } - - public AssetEntry asset(String routePath) { - return find(routePath); - } - - public AssetEntry indexAsset() { - return index; - } - - public int size() { - return byRoutePath.size(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java deleted file mode 100644 index 088198b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetDirectoryScanner.java +++ /dev/null @@ -1,60 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Walks a directory and computes the same etag/mimeType/immutable metadata a served asset needs. - * Shared by {@link FilesystemAssetsSource} (runtime, dev/filesystem prod) and {@link WebBundlerBuild} - * (build-time classpath manifest) so both agree on cache semantics for the same file. - */ -final class AssetDirectoryScanner { - private AssetDirectoryScanner() { - } - - record ScannedAsset(String canonicalPath, byte[] raw, byte[] br, byte[] gz, String etag, String mimeType, boolean immutable) { - } - - static List scan(Path root) { - Map builders = new HashMap<>(); - try (var walk = Files.walk(root)) { - walk.filter(Files::isRegularFile).forEach(file -> { - String rel = "/" + root.relativize(file).toString().replace('\\', '/'); - String canonical = AssetIo.stripBrGzSuffix(rel); - Builder b = builders.computeIfAbsent(canonical, Builder::new); - byte[] bytes = AssetIo.read(file); - if (rel.endsWith(".br")) b.br = bytes; - else if (rel.endsWith(".gz")) b.gz = bytes; - else b.raw = bytes; - }); - } catch (IOException e) { - throw new IllegalStateException("Failed to scan assets from " + root, e); - } - - List result = new ArrayList<>(); - for (Builder b : builders.values()) { - if (b.raw == null) continue; - String etag = AssetIo.quotedSha1(b.raw); - String mime = MimeTypes.byPath(b.canonicalPath); - boolean immutable = AssetIo.isFingerprinted(b.canonicalPath); - result.add(new ScannedAsset(b.canonicalPath, b.raw, b.br, b.gz, etag, mime, immutable)); - } - return result; - } - - private static final class Builder { - private final String canonicalPath; - private byte[] raw; - private byte[] br; - private byte[] gz; - - private Builder(String canonicalPath) { - this.canonicalPath = canonicalPath; - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java deleted file mode 100644 index 46bde2b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetEntry.java +++ /dev/null @@ -1,11 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public record AssetEntry( - byte[] raw, - byte[] br, - byte[] gz, - String etag, - String mimeType, - boolean immutable -) { -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java deleted file mode 100644 index 3cbaeaa..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetIo.java +++ /dev/null @@ -1,93 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -final class AssetIo { - private AssetIo() { - } - - static byte[] read(Path path) { - try { - return Files.readAllBytes(path); - } catch (IOException e) { - throw new IllegalStateException("Failed reading asset " + path, e); - } - } - - static byte[] readClasspath(String path) { - try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) { - if (in == null) throw new IllegalStateException("Missing classpath resource: " + path); - return in.readAllBytes(); - } catch (IOException e) { - throw new IllegalStateException("Failed reading classpath resource: " + path, e); - } - } - - static byte[] readClasspathOrNull(String path) { - try (InputStream in = AssetIo.class.getClassLoader().getResourceAsStream(stripLeadingSlash(path))) { - if (in == null) return null; - return in.readAllBytes(); - } catch (IOException e) { - throw new IllegalStateException("Failed reading classpath resource: " + path, e); - } - } - - static String quotedSha1(byte[] data) { - return "\"" + sha1Hex(data) + "\""; - } - - private static String sha1Hex(byte[] data) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-1"); - byte[] hash = md.digest(data); - StringBuilder out = new StringBuilder(hash.length * 2); - for (byte b : hash) { - out.append(String.format("%02x", b)); - } - return out.toString(); - } catch (NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-1 unavailable", e); - } - } - - static String stripLeadingSlash(String path) { - if (path == null || path.isBlank()) return path; - return path.startsWith("/") ? path.substring(1) : path; - } - - static String normalizePath(String path) { - if (path == null || path.isBlank()) return "/"; - String p = path.replace('\\', '/'); - if (!p.startsWith("/")) p = "/" + p; - return p; - } - - static String stripBrGzSuffix(String path) { - if (path.endsWith(".br")) return path.substring(0, path.length() - 3); - if (path.endsWith(".gz")) return path.substring(0, path.length() - 3); - return path; - } - - static boolean isFingerprinted(String path) { - String file = path; - int slash = file.lastIndexOf('/'); - if (slash >= 0) file = file.substring(slash + 1); - int dot = file.lastIndexOf('.'); - if (dot <= 0) return false; - int prevDot = file.lastIndexOf('.', dot - 1); - if (prevDot <= 0) return false; - String token = file.substring(prevDot + 1, dot); - if (token.length() < 8) return false; - for (int i = 0; i < token.length(); i++) { - char c = token.charAt(i); - boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); - if (!hex) return false; - } - return true; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java deleted file mode 100644 index eb88e56..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetLoadRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.nio.file.Path; - -public record AssetLoadRequest(String basePath, String indexFile, RuntimeEnvironment environment, Path webRoot) { - public AssetLoadRequest { - if (basePath == null || basePath.isBlank()) throw new IllegalArgumentException("basePath is required"); - if (indexFile == null || indexFile.isBlank()) throw new IllegalArgumentException("indexFile is required"); - if (environment == null) throw new IllegalArgumentException("environment is required"); - if (webRoot == null) throw new IllegalArgumentException("webRoot is required"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java deleted file mode 100644 index 4e8d8a3..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetMetadata.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -record AssetMetadata( - String routePath, - String logicalPath, - String mimeType, - String etag, - boolean immutable -) { - AssetMetadata { - if (routePath == null || routePath.isBlank()) throw new IllegalArgumentException("routePath is required"); - if (logicalPath == null || logicalPath.isBlank()) throw new IllegalArgumentException("logicalPath is required"); - if (mimeType == null || mimeType.isBlank()) throw new IllegalArgumentException("mimeType is required"); - if (etag == null || etag.isBlank()) throw new IllegalArgumentException("etag is required"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java deleted file mode 100644 index 7761eac..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetPaths.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -final class AssetPaths { - private AssetPaths() { - } - - static String joinBase(String basePath, String routePath) { - String base = "/".equals(basePath) ? "" : basePath; - String route = AssetIo.normalizePath(routePath); - return base + route; - } - - static String normalizePrefix(String prefix) { - if (prefix == null || prefix.isBlank()) return ""; - String p = prefix.replace('\\', '/'); - if (p.startsWith("/")) p = p.substring(1); - while (p.endsWith("/")) p = p.substring(0, p.length() - 1); - return p; - } - - static String classpathJoin(String prefix, String relative) { - String p = normalizePrefix(prefix); - String r = AssetIo.stripLeadingSlash(relative).replace('\\', '/'); - return p.isEmpty() ? r : p + "/" + r; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java deleted file mode 100644 index 4660b1e..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSource.java +++ /dev/null @@ -1,5 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public interface AssetsSource { - AssetCatalog load(AssetLoadRequest request); -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java deleted file mode 100644 index 6fe119e..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/AssetsSources.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.nio.file.Path; - -public final class AssetsSources { - private AssetsSources() { - } - - public static AssetsSource filesystem(Path distDir) { - return FilesystemAssetsSource.of(distDir); - } - - public static AssetsSource classpath(String rootPrefix) { - return ClasspathAssetsSource.of(rootPrefix); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java deleted file mode 100644 index b7b8c0d..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/BasePathEnforcementMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum BasePathEnforcementMode { - WARN_ONLY, - STRICT -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java deleted file mode 100644 index 367e468..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetManifest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import com.fasterxml.jackson.annotation.JsonProperty; - -import java.util.List; - -record ClasspathAssetManifest(@JsonProperty("assets") List assets) { - record Entry( - @JsonProperty("routePath") String routePath, - @JsonProperty("resourcePath") String resourcePath, - @JsonProperty("mimeType") String mimeType, - @JsonProperty("etag") String etag, - @JsonProperty("immutable") boolean immutable - ) { - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java deleted file mode 100644 index 571cd99..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ClasspathAssetsSource.java +++ /dev/null @@ -1,91 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; - -import com.fasterxml.jackson.databind.ObjectMapper; - -public final class ClasspathAssetsSource implements AssetsSource { - private static final ObjectMapper JSON = new ObjectMapper(); - - private final String rootPrefix; - private final String manifestResource; - - private ClasspathAssetsSource(String rootPrefix, String manifestResource) { - this.rootPrefix = AssetPaths.normalizePrefix(rootPrefix); - this.manifestResource = AssetIo.stripLeadingSlash(manifestResource); - } - - public static ClasspathAssetsSource of(String rootPrefix) { - if (rootPrefix == null || rootPrefix.isBlank()) { - throw new IllegalArgumentException("rootPrefix cannot be blank"); - } - String normalized = AssetPaths.normalizePrefix(rootPrefix); - return new ClasspathAssetsSource(normalized, normalized + "/asset-manifest.json"); - } - - public static ClasspathAssetsSource of(String rootPrefix, String manifestResource) { - if (rootPrefix == null || rootPrefix.isBlank()) { - throw new IllegalArgumentException("rootPrefix cannot be blank"); - } - if (manifestResource == null || manifestResource.isBlank()) { - throw new IllegalArgumentException("manifestResource cannot be blank"); - } - return new ClasspathAssetsSource(rootPrefix, manifestResource); - } - - @Override - public AssetCatalog load(AssetLoadRequest request) { - ClasspathAssetManifest manifest = readManifest(); - if (manifest.assets() == null || manifest.assets().isEmpty()) { - throw new IllegalStateException("Classpath asset manifest has no assets: " + manifestResource); - } - - Map byRoute = new HashMap<>(); - for (ClasspathAssetManifest.Entry entry : manifest.assets()) { - AssetMetadata meta = toMeta(entry); - byte[] raw = AssetIo.readClasspath(resourcePath(meta.logicalPath())); - byte[] br = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".br")); - byte[] gz = AssetIo.readClasspathOrNull(resourcePath(meta.logicalPath() + ".gz")); - String routePath = AssetPaths.joinBase(request.basePath(), meta.routePath()); - byRoute.put(routePath, new AssetEntry(raw, br, gz, meta.etag(), meta.mimeType(), meta.immutable())); - } - - String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile()); - AssetEntry index = byRoute.get(indexRoute); - if (index == null) { - throw new IllegalStateException("Missing SPA fallback file in classpath manifest: " + indexRoute); - } - return new AssetCatalog(byRoute, index); - } - - private ClasspathAssetManifest readManifest() { - try (InputStream in = ClasspathAssetsSource.class.getClassLoader().getResourceAsStream(manifestResource)) { - if (in == null) throw new IllegalStateException("Missing classpath manifest: " + manifestResource); - String json = new String(in.readAllBytes(), StandardCharsets.UTF_8); - return JSON.readValue(json, ClasspathAssetManifest.class); - } catch (IOException e) { - throw new IllegalStateException("Invalid classpath manifest: " + manifestResource, e); - } - } - - private AssetMetadata toMeta(ClasspathAssetManifest.Entry entry) { - if (entry == null) throw new IllegalStateException("Manifest entry cannot be null"); - String routePath = AssetIo.normalizePath(entry.routePath()); - String logicalPath = entry.resourcePath(); - if (logicalPath == null || logicalPath.isBlank()) { - throw new IllegalStateException("Manifest entry resourcePath is required"); - } - return new AssetMetadata(routePath, logicalPath.trim(), entry.mimeType(), entry.etag(), entry.immutable()); - } - - private String resourcePath(String logicalPath) { - if (logicalPath.startsWith("/")) { - return logicalPath.substring(1); - } - return AssetPaths.classpathJoin(rootPrefix, logicalPath); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java deleted file mode 100644 index fd1a871..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandOrchestrator.java +++ /dev/null @@ -1,187 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - -final class CommandOrchestrator implements AutoCloseable { - private static final Logger log = LoggerFactory.getLogger(CommandOrchestrator.class); - - private final WebBundlerConfig config; - private final CommandSafetyPolicy safetyPolicy; - private final ExecutorService logPool = Executors.newCachedThreadPool(daemonThreadFactory("flash-web-bundler-log")); - private Process devProcess; - - CommandOrchestrator(WebBundlerConfig config, CommandSafetyPolicy safetyPolicy) { - this.config = config; - this.safetyPolicy = safetyPolicy; - } - - void runBlocking(List command, Path cwd) { - safetyPolicy.check(command); - Process process = start(command, cwd); - try { - int code = process.waitFor(); - if (code != 0) { - throw new IllegalStateException("Command failed (" + code + "): " + String.join(" ", command)); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while waiting process", e); - } - } - - void startDevServer(List command, Path cwd) { - if (devProcess != null && devProcess.isAlive()) return; - if (isPortOccupied(config.devHost(), config.devPort())) { - throw new IllegalStateException("Dev server port already occupied: " + config.devHost() + ":" + config.devPort()); - } - safetyPolicy.check(command); - devProcess = start(command, cwd); - } - - void healthcheck(String healthPath, Duration timeout) { - long deadline = System.currentTimeMillis() + timeout.toMillis(); - String url = "http://" + config.devHost() + ":" + config.devPort() + healthPath; - while (System.currentTimeMillis() < deadline) { - if (devProcess == null || !devProcess.isAlive()) { - throw new IllegalStateException("Dev process exited before becoming healthy"); - } - if (ping(url)) return; - sleep(200L); - } - throw new IllegalStateException("Dev server healthcheck timeout for " + url); - } - - void restartDevServer(List command, Path cwd) { - stopDevServer(); - startDevServer(command, cwd); - } - - void stopDevServer() { - if (devProcess == null) return; - destroyProcessTree(devProcess, 5); - devProcess = null; - } - - @Override - public void close() { - stopDevServer(); - logPool.shutdownNow(); - } - - private Process start(List command, Path cwd) { - ProcessBuilder pb = new ProcessBuilder(command); - pb.directory(cwd.toFile()); - try { - Process process = pb.start(); - attachLogs(process, command.get(0)); - return process; - } catch (IOException e) { - throw new IllegalStateException("Failed to launch process: " + String.join(" ", command), e); - } - } - - private void attachLogs(Process process, String name) { - if (config.loggingMode() == LoggingMode.QUIET) return; - boolean merge = config.loggingMode() == LoggingMode.MERGED || config.loggingMode() == LoggingMode.VERBOSE; - if (merge) { - logPool.submit(() -> readStream(process.getInputStream(), "[" + name + "] ")); - logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + "] ")); - } else { - logPool.submit(() -> readStream(process.getInputStream(), "[" + name + ":out] ")); - logPool.submit(() -> readStream(process.getErrorStream(), "[" + name + ":err] ")); - } - } - - private void readStream(java.io.InputStream in, String prefix) { - try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { - String line; - while ((line = r.readLine()) != null) { - if (config.loggingMode() == LoggingMode.VERBOSE) log.info("{}{}", prefix, line); - else log.debug("{}{}", prefix, line); - } - } catch (IOException ignored) { - } - } - - private static boolean ping(String rawUrl) { - try { - HttpURLConnection conn = (HttpURLConnection) new URL(rawUrl).openConnection(); - conn.setConnectTimeout(500); - conn.setReadTimeout(500); - conn.setRequestMethod("GET"); - int code = conn.getResponseCode(); - return code >= 200 && code < 500; - } catch (IOException e) { - return false; - } - } - - private static boolean isPortOccupied(String host, int port) { - try (Socket socket = new Socket()) { - socket.connect(new InetSocketAddress(host, port), 250); - return true; - } catch (IOException e) { - return false; - } - } - - private static void sleep(long millis) { - try { - Thread.sleep(millis); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - } - - private static ThreadFactory daemonThreadFactory(String namePrefix) { - return runnable -> { - Thread thread = new Thread(runnable, namePrefix); - thread.setDaemon(true); - return thread; - }; - } - - private static void destroyProcessTree(Process process, int gracefulSeconds) { - ProcessHandle root = process.toHandle(); - List descendants = root.descendants().toList(); - - // First pass: graceful termination for wrapper + children. - for (ProcessHandle child : descendants) child.destroy(); - root.destroy(); - - waitForExit(root, gracefulSeconds); - - // Second pass: force-kill anything still alive (important on Windows cmd wrappers). - for (ProcessHandle child : descendants) { - if (child.isAlive()) child.destroyForcibly(); - } - if (root.isAlive()) root.destroyForcibly(); - - waitForExit(root, 2); - } - - private static void waitForExit(ProcessHandle handle, int timeoutSeconds) { - try { - handle.onExit().get(timeoutSeconds, TimeUnit.SECONDS); - } catch (Exception ignored) { - // Best-effort shutdown; callers handle remaining state. - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java deleted file mode 100644 index 61882bd..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum CommandSafetyMode { - WARN, - BLOCK, - ALLOW -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java deleted file mode 100644 index 9169101..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicy.java +++ /dev/null @@ -1,27 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; - -final class CommandSafetyPolicy { - private static final Logger log = LoggerFactory.getLogger(CommandSafetyPolicy.class); - - private final WebBundlerConfig config; - - CommandSafetyPolicy(WebBundlerConfig config) { - this.config = config; - } - - void check(List command) { - if (command.isEmpty()) throw new IllegalArgumentException("Command cannot be empty"); - String binary = command.get(0); - boolean safe = config.safeCommandRegistry().contains(binary); - if (safe || config.commandSafetyMode() == CommandSafetyMode.ALLOW) return; - if (config.commandSafetyMode() == CommandSafetyMode.BLOCK) { - throw new IllegalStateException("Blocked unsafe command: " + String.join(" ", command)); - } - log.warn("Executing command outside safe registry: {}", String.join(" ", command)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java deleted file mode 100644 index 8f1a11d..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/CommandTokens.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.Arrays; -import java.util.List; - -final class CommandTokens { - private CommandTokens() {} - - static List split(String command) { - return Arrays.stream(command.trim().split("\\s+")) - .filter(s -> !s.isBlank()) - .toList(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java deleted file mode 100644 index 769c07b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FilesystemAssetsSource.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -public final class FilesystemAssetsSource implements AssetsSource { - private final Path distDir; - - private FilesystemAssetsSource(Path distDir) { - this.distDir = distDir.normalize(); - } - - public static FilesystemAssetsSource of(Path distDir) { - if (distDir == null) throw new IllegalArgumentException("distDir cannot be null"); - return new FilesystemAssetsSource(distDir); - } - - @Override - public AssetCatalog load(AssetLoadRequest request) { - Path root = request.webRoot().resolve(distDir).normalize(); - if (!Files.exists(root)) { - throw new IllegalStateException("distDir does not exist: " + root); - } - - Map byRoute = new HashMap<>(); - for (AssetDirectoryScanner.ScannedAsset asset : AssetDirectoryScanner.scan(root)) { - String routePath = AssetPaths.joinBase(request.basePath(), asset.canonicalPath()); - byRoute.put(routePath, new AssetEntry(asset.raw(), asset.br(), asset.gz(), asset.etag(), asset.mimeType(), asset.immutable())); - } - - String indexRoute = AssetPaths.joinBase(request.basePath(), "/" + request.indexFile()); - AssetEntry index = byRoute.get(indexRoute); - if (index == null) { - throw new IllegalStateException("Missing SPA fallback file: " + indexRoute + " (from " + root + ")"); - } - return new AssetCatalog(byRoute, index); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java deleted file mode 100644 index 691047b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendStrategy.java +++ /dev/null @@ -1,12 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.List; - -interface FrontendStrategy { - FrontendType type(); - List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter); - List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter); - default String healthcheckPath() { - return "/"; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java deleted file mode 100644 index c980410..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendType.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum FrontendType { - VITE(true), - STATIC(false); - - private final boolean requiresOrchestration; - - FrontendType(boolean requiresOrchestration) { - this.requiresOrchestration = requiresOrchestration; - } - - /** Whether this frontend type needs a dev-server process, package manager, and watch loop. */ - boolean requiresOrchestration() { - return requiresOrchestration; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java deleted file mode 100644 index 05b39da..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/FrontendTypeResolver.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.EnumMap; -import java.util.Map; - -final class FrontendTypeResolver { - private final Map strategies = new EnumMap<>(FrontendType.class); - - FrontendTypeResolver() { - register(new ViteFrontendStrategy()); - register(new StaticFrontendStrategy()); - } - - void register(FrontendStrategy strategy) { - strategies.put(strategy.type(), strategy); - } - - FrontendStrategy resolve(FrontendType type) { - FrontendStrategy strategy = strategies.get(type); - if (strategy == null) { - throw new IllegalArgumentException("Unsupported frontend type: " + type); - } - return strategy; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java deleted file mode 100644 index 9241631..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallCache.java +++ /dev/null @@ -1,57 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; - -final class InstallCache { - private final Path cacheFile; - - InstallCache(Path webRoot) { - this.cacheFile = webRoot.resolve(".flash-web-bundler/install.hash"); - } - - boolean lockChanged(Path lockfile) { - if (!Files.exists(lockfile)) return true; - String current = digest(lockfile); - String previous = readCached(); - return !current.equals(previous); - } - - void writeLockHash(Path lockfile) { - if (!Files.exists(lockfile)) return; - try { - Files.createDirectories(cacheFile.getParent()); - Files.writeString(cacheFile, digest(lockfile), StandardCharsets.UTF_8); - } catch (IOException e) { - throw new IllegalStateException("Failed to write install cache hash", e); - } - } - - private String readCached() { - if (!Files.exists(cacheFile)) return ""; - try { - return Files.readString(cacheFile, StandardCharsets.UTF_8).trim(); - } catch (IOException e) { - return ""; - } - } - - private static String digest(Path file) { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] bytes = Files.readAllBytes(file); - byte[] hash = md.digest(bytes); - StringBuilder sb = new StringBuilder(hash.length * 2); - for (byte b : hash) { - sb.append(String.format("%02x", b)); - } - return sb.toString(); - } catch (NoSuchAlgorithmException | IOException e) { - throw new IllegalStateException("Unable to hash lockfile " + file, e); - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java deleted file mode 100644 index e271cda..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/InstallPolicy.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum InstallPolicy { - AUTO_IF_LOCK_HASH_CHANGED, - NEVER -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java deleted file mode 100644 index 3134e89..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/LoggingMode.java +++ /dev/null @@ -1,8 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum LoggingMode { - MERGED, - SEPARATE, - QUIET, - VERBOSE -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java deleted file mode 100644 index 75db7f2..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/MimeTypes.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.Map; - -final class MimeTypes { - private static final Map EXT = Map.ofEntries( - Map.entry("html", "text/html"), - Map.entry("js", "text/javascript"), - Map.entry("css", "text/css"), - Map.entry("json", "application/json"), - Map.entry("svg", "image/svg+xml"), - Map.entry("png", "image/png"), - Map.entry("jpg", "image/jpeg"), - Map.entry("jpeg", "image/jpeg"), - Map.entry("gif", "image/gif"), - Map.entry("ico", "image/x-icon"), - Map.entry("woff2", "font/woff2"), - Map.entry("ttf", "font/ttf"), - Map.entry("txt", "text/plain") - ); - - private MimeTypes() {} - - static String byPath(String path) { - int idx = path.lastIndexOf('.'); - if (idx < 0 || idx == path.length() - 1) return "application/octet-stream"; - String ext = path.substring(idx + 1).toLowerCase(); - return EXT.getOrDefault(ext, "application/octet-stream"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java deleted file mode 100644 index 4f04904..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ModeResolver.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import dev.relism.flash.Flash; - -final class ModeResolver { - RuntimeEnvironment resolve(WebBundlerConfig config) { - return switch (config.runtimeMode()) { - case PROD -> RuntimeEnvironment.PROD; - case AUTODETECT -> Flash.DEV ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD; - case ENV -> fromEnvironment(config.envRuntimeVariable()); - }; - } - - private RuntimeEnvironment fromEnvironment(String key) { - String value = System.getenv(key); - if (value == null || value.isBlank()) return RuntimeEnvironment.PROD; - return "dev".equalsIgnoreCase(value) ? RuntimeEnvironment.DEV : RuntimeEnvironment.PROD; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java deleted file mode 100644 index 7d4cfa8..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/OperationMode.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum OperationMode { - ORCHESTRATE_ONLY, - MANAGED -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java deleted file mode 100644 index 0199419..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManager.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum PackageManager { - NPM("npm", "package-lock.json"), - PNPM("pnpm", "pnpm-lock.yaml"), - YARN("yarn", "yarn.lock"), - BUN("bun", "bun.lockb"); - - private final String binary; - private final String lockfileName; - - PackageManager(String binary, String lockfileName) { - this.binary = binary; - this.lockfileName = lockfileName; - } - - public String binary() { - return binary; - } - - public String lockfileName() { - return lockfileName; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java deleted file mode 100644 index 53bb798..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/PackageManagerAdapter.java +++ /dev/null @@ -1,44 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.List; - -final class PackageManagerAdapter { - private final WebBundlerConfig config; - private final boolean windows = System.getProperty("os.name", "").toLowerCase().contains("win"); - - PackageManagerAdapter(WebBundlerConfig config) { - this.config = config; - } - - List installCommand() { - if (config.installCommand() != null) return CommandTokens.split(config.installCommand()); - return switch (config.packageManager()) { - case NPM -> List.of(bin("npm"), "install"); - case PNPM -> List.of(bin("pnpm"), "install", "--frozen-lockfile"); - case YARN -> List.of(bin("yarn"), "install", "--frozen-lockfile"); - case BUN -> List.of(bin("bun"), "install", "--frozen-lockfile"); - }; - } - - List devCommand(String host, int port) { - return switch (config.packageManager()) { - case NPM -> List.of(bin("npm"), "run", "dev", "--", "--host", host, "--port", String.valueOf(port)); - case PNPM -> List.of(bin("pnpm"), "dev", "--host", host, "--port", String.valueOf(port)); - case YARN -> List.of(bin("yarn"), "dev", "--host", host, "--port", String.valueOf(port)); - case BUN -> List.of(bin("bun"), "run", "dev", "--host", host, "--port", String.valueOf(port)); - }; - } - - List buildCommand() { - return switch (config.packageManager()) { - case NPM -> List.of(bin("npm"), "run", "build"); - case PNPM -> List.of(bin("pnpm"), "build"); - case YARN -> List.of(bin("yarn"), "build"); - case BUN -> List.of(bin("bun"), "run", "build"); - }; - } - - private String bin(String name) { - return windows ? name + ".cmd" : name; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java deleted file mode 100644 index 7050be5..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeEnvironment.java +++ /dev/null @@ -1,6 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -enum RuntimeEnvironment { - DEV, - PROD -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java deleted file mode 100644 index bda8e72..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/RuntimeMode.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -public enum RuntimeMode { - PROD, - ENV, - AUTODETECT -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java deleted file mode 100644 index 0f8f799..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/SpaFallbackPolicy.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import dev.relism.flash.models.Response; - -final class SpaFallbackPolicy { - private final byte[] html; - - SpaFallbackPolicy(AssetCatalog catalog) { - this.html = catalog.index().raw(); - } - - void apply(Response response) { - response.type("text/html"); - response.header("Cache-Control", "no-cache"); - response.body(html); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java deleted file mode 100644 index 105616b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticAssetServingPolicy.java +++ /dev/null @@ -1,46 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import dev.relism.flash.http.HttpStatus; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.Response; - -final class StaticAssetServingPolicy { - private final AssetCatalog catalog; - - StaticAssetServingPolicy(AssetCatalog catalog) { - this.catalog = catalog; - } - - boolean serve(Request req, Response res) { - AssetEntry entry = catalog.find(req.path()); - if (entry == null) return false; - String inm = req.header("If-None-Match"); - if (entry.etag().equals(inm)) { - res.status(HttpStatus.NOT_MODIFIED); - return true; - } - res.type(entry.mimeType()); - if (entry.immutable()) { - res.header("Cache-Control", "public, max-age=31536000, immutable"); - } else { - res.header("Cache-Control", "no-cache"); - } - res.header("ETag", entry.etag()); - res.header("Vary", "Accept-Encoding"); - if (accepts(req, "br") && entry.br() != null) { - res.header("Content-Encoding", "br"); - res.body(entry.br()); - } else if (accepts(req, "gzip") && entry.gz() != null) { - res.header("Content-Encoding", "gzip"); - res.body(entry.gz()); - } else { - res.body(entry.raw()); - } - return true; - } - - private static boolean accepts(Request req, String encoding) { - String value = req.header("Accept-Encoding"); - return value != null && value.contains(encoding); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java deleted file mode 100644 index ed34e83..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/StaticFrontendStrategy.java +++ /dev/null @@ -1,21 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.List; - -/** No dev server, no build step — assets are served as-is. Both methods below are unreachable: call sites are gated by {@link FrontendType#requiresOrchestration()}. */ -final class StaticFrontendStrategy implements FrontendStrategy { - @Override - public FrontendType type() { - return FrontendType.STATIC; - } - - @Override - public List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - throw new UnsupportedOperationException("STATIC frontend type has no dev command"); - } - - @Override - public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - throw new UnsupportedOperationException("STATIC frontend type has no build command"); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java deleted file mode 100644 index e49cbe7..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/ViteFrontendStrategy.java +++ /dev/null @@ -1,26 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.util.List; - -final class ViteFrontendStrategy implements FrontendStrategy { - @Override - public FrontendType type() { - return FrontendType.VITE; - } - - @Override - public List devCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - if (config.devCommand() != null) { - return CommandTokens.split(config.devCommand()); - } - return adapter.devCommand(config.devHost(), config.devPort()); - } - - @Override - public List buildCommand(WebBundlerConfig config, PackageManagerAdapter adapter) { - if (config.buildCommand() != null) { - return CommandTokens.split(config.buildCommand()); - } - return adapter.buildCommand(); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java deleted file mode 100644 index defd640..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WatchList.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -final class WatchList { - private final Path webRoot; - private final Map lastKnown = new HashMap<>(); - - WatchList(Path webRoot) { - this.webRoot = webRoot; - } - - void initialize(Iterable watchEntries) { - for (String entry : watchEntries) { - Path file = webRoot.resolve(entry).normalize(); - lastKnown.put(file, lastModified(file)); - } - } - - boolean changed() { - for (Map.Entry entry : lastKnown.entrySet()) { - long current = lastModified(entry.getKey()); - if (current != entry.getValue()) { - entry.setValue(current); - return true; - } - } - return false; - } - - private static long lastModified(Path path) { - try { - if (!Files.exists(path)) return -1L; - return Files.getLastModifiedTime(path).toMillis(); - } catch (IOException e) { - return -1L; - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java deleted file mode 100644 index 678323c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerBuild.java +++ /dev/null @@ -1,53 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -/** - * Build-time counterpart to {@link ClasspathAssetsSource}: scans a prebuilt directory (a Vite - * {@code dist/} or a static asset folder) and writes the {@code asset-manifest.json} that - * classpath-based production serving requires. Meant to run from a consumer's build (e.g. via - * exec-maven-plugin's {@code exec:java}), not from the running application — see {@code docs/build-time.md}. - */ -public final class WebBundlerBuild { - private static final ObjectMapper JSON = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); - - private WebBundlerBuild() { - } - - /** Scans {@code distDir} and writes {@code distDir/asset-manifest.json} for classpath serving. */ - public static void generateManifest(Path distDir) { - if (!Files.isDirectory(distDir)) { - throw new IllegalArgumentException("Not a directory: " + distDir); - } - List entries = AssetDirectoryScanner.scan(distDir).stream() - .map(asset -> new ClasspathAssetManifest.Entry( - asset.canonicalPath(), - AssetIo.stripLeadingSlash(asset.canonicalPath()), - asset.mimeType(), - asset.etag(), - asset.immutable())) - .toList(); - if (entries.isEmpty()) { - throw new IllegalStateException("No assets found under " + distDir); - } - try { - JSON.writeValue(distDir.resolve("asset-manifest.json").toFile(), new ClasspathAssetManifest(entries)); - } catch (IOException e) { - throw new IllegalStateException("Failed to write asset-manifest.json in " + distDir, e); - } - } - - public static void main(String[] args) { - if (args.length != 1) { - System.err.println("Usage: java " + WebBundlerBuild.class.getName() + " "); - System.exit(1); - } - generateManifest(Path.of(args[0])); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java deleted file mode 100644 index 73415ef..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerConfig.java +++ /dev/null @@ -1,223 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Objects; - -public final class WebBundlerConfig { - private final RuntimeMode runtimeMode; - private final OperationMode operationMode; - private final FrontendType frontendType; - private final PackageManager packageManager; - private final InstallPolicy installPolicy; - private final LoggingMode loggingMode; - private final CommandSafetyMode commandSafetyMode; - private final BasePathEnforcementMode basePathEnforcement; - private final Path webRoot; - private final String basePath; - private final String devHost; - private final int devPort; - private final AssetsSource assetsSource; - private final String indexFile; - private final List watchList; - private final String devCommand; - private final String buildCommand; - private final String installCommand; - private final List safeCommandRegistry; - private final String envRuntimeVariable; - - private WebBundlerConfig(Builder b) { - this.runtimeMode = b.runtimeMode; - this.operationMode = b.operationMode; - this.frontendType = b.frontendType; - this.packageManager = b.packageManager; - this.installPolicy = b.installPolicy; - this.loggingMode = b.loggingMode; - this.commandSafetyMode = b.commandSafetyMode; - this.basePathEnforcement = b.basePathEnforcement; - this.webRoot = b.webRoot.normalize(); - this.basePath = sanitizeBasePath(b.basePath); - this.devHost = b.devHost; - this.devPort = b.devPort; - this.assetsSource = b.assetsSource; - this.indexFile = b.indexFile; - this.watchList = List.copyOf(b.watchList); - this.devCommand = blankToNull(b.devCommand); - this.buildCommand = blankToNull(b.buildCommand); - this.installCommand = blankToNull(b.installCommand); - this.safeCommandRegistry = List.copyOf(b.safeCommandRegistry); - this.envRuntimeVariable = b.envRuntimeVariable; - validate(); - } - - public static Builder builder() { - return new Builder(); - } - - public RuntimeMode runtimeMode() { return runtimeMode; } - public OperationMode operationMode() { return operationMode; } - public FrontendType frontendType() { return frontendType; } - public PackageManager packageManager() { return packageManager; } - public InstallPolicy installPolicy() { return installPolicy; } - public LoggingMode loggingMode() { return loggingMode; } - public CommandSafetyMode commandSafetyMode() { return commandSafetyMode; } - public BasePathEnforcementMode basePathEnforcement() { return basePathEnforcement; } - public Path webRoot() { return webRoot; } - public String basePath() { return basePath; } - public String devHost() { return devHost; } - public int devPort() { return devPort; } - public AssetsSource assetsSource() { return assetsSource; } - public String indexFile() { return indexFile; } - public List watchList() { return watchList; } - public String devCommand() { return devCommand; } - public String buildCommand() { return buildCommand; } - public String installCommand() { return installCommand; } - public List safeCommandRegistry() { return safeCommandRegistry; } - public String envRuntimeVariable() { return envRuntimeVariable; } - - public Path expectedLockfile() { - return webRoot.resolve(packageManager.lockfileName()); - } - - private void validate() { - Objects.requireNonNull(runtimeMode, "runtimeMode"); - Objects.requireNonNull(operationMode, "operationMode"); - Objects.requireNonNull(frontendType, "frontendType"); - Objects.requireNonNull(packageManager, "packageManager"); - Objects.requireNonNull(installPolicy, "installPolicy"); - Objects.requireNonNull(loggingMode, "loggingMode"); - Objects.requireNonNull(commandSafetyMode, "commandSafetyMode"); - Objects.requireNonNull(basePathEnforcement, "basePathEnforcement"); - Objects.requireNonNull(webRoot, "webRoot"); - Objects.requireNonNull(assetsSource, "assetsSource"); - Objects.requireNonNull(indexFile, "indexFile"); - if (frontendType.requiresOrchestration()) { - if (devPort <= 0 || devPort > 65535) { - throw new IllegalArgumentException("WebBundlerConfig: devPort must be in range 1..65535"); - } - if (watchList.isEmpty()) { - throw new IllegalArgumentException("WebBundlerConfig: watchList must not be empty"); - } - for (String path : watchList) { - if (path == null || path.isBlank()) { - throw new IllegalArgumentException("WebBundlerConfig: watchList contains blank entries"); - } - } - } - if (!basePath.startsWith("/")) { - throw new IllegalArgumentException("WebBundlerConfig: basePath must start with '/'"); - } - } - - private static String sanitizeBasePath(String raw) { - if (raw == null || raw.isBlank()) return "/"; - String normalized = raw.trim(); - if (!normalized.startsWith("/")) normalized = "/" + normalized; - if (normalized.length() > 1 && normalized.endsWith("/")) { - normalized = normalized.substring(0, normalized.length() - 1); - } - return normalized; - } - - private static String blankToNull(String value) { - return value == null || value.isBlank() ? null : value.trim(); - } - - public static final class Builder { - private RuntimeMode runtimeMode = RuntimeMode.AUTODETECT; - private OperationMode operationMode = OperationMode.ORCHESTRATE_ONLY; - private FrontendType frontendType = FrontendType.VITE; - private PackageManager packageManager = PackageManager.NPM; - private InstallPolicy installPolicy = InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED; - private LoggingMode loggingMode = LoggingMode.MERGED; - private CommandSafetyMode commandSafetyMode = CommandSafetyMode.WARN; - private BasePathEnforcementMode basePathEnforcement = BasePathEnforcementMode.WARN_ONLY; - private Path webRoot = Path.of("web"); - private String basePath = "/"; - private String devHost = "127.0.0.1"; - private int devPort = 5173; - private AssetsSource assetsSource = defaultAssetsSource(FrontendType.VITE); - private String indexFile = "index.html"; - private List watchList = defaultWatchList(PackageManager.NPM); - private String devCommand; - private String buildCommand; - private String installCommand; - private List safeCommandRegistry = defaultSafeRegistry(); - private String envRuntimeVariable = "FLASH_WEB_BUNDLER_MODE"; - - public Builder runtimeMode(RuntimeMode runtimeMode) { this.runtimeMode = runtimeMode; return this; } - public Builder operationMode(OperationMode operationMode) { this.operationMode = operationMode; return this; } - public Builder frontendType(FrontendType frontendType) { - this.frontendType = frontendType; - this.assetsSource = defaultAssetsSource(frontendType); - this.operationMode = frontendType.requiresOrchestration() ? OperationMode.ORCHESTRATE_ONLY : OperationMode.MANAGED; - return this; - } - public Builder packageManager(PackageManager packageManager) { - this.packageManager = packageManager; - this.watchList = defaultWatchList(packageManager); - return this; - } - public Builder installPolicy(InstallPolicy installPolicy) { this.installPolicy = installPolicy; return this; } - public Builder loggingMode(LoggingMode loggingMode) { this.loggingMode = loggingMode; return this; } - public Builder commandSafetyMode(CommandSafetyMode commandSafetyMode) { this.commandSafetyMode = commandSafetyMode; return this; } - public Builder basePathEnforcement(BasePathEnforcementMode basePathEnforcement) { this.basePathEnforcement = basePathEnforcement; return this; } - public Builder webRoot(Path webRoot) { this.webRoot = webRoot; return this; } - public Builder basePath(String basePath) { this.basePath = basePath; return this; } - public Builder devHost(String devHost) { this.devHost = devHost; return this; } - public Builder devPort(int devPort) { this.devPort = devPort; return this; } - public Builder assetsSource(AssetsSource assetsSource) { this.assetsSource = assetsSource; return this; } - public Builder assetsFromFilesystem(Path distDir) { - this.assetsSource = FilesystemAssetsSource.of(distDir); - return this; - } - public Builder assetsFromClasspath(String rootPrefix) { - this.assetsSource = ClasspathAssetsSource.of(rootPrefix); - return this; - } - public Builder assetsFromClasspath(String rootPrefix, String manifestResource) { - this.assetsSource = ClasspathAssetsSource.of(rootPrefix, manifestResource); - return this; - } - public Builder indexFile(String indexFile) { this.indexFile = indexFile; return this; } - public Builder watchList(List watchList) { this.watchList = new ArrayList<>(watchList); return this; } - public Builder devCommand(String devCommand) { this.devCommand = devCommand; return this; } - public Builder buildCommand(String buildCommand) { this.buildCommand = buildCommand; return this; } - public Builder installCommand(String installCommand) { this.installCommand = installCommand; return this; } - public Builder safeCommandRegistry(List safeCommandRegistry) { - this.safeCommandRegistry = new ArrayList<>(safeCommandRegistry); - return this; - } - public Builder envRuntimeVariable(String envRuntimeVariable) { this.envRuntimeVariable = envRuntimeVariable; return this; } - - public WebBundlerConfig build() { - return new WebBundlerConfig(this); - } - - private static AssetsSource defaultAssetsSource(FrontendType type) { - return type == FrontendType.STATIC - ? FilesystemAssetsSource.of(Path.of(".")) - : FilesystemAssetsSource.of(Path.of("dist")); - } - - private static List defaultWatchList(PackageManager manager) { - return List.of( - "package.json", - manager.lockfileName(), - "vite.config.ts", - "vite.config.js", - "tsconfig.json", - ".env", - ".env.local" - ); - } - - private static List defaultSafeRegistry() { - return List.of( - "npm", "pnpm", "yarn", "bun", "npx", - "npm.cmd", "pnpm.cmd", "yarn.cmd", "bun.cmd", "npx.cmd" - ); - } - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java deleted file mode 100644 index 3fc1c7c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerExtension.java +++ /dev/null @@ -1,156 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import dev.relism.flash.extension.FlashContext; -import dev.relism.flash.extension.FlashRegistrar; -import dev.relism.flash.extension.FlashExtension; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; - -public final class WebBundlerExtension implements FlashExtension { - private static final Logger log = LoggerFactory.getLogger(WebBundlerExtension.class); - - private final WebBundlerConfig config; - private final ModeResolver modeResolver = new ModeResolver(); - private final FrontendTypeResolver frontendTypeResolver = new FrontendTypeResolver(); - private ScheduledExecutorService watchLoop; - private Thread shutdownHook; - - public WebBundlerExtension() { - this(WebBundlerConfig.builder().build()); - } - - public WebBundlerExtension(WebBundlerConfig config) { - this.config = config; - } - - @Override - public void configure(FlashRegistrar app, FlashContext ctx) { - RuntimeEnvironment environment = modeResolver.resolve(config); - PackageManagerAdapter pmAdapter = new PackageManagerAdapter(config); - FrontendStrategy strategy = frontendTypeResolver.resolve(config.frontendType()); - CommandSafetyPolicy safetyPolicy = new CommandSafetyPolicy(config); - CommandOrchestrator orchestrator = new CommandOrchestrator(config, safetyPolicy); - WatchList watchList = new WatchList(config.webRoot()); - watchList.initialize(config.watchList()); - - StaticAssetServingPolicy servingPolicy = null; - SpaFallbackPolicy fallbackPolicy = null; - - try { - if (environment == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration()) { - bootstrapDev(strategy, pmAdapter, orchestrator, watchList); - } else { - AssetCatalog catalog = config.assetsSource().load(new AssetLoadRequest( - config.basePath(), - config.indexFile(), - environment, - config.webRoot() - )); - servingPolicy = new StaticAssetServingPolicy(catalog); - fallbackPolicy = new SpaFallbackPolicy(catalog); - log.info("flash-web-bundler loaded {} assets for production serving", catalog.size()); - } - - WebBundlerRuntime runtime = new WebBundlerRuntime( - environment, servingPolicy, fallbackPolicy, orchestrator, watchList - ); - registerShutdownHook(runtime); - ctx.provide(WebBundlerRuntime.class, runtime); - } catch (RuntimeException ex) { - shutdownResources(orchestrator); - throw ex; - } - ctx.onReady(() -> { - WebBundlerRuntime runtime = ctx.require(WebBundlerRuntime.class); - boolean orchestrated = runtime.environment() == RuntimeEnvironment.DEV && config.frontendType().requiresOrchestration(); - if (orchestrated || config.operationMode() == OperationMode.ORCHESTRATE_ONLY) { - return; - } - - String basePath = config.basePath(); - String wildcard = "/".equals(basePath) ? "/**" : basePath + "/**"; - - app.get(wildcard, (req, res) -> { - if (runtime.servingPolicy().serve(req, res)) return null; - runtime.fallbackPolicy().apply(res); - return null; - }); - app.head(wildcard, (req, res) -> { - if (runtime.servingPolicy().serve(req, res)) { - res.body(new byte[0]); - return null; - } - runtime.fallbackPolicy().apply(res); - res.body(new byte[0]); - return null; - }); - }); - } - - private void bootstrapDev( - FrontendStrategy strategy, - PackageManagerAdapter pmAdapter, - CommandOrchestrator orchestrator, - WatchList watchList - ) { - validateDevAssetsSource(config); - InstallCache installCache = new InstallCache(config.webRoot()); - Path lock = config.expectedLockfile(); - if (config.installPolicy() == InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED && installCache.lockChanged(lock)) { - orchestrator.runBlocking(pmAdapter.installCommand(), config.webRoot()); - installCache.writeLockHash(lock); - } - - List devCommand = strategy.devCommand(config, pmAdapter); - orchestrator.startDevServer(devCommand, config.webRoot()); - orchestrator.healthcheck(strategy.healthcheckPath(), Duration.ofSeconds(25)); - startWatchLoop(devCommand, orchestrator, watchList); - log.info("flash-web-bundler started dev server at http://{}:{}", config.devHost(), config.devPort()); - } - - static void validateDevAssetsSource(WebBundlerConfig config) { - if (config.assetsSource() instanceof ClasspathAssetsSource) { - throw new IllegalStateException("Classpath assets source is not supported in DEV mode. Use assetsFromFilesystem(...) for DEV."); - } - } - - private void startWatchLoop(List devCommand, CommandOrchestrator orchestrator, WatchList watchList) { - watchLoop = Executors.newSingleThreadScheduledExecutor(daemonThreadFactory("flash-web-bundler-watch")); - watchLoop.scheduleWithFixedDelay(() -> { - if (watchList.changed()) { - log.warn("Watchlist changed. Restarting frontend dev server."); - orchestrator.restartDevServer(devCommand, config.webRoot()); - } - }, 1500L, 1500L, TimeUnit.MILLISECONDS); - } - - private void registerShutdownHook(WebBundlerRuntime runtime) { - shutdownHook = new Thread(() -> shutdownResources(runtime.orchestrator()), "flash-web-bundler-shutdown"); - shutdownHook.setDaemon(true); - Runtime.getRuntime().addShutdownHook(shutdownHook); - } - - private void shutdownResources(CommandOrchestrator orchestrator) { - if (watchLoop != null) { - watchLoop.shutdownNow(); - watchLoop = null; - } - orchestrator.close(); - } - - private static ThreadFactory daemonThreadFactory(String namePrefix) { - return runnable -> { - Thread thread = new Thread(runnable, namePrefix); - thread.setDaemon(true); - return thread; - }; - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java b/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java deleted file mode 100644 index b825de2..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/main/java/dev/relism/flash/ext/webbundler/WebBundlerRuntime.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -final class WebBundlerRuntime { - private final RuntimeEnvironment environment; - private final StaticAssetServingPolicy servingPolicy; - private final SpaFallbackPolicy fallbackPolicy; - private final CommandOrchestrator orchestrator; - private final WatchList watchList; - - WebBundlerRuntime( - RuntimeEnvironment environment, - StaticAssetServingPolicy servingPolicy, - SpaFallbackPolicy fallbackPolicy, - CommandOrchestrator orchestrator, - WatchList watchList - ) { - this.environment = environment; - this.servingPolicy = servingPolicy; - this.fallbackPolicy = fallbackPolicy; - this.orchestrator = orchestrator; - this.watchList = watchList; - } - - RuntimeEnvironment environment() { return environment; } - StaticAssetServingPolicy servingPolicy() { return servingPolicy; } - SpaFallbackPolicy fallbackPolicy() { return fallbackPolicy; } - CommandOrchestrator orchestrator() { return orchestrator; } - WatchList watchList() { return watchList; } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java deleted file mode 100644 index 9ca7522..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/AssetsSourceTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.*; - -class AssetsSourceTest { - @TempDir - Path tempDir; - - @Test - void filesystemSource_loadsAssetsAndIndex() throws Exception { - Path webRoot = tempDir.resolve("web"); - Path dist = webRoot.resolve("dist"); - Files.createDirectories(dist); - Files.writeString(dist.resolve("index.html"), "ok"); - Files.writeString(dist.resolve("app.abcd1234.js"), "console.log(1)"); - - AssetsSource source = FilesystemAssetsSource.of(Path.of("dist")); - AssetCatalog catalog = source.load(new AssetLoadRequest("/app", "index.html", RuntimeEnvironment.PROD, webRoot)); - - assertNotNull(catalog.index()); - AssetEntry js = catalog.find("/app/app.abcd1234.js"); - assertNotNull(js); - assertTrue(js.immutable()); - } - - @Test - void classpathSource_missingManifestFailsFast() { - AssetsSource source = ClasspathAssetsSource.of("missing-root"); - IllegalStateException ex = assertThrows(IllegalStateException.class, - () -> source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of(".")))); - assertTrue(ex.getMessage().contains("manifest")); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java deleted file mode 100644 index 4d7d9a6..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/CommandSafetyPolicyTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertThrows; - -class CommandSafetyPolicyTest { - @Test - void block_mode_rejectsUnknownBinary() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .commandSafetyMode(CommandSafetyMode.BLOCK) - .safeCommandRegistry(List.of("npm")) - .build(); - CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg); - assertThrows(IllegalStateException.class, () -> policy.check(List.of("sh", "-c", "echo nope"))); - } - - @Test - void allow_mode_permitsUnknownBinary() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .commandSafetyMode(CommandSafetyMode.ALLOW) - .safeCommandRegistry(List.of("npm")) - .build(); - CommandSafetyPolicy policy = new CommandSafetyPolicy(cfg); - assertDoesNotThrow(() -> policy.check(List.of("custom", "run"))); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java deleted file mode 100644 index 24f8e53..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/InstallCacheTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class InstallCacheTest { - @TempDir - Path tempDir; - - @Test - void lockHashChanges_areDetected() throws Exception { - Path lockfile = tempDir.resolve("package-lock.json"); - Files.writeString(lockfile, "{\"a\":1}"); - - InstallCache cache = new InstallCache(tempDir); - assertTrue(cache.lockChanged(lockfile)); - cache.writeLockHash(lockfile); - assertFalse(cache.lockChanged(lockfile)); - - Files.writeString(lockfile, "{\"a\":2}"); - assertTrue(cache.lockChanged(lockfile)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java deleted file mode 100644 index 7b8e248..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/ModeResolverTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class ModeResolverTest { - private final ModeResolver resolver = new ModeResolver(); - - @Test - void prod_mode_isAlwaysProd() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .runtimeMode(RuntimeMode.PROD) - .build(); - assertEquals(RuntimeEnvironment.PROD, resolver.resolve(cfg)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java deleted file mode 100644 index 3cbe93b..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/PackageManagerAdapterTest.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class PackageManagerAdapterTest { - @Test - void pnpm_hasFrozenInstallCommand() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .packageManager(PackageManager.PNPM) - .build(); - PackageManagerAdapter adapter = new PackageManagerAdapter(cfg); - assertTrue(adapter.installCommand().get(0).startsWith("pnpm")); - assertEquals("--frozen-lockfile", adapter.installCommand().get(2)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java deleted file mode 100644 index a8ffe8a..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerBuildTest.java +++ /dev/null @@ -1,68 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; - -import static org.junit.jupiter.api.Assertions.*; - -class WebBundlerBuildTest { - private Path root; - - @AfterEach - void tearDown() throws IOException { - if (root == null || !Files.exists(root)) return; - try (var walk = Files.walk(root)) { - walk.sorted(Comparator.reverseOrder()).forEach(p -> { - try { - Files.delete(p); - } catch (IOException ignored) { - } - }); - } - } - - /** - * {@link ClasspathAssetsSource} resolves manifest + resources via the classloader, so the - * scanned directory needs to actually be on the test classpath — using the directory the test - * class itself was loaded from (Maven: {@code target/test-classes}) keeps this portable across - * runners instead of hardcoding a build-tool-specific path. - */ - @Test - void generateManifest_isConsumableByClasspathAssetsSource() throws Exception { - Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - root = testClasses.resolve("web-bundler-build-test-" + System.nanoTime()); - Files.createDirectories(root); - Files.writeString(root.resolve("index.html"), "built"); - Files.writeString(root.resolve("app.a1b2c3d4.js"), "console.log('built')"); - - WebBundlerBuild.generateManifest(root); - assertTrue(Files.exists(root.resolve("asset-manifest.json"))); - - String rootPrefix = testClasses.relativize(root).toString().replace('\\', '/'); - ClasspathAssetsSource source = ClasspathAssetsSource.of(rootPrefix); - AssetCatalog catalog = source.load(new AssetLoadRequest("/", "index.html", RuntimeEnvironment.PROD, Path.of("."))); - - assertNotNull(catalog.index()); - assertTrue(new String(catalog.index().raw()).contains("built")); - - AssetEntry js = catalog.find("/app.a1b2c3d4.js"); - assertNotNull(js); - assertTrue(js.immutable()); - assertEquals("text/javascript", js.mimeType()); - assertFalse(catalog.index().immutable()); - } - - @Test - void generateManifest_emptyDirectory_throws() throws Exception { - Path testClasses = Path.of(WebBundlerBuildTest.class.getProtectionDomain().getCodeSource().getLocation().toURI()); - root = testClasses.resolve("web-bundler-build-empty-" + System.nanoTime()); - Files.createDirectories(root); - - assertThrows(IllegalStateException.class, () -> WebBundlerBuild.generateManifest(root)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java deleted file mode 100644 index 1e3542c..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerConfigTest.java +++ /dev/null @@ -1,76 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; - -import java.nio.file.Path; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class WebBundlerConfigTest { - @Test - void defaults_areSane() { - WebBundlerConfig cfg = WebBundlerConfig.builder().build(); - assertEquals(RuntimeMode.AUTODETECT, cfg.runtimeMode()); - assertEquals(OperationMode.ORCHESTRATE_ONLY, cfg.operationMode()); - assertEquals(InstallPolicy.AUTO_IF_LOCK_HASH_CHANGED, cfg.installPolicy()); - assertEquals(LoggingMode.MERGED, cfg.loggingMode()); - assertEquals(CommandSafetyMode.WARN, cfg.commandSafetyMode()); - } - - @Test - void packageManager_setsLockfileInWatchList() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .packageManager(PackageManager.PNPM) - .build(); - assertTrue(cfg.watchList().contains("pnpm-lock.yaml")); - } - - @Test - void basePath_isNormalized() { - WebBundlerConfig cfg = WebBundlerConfig.builder().basePath("app/").build(); - assertEquals("/app", cfg.basePath()); - } - - @Test - void invalidPort_throws() { - assertThrows(IllegalArgumentException.class, () -> - WebBundlerConfig.builder().devPort(0).build()); - } - - @Test - void assetsFromFilesystem_setsFilesystemSource() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .webRoot(Path.of("frontend")) - .assetsFromFilesystem(Path.of("build")) - .watchList(List.of("package.json")) - .build(); - assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource); - } - - @Test - void assetsFromClasspath_setsClasspathSource() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .assetsFromClasspath("web/dist") - .build(); - assertTrue(cfg.assetsSource() instanceof ClasspathAssetsSource); - } - - @Test - void staticFrontend_defaultsToManagedAndFilesystemSource() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .frontendType(FrontendType.STATIC) - .build(); - assertEquals(OperationMode.MANAGED, cfg.operationMode()); - assertTrue(cfg.assetsSource() instanceof FilesystemAssetsSource); - } - - @Test - void staticFrontend_skipsOrchestrationValidation() { - assertDoesNotThrow(() -> WebBundlerConfig.builder() - .frontendType(FrontendType.STATIC) - .devPort(0) - .watchList(List.of()) - .build()); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java deleted file mode 100644 index 45e0c2a..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionDevGuardTest.java +++ /dev/null @@ -1,16 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertThrows; - -class WebBundlerExtensionDevGuardTest { - @Test - void classpathSourceInDev_isRejected() { - WebBundlerConfig cfg = WebBundlerConfig.builder() - .assetsFromClasspath("web/dist") - .build(); - - assertThrows(IllegalStateException.class, () -> WebBundlerExtension.validateDevAssetsSource(cfg)); - } -} diff --git a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java b/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java deleted file mode 100644 index 6060fa4..0000000 --- a/flash-extensions/flash-ext-web-bundler/src/test/java/dev/relism/flash/ext/webbundler/WebBundlerExtensionIntegrationTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package dev.relism.flash.ext.webbundler; - -import dev.relism.flash.testing.FlashTest; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; - -/** - * Two frontend layouts served by a real server. Each is laid out on disk inside the - * application's own configure(), which the harness runs lazily at first access — by then - * {@link TempDir} is populated, and a server whose test never runs is never booted. - */ -class WebBundlerExtensionIntegrationTest { - - @TempDir - static Path tempDir; - - @RegisterExtension - static FlashTest managed = FlashTest.of(app -> { - Path webRoot = write(tempDir.resolve("web").resolve("dist"), - "index.html", "spa", - "app.js", "console.log('ok');").getParent(); - - app.install(new WebBundlerExtension(WebBundlerConfig.builder() - .runtimeMode(RuntimeMode.PROD) - .operationMode(OperationMode.MANAGED) - .webRoot(webRoot) - .assetsFromFilesystem(Path.of("dist")) - .basePath("/app") - .build())); - app.get("/api/ping", (req, res) -> "pong"); - }); - - // PROD is deterministic in a test JVM (Flash.DEV depends on env/system-property detection - // that can't be forced per-test); STATIC's actual guarantee — that it never orchestrates, - // in DEV or PROD — is enforced structurally by the same requiresOrchestration() gate in - // both WebBundlerExtension.provide() and .routes(), not by this test. - @RegisterExtension - static FlashTest staticFrontend = FlashTest.of(app -> { - Path webRoot = write(tempDir.resolve("public"), - "index.html", "static", - "style.css", "body{color:red}"); - - app.install(new WebBundlerExtension(WebBundlerConfig.builder() - .runtimeMode(RuntimeMode.PROD) - .frontendType(FrontendType.STATIC) - .webRoot(webRoot) - .build())); - app.get("/api/ping", (req, res) -> "pong"); - }); - - @Test - void prodMode_servesAssetsAndFallback_withoutBreakingBackendRoutes() { - managed.get("/api/ping").expectStatus(200).expectBody("pong"); - managed.get("/app/app.js").expectStatus(200).expectBodyContains("console.log"); - managed.get("/app/some/client/route").expectStatus(200).expectBodyContains("spa"); - } - - @Test - void staticFrontend_servesAssetsWithoutOrchestration() { - staticFrontend.get("/api/ping").expectStatus(200).expectBody("pong"); - staticFrontend.get("/style.css").expectStatus(200).expectBodyContains("color:red"); - } - - /** Creates {@code directory} and writes the given name/content pairs into it. */ - private static Path write(Path directory, String... nameThenContent) { - try { - Files.createDirectories(directory); - for (int i = 0; i < nameThenContent.length; i += 2) - Files.writeString(directory.resolve(nameThenContent[i]), nameThenContent[i + 1]); - return directory; - } catch (IOException failure) { - throw new UncheckedIOException(failure); - } - } -} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index bbbf68b..986162c 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -27,7 +27,8 @@ flash-ext-view-jte flash-ext-view-thymeleaf flash-ext-limiter - flash-ext-web-bundler + flash-ext-vite + flash-ext-vite-maven-plugin flash-ext-mcp flash-ext-validation flash-ext-scheduler @@ -119,7 +120,7 @@
dev.relism - flash-ext-web-bundler + flash-ext-vite ${project.version} diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index d7597dd..4b45d25 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -181,6 +181,12 @@ public class Request { */ public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); } + /** + * The first value of header {@code name} as a view over the request's own bytes, or {@code null}: + * {@link #header} without the {@code String}, for a hot path. Valid only while the handler runs. + */ + public ByteView headerView(String name) { checkActive(); return requestLine.getHeaders().view(name); } + /** * Returns all values of header {@code name} in declaration order. * Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}). diff --git a/flash/src/main/java/dev/relism/flash/models/Response.java b/flash/src/main/java/dev/relism/flash/models/Response.java index 246c1bd..a49d299 100644 --- a/flash/src/main/java/dev/relism/flash/models/Response.java +++ b/flash/src/main/java/dev/relism/flash/models/Response.java @@ -171,6 +171,13 @@ public class Response { return this; } + /** A content type encoded once, typically at boot: the array is kept, not copied, so it must not change. */ + public Response type(byte[] ct) { + checkActive(); + this.contentType = ct; + return this; + } + public Response type(String ct) { checkActive(); this.contentType = ct.getBytes(StandardCharsets.UTF_8); diff --git a/pom.xml b/pom.xml index d5546cc..e06dad3 100644 --- a/pom.xml +++ b/pom.xml @@ -136,7 +136,7 @@ dev.relism - flash-ext-web-bundler + flash-ext-vite ${project.version}