From c2e3f98ea282cffd646da60339fba05da23ef931 Mon Sep 17 00:00:00 2001 From: Relism Date: Sun, 26 Apr 2026 12:27:59 +0200 Subject: [PATCH] add static asset serving support with configurable options --- .../src/main/resources/routeviewer/index.html | 22 +- flash-extensions/flash-ext-view-jte/README.md | 53 +++++ .../flash-ext-view-jte/docs/architecture.md | 10 + .../ondemand/pages/JtehomeGenerated.class | Bin 2108 -> 2113 bytes .../ondemand/pages/JtehomeGenerated.java | 10 +- .../dev/relism/ext/view/jte/JteExtension.java | 87 +++++++- .../dev/relism/ext/view/jte/JteSettings.java | 125 +++++++++++ .../relism/ext/view/jte/JteStaticServing.java | 205 ++++++++++++++++++ .../relism/ext/view/jte/JteExtensionTest.java | 106 +++++++++ .../relism/ext/view/jte/JteSettingsTest.java | 36 +++ .../ext/view/jte/JteStaticServingTest.java | 50 +++++ .../src/test/resources/static/sample.css | 1 + .../src/test/resources/static/sample.css.gz | 1 + .../dev/relism/extension/FlashContext.java | 2 +- .../main/java/dev/relism/http/HttpStatus.java | 8 +- 15 files changed, 697 insertions(+), 19 deletions(-) create mode 100644 flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteStaticServing.java create mode 100644 flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteStaticServingTest.java create mode 100644 flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css create mode 100644 flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css.gz diff --git a/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html b/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html index bab0ef8..720f499 100644 --- a/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html +++ b/flash-extensions/flash-ext-routeviewer/src/main/resources/routeviewer/index.html @@ -1,13 +1,13 @@ - - - - - - Flash Route Viewer + + + + + + Flash Route Viewer - - -
- - + + +
+ + diff --git a/flash-extensions/flash-ext-view-jte/README.md b/flash-extensions/flash-ext-view-jte/README.md index 910ec10..e4b9972 100644 --- a/flash-extensions/flash-ext-view-jte/README.md +++ b/flash-extensions/flash-ext-view-jte/README.md @@ -15,6 +15,59 @@ The extension mirrors `gg.jte.ContentType` into Flash HTTP content type: - `gg.jte.ContentType.Html` -> `text/html` - `gg.jte.ContentType.Plain` -> `text/plain` +## Static serving (opinionated) + +Static serving is enabled by default and serves classpath assets from `/static/**` +(`src/main/resources/static/**`) on an HTTP wildcard route. + +Defaults: + +- `serveStatics = true` +- `staticPrefix = "/static"` +- ETag via CRC32 + length +- `304 Not Modified` on `If-None-Match` +- pre-compressed `.gz` support when client sends `Accept-Encoding: gzip` +- MIME auto-resolution for common web/media/font/wasm types +- cache policy: + - versioned assets (`file..ext` or query `?v=...` / `?hash=...`) -> `public, max-age=31536000, immutable` + - non-versioned -> `no-cache` +- byte-range support for large files (threshold configurable, default 64 KiB) + +If classpath `/static` is missing, extension logs a warning and disables static routes. + +### Configuration examples + +```java +// option 1: customizer constructor +app.install(new JteExtension(cfg -> cfg + .templateRoot("/templates") + .serveStatics(true) + .staticPrefix("/assets"))); + +// option 2: fluent style +app.install(new JteExtension() + .templateRoot("/templates") + .withStaticServing() + .staticPrefix("/assets")); +``` + +Disable static serving: + +```java +app.install(new JteExtension().serveStatics(false)); +``` + +Enable static CORS (default disabled): + +```java +app.install(new JteExtension().staticCors(cfg -> cfg + .enableStaticCors(true) + .staticCorsAllowOrigin("*") + .staticCorsAllowMethods("GET,HEAD,OPTIONS") + .staticCorsAllowHeaders("*") + .staticCorsMaxAge(3600))); +``` + ## Quick Start ```java diff --git a/flash-extensions/flash-ext-view-jte/docs/architecture.md b/flash-extensions/flash-ext-view-jte/docs/architecture.md index 3f5c766..5acd87f 100644 --- a/flash-extensions/flash-ext-view-jte/docs/architecture.md +++ b/flash-extensions/flash-ext-view-jte/docs/architecture.md @@ -12,6 +12,12 @@ - Runtime injects globals under reserved `global` namespace and merges local model. - jte renders template into `StringOutput`. +3. **Static assets (optional, enabled by default)** + - Extension mounts wildcard routes under `staticPrefix` (default `/static/**`). + - Assets are served from classpath `/static/**`. + - Uses Flash `Response.body(...)` for small payloads and `Response.stream(...)` for large payloads. + - Supports ETag/304, gzip precompressed assets, MIME auto-resolution, and byte ranges. + ## Handler Contract - Must extend `JteHandler`. @@ -27,3 +33,7 @@ Invalid configurations fail fast at startup. - `developmentMode`: `Flash.DEV` - `usePrecompiled`: derived from `!developmentMode` unless explicitly set - `binaryStaticContent`: `false` +- `serveStatics`: `true` +- `staticPrefix`: `/static` +- `largeFileThresholdBytes`: `65536` +- static CORS: disabled (`false`) diff --git a/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.class index 7ef77154eb76337a852585cf8c7e0a19af45eacc..64f788b358a3558fce92ce5022a8e5105c6c8d88 100644 GIT binary patch delta 86 zcmdlZa8O`^9TOYxMtdnnb}k$J3`0BK$?=TJ8oW081$Mk#HpRJ#IXQM53>u6KtY9_= cgBBx$pp8C6f_L%?#u_f5Mksf)E)zc+0D^uL?f?J) delta 81 zcmX>out#8m9V6>T2PsB2P8}Hu?p2TsFnIi8(oT91I$a46I-_2ZI(P YgMf`bL}Kz9#u`qbE(mY45feWf07T9duK)l5 diff --git a/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java index fc74c2b..79187cf 100644 --- a/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java +++ b/flash-extensions/flash-ext-view-jte/jte-classes/gg/jte/generated/ondemand/pages/JtehomeGenerated.java @@ -6,19 +6,19 @@ public final class JtehomeGenerated { public static final String JTE_NAME = "pages/home.jte"; public static final int[] JTE_LINE_INFO = {0,0,1,2,2,2,2,6,6,6,6,7,7,7,8,8,8,9,9,9,10,10,10,2,3,4,4,4,4}; public static void render(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, HomePage page, String build, Map global) { - jteOutput.writeContent("\n

"); + jteOutput.writeContent("\r\n

"); jteOutput.setContext("h1", null); jteOutput.writeUserContent(page.title()); - jteOutput.writeContent("

\n

"); + jteOutput.writeContent("\r\n

"); jteOutput.setContext("p", null); jteOutput.writeUserContent(page.author()); - jteOutput.writeContent("

\n"); + jteOutput.writeContent("

\r\n"); jteOutput.setContext("small", null); jteOutput.writeUserContent(build); - jteOutput.writeContent("\n"); + jteOutput.writeContent("\r\n"); jteOutput.setContext("small", null); jteOutput.writeUserContent((String) global.get("appName")); - jteOutput.writeContent("\n"); + jteOutput.writeContent("
\r\n"); } public static void renderMap(gg.jte.html.HtmlTemplateOutput jteOutput, gg.jte.html.HtmlInterceptor jteHtmlInterceptor, java.util.Map params) { HomePage page = (HomePage)params.get("page"); diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java index d60b6e9..1e3410e 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteExtension.java @@ -3,8 +3,11 @@ package dev.relism.ext.view.jte; import dev.relism.ext.view.core.BaseViewExtension; import dev.relism.ext.view.core.GlobalValue; import dev.relism.ext.view.core.ViewRuntimeBridge; +import dev.relism.extension.FlashContext; +import dev.relism.extension.FlashRegistrar; import dev.relism.models.Request; import dev.relism.models.RequestHandler; +import dev.relism.routing.Middleware; import java.util.List; import java.util.function.Consumer; @@ -14,7 +17,7 @@ import java.util.function.Function; * Opinionated jte SSR extension for Flash. */ public final class JteExtension extends BaseViewExtension { - private final JteSettings settings; + private JteSettings settings; static { ensureJtePresent(); @@ -34,6 +37,38 @@ public final class JteExtension extends BaseViewExtension { this.settings = settings; } + public JteExtension templateRoot(String templateRoot) { + settings = settings.toBuilder().templateRoot(templateRoot).build(); + return this; + } + + public JteExtension serveStatics(boolean enabled) { + settings = settings.toBuilder().serveStatics(enabled).build(); + return this; + } + + public JteExtension withStaticServing() { + return serveStatics(true); + } + + public JteExtension staticPrefix(String staticPrefix) { + settings = settings.toBuilder().staticPrefix(staticPrefix).build(); + return this; + } + + public JteExtension staticCors(Consumer customizer) { + java.util.Objects.requireNonNull(customizer, "customizer must not be null"); + JteSettings.Builder b = settings.toBuilder(); + customizer.accept(b); + settings = b.build(); + return this; + } + + public JteExtension withStaticCors() { + settings = settings.toBuilder().enableStaticCors(true).build(); + return this; + } + @Override public JteExtension addGlobal(String key, Function resolver) { super.addGlobal(key, resolver); @@ -60,6 +95,56 @@ public final class JteExtension extends BaseViewExtension { } } + @Override + public void routes(FlashRegistrar app, FlashContext ctx) { + if (!settings.serveStatics()) return; + + JteStaticServing staticServing = JteStaticServing.load(settings); + if (staticServing == null) return; + + String prefix = settings.staticPrefix(); + String wildcard = "/".equals(prefix) ? "/**" : prefix + "/**"; + Middleware cors = staticCorsMiddleware(); + + if (settings.staticCorsEnabled()) { + app.options(wildcard, (req, res) -> { + applyCors(req, res); + res.status(204); + return null; + }); + } + + if (cors != null) { + app.get(wildcard, (req, res) -> staticServing.serve(req, res, false) ? null : notFound(res), cors); + app.head(wildcard, (req, res) -> staticServing.serve(req, res, true) ? null : notFound(res), cors); + } else { + app.get(wildcard, (req, res) -> staticServing.serve(req, res, false) ? null : notFound(res)); + app.head(wildcard, (req, res) -> staticServing.serve(req, res, true) ? null : notFound(res)); + } + } + + private Middleware staticCorsMiddleware() { + if (!settings.staticCorsEnabled()) return null; + return next -> (req, res) -> { + applyCors(req, res); + return next.handle(req, res); + }; + } + + private Object notFound(dev.relism.models.Response res) { + res.status(404); + return null; + } + + private void applyCors(dev.relism.models.Request req, dev.relism.models.Response res) { + String origin = req.header("Origin"); + if (origin == null || origin.isBlank()) return; + res.header("Access-Control-Allow-Origin", settings.staticCorsAllowOrigin()); + res.header("Access-Control-Allow-Methods", settings.staticCorsAllowMethods()); + res.header("Access-Control-Allow-Headers", settings.staticCorsAllowHeaders()); + res.header("Access-Control-Max-Age", Integer.toString(settings.staticCorsMaxAge())); + } + private static void ensureJtePresent() { try { Class.forName("gg.jte.TemplateEngine", false, JteExtension.class.getClassLoader()); diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java index f0b8365..9b8c7e3 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteSettings.java @@ -8,6 +8,8 @@ import java.nio.file.Path; * jte runtime settings with Flash-sensitive defaults. */ public final class JteSettings { + private static final long DEFAULT_LARGE_FILE_THRESHOLD_BYTES = 64L * 1024L; + private final String templateRoot; private final gg.jte.ContentType contentType; private final boolean developmentMode; @@ -15,6 +17,14 @@ public final class JteSettings { private final boolean binaryStaticContent; private final Path dynamicClassesPath; private final Path precompiledClassesPath; + private final boolean serveStatics; + private final String staticPrefix; + private final long largeFileThresholdBytes; + private final boolean staticCorsEnabled; + private final String staticCorsAllowOrigin; + private final String staticCorsAllowMethods; + private final String staticCorsAllowHeaders; + private final int staticCorsMaxAge; private JteSettings(Builder b) { this.templateRoot = b.templateRoot; @@ -24,6 +34,14 @@ public final class JteSettings { this.binaryStaticContent = b.binaryStaticContent; this.dynamicClassesPath = b.dynamicClassesPath; this.precompiledClassesPath = b.precompiledClassesPath; + this.serveStatics = b.serveStatics; + this.staticPrefix = b.staticPrefix; + this.largeFileThresholdBytes = b.largeFileThresholdBytes; + this.staticCorsEnabled = b.staticCorsEnabled; + this.staticCorsAllowOrigin = b.staticCorsAllowOrigin; + this.staticCorsAllowMethods = b.staticCorsAllowMethods; + this.staticCorsAllowHeaders = b.staticCorsAllowHeaders; + this.staticCorsMaxAge = b.staticCorsMaxAge; } public static Builder builder() { @@ -37,6 +55,34 @@ public final class JteSettings { boolean binaryStaticContent() { return binaryStaticContent; } Path dynamicClassesPath() { return dynamicClassesPath; } Path precompiledClassesPath() { return precompiledClassesPath; } + boolean serveStatics() { return serveStatics; } + String staticPrefix() { return staticPrefix; } + long largeFileThresholdBytes() { return largeFileThresholdBytes; } + boolean staticCorsEnabled() { return staticCorsEnabled; } + String staticCorsAllowOrigin() { return staticCorsAllowOrigin; } + String staticCorsAllowMethods() { return staticCorsAllowMethods; } + String staticCorsAllowHeaders() { return staticCorsAllowHeaders; } + int staticCorsMaxAge() { return staticCorsMaxAge; } + + Builder toBuilder() { + Builder b = new Builder(); + b.templateRoot = templateRoot; + b.contentType = contentType; + b.developmentMode = developmentMode; + b.usePrecompiled = usePrecompiled; + b.binaryStaticContent = binaryStaticContent; + b.dynamicClassesPath = dynamicClassesPath; + b.precompiledClassesPath = precompiledClassesPath; + b.serveStatics = serveStatics; + b.staticPrefix = staticPrefix; + b.largeFileThresholdBytes = largeFileThresholdBytes; + b.staticCorsEnabled = staticCorsEnabled; + b.staticCorsAllowOrigin = staticCorsAllowOrigin; + b.staticCorsAllowMethods = staticCorsAllowMethods; + b.staticCorsAllowHeaders = staticCorsAllowHeaders; + b.staticCorsMaxAge = staticCorsMaxAge; + return b; + } public static final class Builder { private String templateRoot = "/templates"; @@ -46,6 +92,14 @@ public final class JteSettings { private boolean binaryStaticContent; private Path dynamicClassesPath = Path.of("jte-classes"); private Path precompiledClassesPath = Path.of("jte-classes"); + private boolean serveStatics = true; + private String staticPrefix = "/static"; + private long largeFileThresholdBytes = DEFAULT_LARGE_FILE_THRESHOLD_BYTES; + private boolean staticCorsEnabled; + private String staticCorsAllowOrigin = "*"; + private String staticCorsAllowMethods = "GET,HEAD,OPTIONS"; + private String staticCorsAllowHeaders = "*"; + private int staticCorsMaxAge = 3600; public Builder templateRoot(String templateRoot) { String root = templateRoot == null ? "" : templateRoot.trim(); @@ -84,6 +138,52 @@ public final class JteSettings { return this; } + public Builder serveStatics(boolean serveStatics) { + this.serveStatics = serveStatics; + return this; + } + + public Builder staticPrefix(String staticPrefix) { + this.staticPrefix = normalizeStaticPrefix(staticPrefix); + return this; + } + + public Builder largeFileThresholdBytes(long largeFileThresholdBytes) { + if (largeFileThresholdBytes <= 0) { + throw new IllegalArgumentException("largeFileThresholdBytes must be > 0"); + } + this.largeFileThresholdBytes = largeFileThresholdBytes; + return this; + } + + public Builder enableStaticCors(boolean enabled) { + this.staticCorsEnabled = enabled; + return this; + } + + public Builder staticCorsAllowOrigin(String allowOrigin) { + this.staticCorsAllowOrigin = requireCorsValue(allowOrigin, "staticCorsAllowOrigin"); + return this; + } + + public Builder staticCorsAllowMethods(String allowMethods) { + this.staticCorsAllowMethods = requireCorsValue(allowMethods, "staticCorsAllowMethods"); + return this; + } + + public Builder staticCorsAllowHeaders(String allowHeaders) { + this.staticCorsAllowHeaders = requireCorsValue(allowHeaders, "staticCorsAllowHeaders"); + return this; + } + + public Builder staticCorsMaxAge(int maxAgeSeconds) { + if (maxAgeSeconds < 0) { + throw new IllegalArgumentException("staticCorsMaxAge must be >= 0"); + } + this.staticCorsMaxAge = maxAgeSeconds; + return this; + } + public JteSettings build() { boolean resolvedDev = developmentMode != null ? developmentMode : Flash.DEV; boolean resolvedPrecompiled = usePrecompiled != null ? usePrecompiled : !resolvedDev; @@ -95,6 +195,14 @@ public final class JteSettings { resolved.binaryStaticContent = this.binaryStaticContent; resolved.dynamicClassesPath = this.dynamicClassesPath; resolved.precompiledClassesPath = this.precompiledClassesPath; + resolved.serveStatics = this.serveStatics; + resolved.staticPrefix = this.staticPrefix; + resolved.largeFileThresholdBytes = this.largeFileThresholdBytes; + resolved.staticCorsEnabled = this.staticCorsEnabled; + resolved.staticCorsAllowOrigin = this.staticCorsAllowOrigin; + resolved.staticCorsAllowMethods = this.staticCorsAllowMethods; + resolved.staticCorsAllowHeaders = this.staticCorsAllowHeaders; + resolved.staticCorsMaxAge = this.staticCorsMaxAge; return new JteSettings(resolved); } @@ -105,5 +213,22 @@ public final class JteSettings { if (normalized.isEmpty()) return "/"; return normalized; } + + private static String normalizeStaticPrefix(String prefix) { + String normalized = prefix == null ? "" : prefix.trim().replace('\\', '/'); + if (normalized.isEmpty()) throw new IllegalArgumentException("staticPrefix must not be blank"); + if (!normalized.startsWith("/")) normalized = '/' + normalized; + while (normalized.length() > 1 && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static String requireCorsValue(String value, String field) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(field + " must not be blank"); + } + return value.trim(); + } } } diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteStaticServing.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteStaticServing.java new file mode 100644 index 0000000..16a0f7c --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/ext/view/jte/JteStaticServing.java @@ -0,0 +1,205 @@ +package dev.relism.ext.view.jte; + +import dev.relism.http.HttpStatus; +import dev.relism.models.Request; +import dev.relism.models.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.zip.CRC32; + +final class JteStaticServing { + private static final Logger log = LoggerFactory.getLogger(JteStaticServing.class); + private static final String STATIC_ROOT = "static"; + private static final Pattern VERSIONED_FILE = Pattern.compile(".*\\.[a-fA-F0-9]{8,}\\..*"); + + private static final Map MIME = Map.ofEntries( + Map.entry("css", "text/css"), + Map.entry("js", "text/javascript"), + Map.entry("mjs", "text/javascript"), + Map.entry("html", "text/html"), + Map.entry("txt", "text/plain"), + Map.entry("json", "application/json"), + Map.entry("xml", "application/xml"), + Map.entry("wasm", "application/wasm"), + 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("ico", "image/x-icon"), + Map.entry("avif", "image/avif"), + Map.entry("woff", "font/woff"), + Map.entry("woff2", "font/woff2"), + Map.entry("ttf", "font/ttf"), + Map.entry("otf", "font/otf"), + Map.entry("map", "application/json"), + Map.entry("pdf", "application/pdf"), + Map.entry("zip", "application/zip") + ); + + private final String prefix; + private final long largeThreshold; + + private JteStaticServing(JteSettings settings) { + this.prefix = settings.staticPrefix(); + this.largeThreshold = settings.largeFileThresholdBytes(); + } + + static JteStaticServing load(JteSettings settings) { + if (JteStaticServing.class.getClassLoader().getResource(STATIC_ROOT) == null) { + log.warn("flash-ext-view-jte static serving enabled but '/static' classpath root is missing; static routes disabled"); + return null; + } + return new JteStaticServing(settings); + } + + boolean serve(Request req, Response res, boolean headOnly) { + String requestPath = req.path(); + if (!requestPath.startsWith(prefix)) return false; + + String relative = requestPath.substring(prefix.length()); + if (relative.isEmpty() || "/".equals(relative)) return false; + if (!relative.startsWith("/") || relative.contains("..") || relative.contains("\\")) return false; + + String routePath = relative; + byte[] raw = read(routePath); + if (raw == null) return false; + + String etag = etag(raw); + String inm = req.header("If-None-Match"); + if (etag.equals(inm)) { + res.status(HttpStatus.NOT_MODIFIED); + res.header("ETag", etag); + res.header("Vary", "Accept-Encoding"); + res.header("Cache-Control", cacheControl(isVersioned(routePath, req))); + return true; + } + + boolean gzipAccepted = accepts(req, "gzip"); + byte[] gz = gzipAccepted ? read(routePath + ".gz") : null; + byte[] selected = gz != null ? gz : raw; + + res.type(mime(routePath)); + res.header("ETag", etag); + res.header("Cache-Control", cacheControl(isVersioned(routePath, req))); + res.header("Vary", "Accept-Encoding"); + if (gz != null) res.header("Content-Encoding", "gzip"); + + if (headOnly) { + res.body(new byte[0]); + return true; + } + + if (selected.length >= largeThreshold) { + String range = req.header("Range"); + if (gz == null && range != null) { + RangeSlice slice = parseRange(range, selected.length); + if (slice == null) { + res.status(416); + res.header("Content-Range", "bytes */" + selected.length); + res.body(new byte[0]); + return true; + } + res.status(HttpStatus.PARTIAL_CONTENT); + res.header("Accept-Ranges", "bytes"); + res.header("Content-Range", "bytes " + slice.start + "-" + slice.end + "/" + selected.length); + res.stream(new ByteArrayInputStream(selected, slice.start, slice.length), slice.length); + return true; + } + res.stream(new ByteArrayInputStream(selected), selected.length); + return true; + } + + res.body(selected); + return true; + } + + static String etagFor(byte[] raw) { return etag(raw); } + static String mimeFor(String routePath) { return mime(routePath); } + static boolean versionedPath(String routePath) { return VERSIONED_FILE.matcher(routePath).matches(); } + static int rangeLength(String rangeHeader, int size) { + RangeSlice slice = parseRange(rangeHeader, size); + return slice == null ? -1 : slice.length; + } + + private static String cacheControl(boolean versioned) { + return versioned ? "public, max-age=31536000, immutable" : "no-cache"; + } + + private static boolean accepts(Request req, String encoding) { + String value = req.header("Accept-Encoding"); + return value != null && value.contains(encoding); + } + + private static byte[] read(String routePath) { + String cp = STATIC_ROOT + routePath; + try (InputStream in = resource(cp.startsWith("/") ? cp.substring(1) : cp)) { + return in == null ? null : in.readAllBytes(); + } catch (Exception e) { + throw new IllegalStateException("Failed to read static asset: " + routePath, e); + } + } + + private static InputStream resource(String path) { + return JteStaticServing.class.getClassLoader().getResourceAsStream(path); + } + + private static String etag(byte[] raw) { + CRC32 crc = new CRC32(); + crc.update(raw, 0, raw.length); + return '"' + Long.toHexString(crc.getValue()) + '-' + raw.length + '"'; + } + + private static String mime(String routePath) { + int dot = routePath.lastIndexOf('.'); + if (dot < 0 || dot == routePath.length() - 1) return "application/octet-stream"; + String ext = routePath.substring(dot + 1).toLowerCase(); + return MIME.getOrDefault(ext, "application/octet-stream"); + } + + private static boolean isVersioned(String routePath, Request req) { + if (VERSIONED_FILE.matcher(routePath).matches()) return true; + return req.query("v") != null || req.query("hash") != null; + } + + private static RangeSlice parseRange(String rangeHeader, int size) { + if (!rangeHeader.startsWith("bytes=")) return null; + String value = rangeHeader.substring(6).trim(); + int comma = value.indexOf(','); + if (comma >= 0) value = value.substring(0, comma).trim(); + int dash = value.indexOf('-'); + if (dash < 0) return null; + + String startPart = value.substring(0, dash).trim(); + String endPart = value.substring(dash + 1).trim(); + + try { + int start; + int end; + if (startPart.isEmpty()) { + int suffixLen = Integer.parseInt(endPart); + if (suffixLen <= 0) return null; + if (suffixLen > size) suffixLen = size; + start = size - suffixLen; + end = size - 1; + } else { + start = Integer.parseInt(startPart); + if (start < 0 || start >= size) return null; + end = endPart.isEmpty() ? size - 1 : Integer.parseInt(endPart); + if (end < start) return null; + if (end >= size) end = size - 1; + } + return new RangeSlice(start, end, end - start + 1); + } catch (NumberFormatException ex) { + return null; + } + } + + private record RangeSlice(int start, int end, int length) {} +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java index e36f5aa..981d48b 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteExtensionTest.java @@ -7,6 +7,11 @@ import dev.relism.routing.GET; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -64,6 +69,69 @@ class JteExtensionTest { assertTrue(ex.getMessage().contains("must declare @Template")); } + @Test + void fluent_static_settings_are_applied() { + JteExtension ext = new JteExtension() + .templateRoot("templates") + .serveStatics(false) + .staticPrefix("assets") + .withStaticCors() + .staticCors(cfg -> cfg + .staticCorsAllowOrigin("https://cdn.example.com")); + + JteSettings settings = readSettings(ext); + assertEquals("/templates", settings.templateRoot()); + assertFalse(settings.serveStatics()); + assertEquals("/assets", settings.staticPrefix()); + assertTrue(settings.staticCorsEnabled()); + assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin()); + } + + @Test + void routes_register_static_wildcard_when_enabled() { + JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets")); + TestRegistrar app = new TestRegistrar(); + ext.routes(app, new dev.relism.extension.FlashContext()); + assertTrue(app.routes.containsKey("GET /assets/**")); + assertTrue(app.routes.containsKey("HEAD /assets/**")); + } + + @Test + void routes_do_not_register_static_when_disabled() { + JteExtension ext = new JteExtension().serveStatics(false); + TestRegistrar app = new TestRegistrar(); + ext.routes(app, new dev.relism.extension.FlashContext()); + assertTrue(app.routes.isEmpty()); + } + + @Test + void static_handler_serves_existing_asset() throws Exception { + JteExtension ext = new JteExtension(cfg -> cfg + .staticPrefix("/assets") + .largeFileThresholdBytes(1)); + TestRegistrar app = new TestRegistrar(); + ext.routes(app, new dev.relism.extension.FlashContext()); + + Request req = request("/assets/sample.css", null, null, null); + Response res = new Response(200, dev.relism.http.ContentType.TEXT_PLAIN); + Object out = app.routes.get("GET /assets/**").handle(req, res); + + assertEquals(null, out); + assertEquals("text/css", new String(res.getContentType(), StandardCharsets.UTF_8)); + assertTrue(res.isStreaming() || res.getBody() != null); + assertEquals(200, res.getStatusCode()); + } + + private static JteSettings readSettings(JteExtension ext) { + try { + var f = JteExtension.class.getDeclaredField("settings"); + f.setAccessible(true); + return (JteSettings) f.get(ext); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + private static void invokeValidate(JteExtension ext, Class type) throws Exception { Method m = JteExtension.class.getDeclaredMethod("validateHandlerClass", Class.class); m.setAccessible(true); @@ -76,4 +144,42 @@ class JteExtensionTest { throw new RuntimeException(cause); } } + + private static Request request(String path, String query, String acceptEncoding, String origin) { + dev.relism.models.HeaderMap headers = new dev.relism.models.HeaderMap(); + String raw = "Host: localhost\r\n"; + if (acceptEncoding != null) raw += "Accept-Encoding: " + acceptEncoding + "\r\n"; + if (origin != null) raw += "Origin: " + origin + "\r\n"; + byte[] bytes = raw.getBytes(StandardCharsets.UTF_8); + headers.reset(bytes, 0, bytes.length); + + dev.relism.models.RequestLine line = new dev.relism.models.RequestLine( + dev.relism.http.HttpMethod.GET, + new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView(path), + query == null ? null : new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView(query), + new dev.relism.routing.routers.fastpathrouter.FastPathViews.StringByteView("HTTP/1.1"), + headers + ); + return new Request(line, new byte[0]); + } + + private static final class TestRegistrar extends dev.relism.extension.FlashRegistrar { + private final Map routes = new HashMap<>(); + private final List mws = new ArrayList<>(); + + @Override + public dev.relism.extension.FlashContext ctx() { + return new dev.relism.extension.FlashContext(); + } + + @Override + protected void addRoute(dev.relism.http.HttpMethod method, String path, RequestHandler handler, List mw) { + routes.put(method.name() + " " + path, handler); + } + + @Override + protected void addMiddleware(dev.relism.routing.Middleware mw) { + mws.add(mw); + } + } } diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java index bf59fb9..bffc133 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteSettingsTest.java @@ -20,6 +20,14 @@ class JteSettingsTest { assertFalse(settings.binaryStaticContent()); assertEquals(Path.of("jte-classes"), settings.dynamicClassesPath()); assertEquals(Path.of("jte-classes"), settings.precompiledClassesPath()); + assertTrue(settings.serveStatics()); + assertEquals("/static", settings.staticPrefix()); + assertEquals(64L * 1024L, settings.largeFileThresholdBytes()); + assertFalse(settings.staticCorsEnabled()); + assertEquals("*", settings.staticCorsAllowOrigin()); + assertEquals("GET,HEAD,OPTIONS", settings.staticCorsAllowMethods()); + assertEquals("*", settings.staticCorsAllowHeaders()); + assertEquals(3600, settings.staticCorsMaxAge()); } @Test @@ -32,6 +40,14 @@ class JteSettingsTest { .binaryStaticContent(true) .dynamicClassesPath(Path.of("var", "jte-dev")) .precompiledClassesPath(Path.of("var", "jte-prod")) + .serveStatics(false) + .staticPrefix("assets") + .largeFileThresholdBytes(1024) + .enableStaticCors(true) + .staticCorsAllowOrigin("https://cdn.example.com") + .staticCorsAllowMethods("GET,HEAD") + .staticCorsAllowHeaders("Origin,Content-Type") + .staticCorsMaxAge(120) .build(); assertEquals("/src/main/jte", settings.templateRoot()); @@ -41,6 +57,14 @@ class JteSettingsTest { assertTrue(settings.binaryStaticContent()); assertEquals(Path.of("var", "jte-dev"), settings.dynamicClassesPath()); assertEquals(Path.of("var", "jte-prod"), settings.precompiledClassesPath()); + assertFalse(settings.serveStatics()); + assertEquals("/assets", settings.staticPrefix()); + assertEquals(1024, settings.largeFileThresholdBytes()); + assertTrue(settings.staticCorsEnabled()); + assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin()); + assertEquals("GET,HEAD", settings.staticCorsAllowMethods()); + assertEquals("Origin,Content-Type", settings.staticCorsAllowHeaders()); + assertEquals(120, settings.staticCorsMaxAge()); } @Test @@ -60,10 +84,22 @@ class JteSettingsTest { assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().templateRoot(" ")); } + @Test + void staticPrefix_normalizes_andRejectsBlank() { + assertEquals("/static", JteSettings.builder().staticPrefix("static/").build().staticPrefix()); + assertEquals("/assets", JteSettings.builder().staticPrefix("/assets/").build().staticPrefix()); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticPrefix(" ")); + } + @Test void nulls_areRejectedForRequiredObjects() { assertThrows(NullPointerException.class, () -> JteSettings.builder().contentType(null)); assertThrows(NullPointerException.class, () -> JteSettings.builder().dynamicClassesPath(null)); assertThrows(NullPointerException.class, () -> JteSettings.builder().precompiledClassesPath(null)); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().largeFileThresholdBytes(0)); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowOrigin(" ")); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowMethods(" ")); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsAllowHeaders(" ")); + assertThrows(IllegalArgumentException.class, () -> JteSettings.builder().staticCorsMaxAge(-1)); } } diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteStaticServingTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteStaticServingTest.java new file mode 100644 index 0000000..405e7d1 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/ext/view/jte/JteStaticServingTest.java @@ -0,0 +1,50 @@ +package dev.relism.ext.view.jte; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class JteStaticServingTest { + + @Test + void etag_is_stable_for_same_payload_and_changes_for_different() { + byte[] a = "hello".getBytes(); + byte[] b = "hello".getBytes(); + byte[] c = "hello!".getBytes(); + + String etagA = JteStaticServing.etagFor(a); + String etagB = JteStaticServing.etagFor(b); + String etagC = JteStaticServing.etagFor(c); + + assertEquals(etagA, etagB); + assertNotEquals(etagA, etagC); + } + + @Test + void mime_resolution_supports_common_extensions_and_defaults() { + assertEquals("text/css", JteStaticServing.mimeFor("/sample.css")); + assertEquals("text/javascript", JteStaticServing.mimeFor("/sample.js")); + assertEquals("application/wasm", JteStaticServing.mimeFor("/sample.wasm")); + assertEquals("application/octet-stream", JteStaticServing.mimeFor("/sample.unknown")); + assertEquals("application/octet-stream", JteStaticServing.mimeFor("/sample")); + } + + @Test + void versioned_path_detection_by_filename_hash() { + assertTrue(JteStaticServing.versionedPath("/app.4f3a2c1b.js")); + assertTrue(JteStaticServing.versionedPath("/app.abcdefabcdef.js")); + assertFalse(JteStaticServing.versionedPath("/app.js")); + } + + @Test + void range_parsing_supports_basic_cases() { + assertEquals(10, JteStaticServing.rangeLength("bytes=0-9", 100)); + assertEquals(100, JteStaticServing.rangeLength("bytes=0-", 100)); + assertEquals(10, JteStaticServing.rangeLength("bytes=-10", 100)); + assertEquals(-1, JteStaticServing.rangeLength("bytes=100-120", 100)); + assertEquals(-1, JteStaticServing.rangeLength("invalid", 100)); + } +} diff --git a/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css b/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css new file mode 100644 index 0000000..ffe45b7 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css @@ -0,0 +1 @@ +body { color: #111; } diff --git a/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css.gz b/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css.gz new file mode 100644 index 0000000..4ff43d3 --- /dev/null +++ b/flash-extensions/flash-ext-view-jte/src/test/resources/static/sample.css.gz @@ -0,0 +1 @@ +gzipped-sample diff --git a/flash/src/main/java/dev/relism/extension/FlashContext.java b/flash/src/main/java/dev/relism/extension/FlashContext.java index 25ae4e3..17f702a 100644 --- a/flash/src/main/java/dev/relism/extension/FlashContext.java +++ b/flash/src/main/java/dev/relism/extension/FlashContext.java @@ -173,4 +173,4 @@ public class FlashContext { merged.addAll(routeListeners); return cachedListeners = List.copyOf(merged); } -} +} \ No newline at end of file diff --git a/flash/src/main/java/dev/relism/http/HttpStatus.java b/flash/src/main/java/dev/relism/http/HttpStatus.java index b9e4d1f..e26afdd 100644 --- a/flash/src/main/java/dev/relism/http/HttpStatus.java +++ b/flash/src/main/java/dev/relism/http/HttpStatus.java @@ -1,6 +1,12 @@ package dev.relism.http; +import dev.relism.models.Request; +import dev.relism.models.Response; +import dev.relism.routing.GET; +import dev.relism.routing.Route; + import java.nio.charset.StandardCharsets; +import java.util.List; /** * Pre-compiled byte representations of standard HTTP status lines. @@ -97,4 +103,4 @@ public enum HttpStatus { } return null; } -} +} \ No newline at end of file