From 87d02aceb775ab149e5da671054ba23ef5f324a9 Mon Sep 17 00:00:00 2001
From: Relism
Date: Tue, 21 Apr 2026 09:34:35 +0200
Subject: [PATCH 1/2] add workspace.xml to .gitignore
---
.gitignore | 1 +
.idea/workspace.xml | 437 --------------------------------------------
2 files changed, 1 insertion(+), 437 deletions(-)
delete mode 100644 .idea/workspace.xml
diff --git a/.gitignore b/.gitignore
index 10da2e0..f4406a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ target/
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
+.idea/workspace.xml
*.iws
*.iml
*.ipr
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
deleted file mode 100644
index 6f89113..0000000
--- a/.idea/workspace.xml
+++ /dev/null
@@ -1,437 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {
- "lastFilter": {
- "state": "OPEN",
- "assignee": "Relism"
- }
-}
-
-
-
-
-
-
-
-
- {
- "associatedIndex": 3
-}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1773265186049
-
-
- 1773265186049
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1773536078762
-
-
-
- 1773536078762
-
-
-
- 1773536678416
-
-
-
- 1773536678416
-
-
-
- 1773581516834
-
-
-
- 1773581516834
-
-
-
- 1773591259108
-
-
-
- 1773591259108
-
-
-
- 1773920675327
-
-
-
- 1773920675327
-
-
-
- 1773920735659
-
-
-
- 1773920735659
-
-
-
- 1773952556190
-
-
-
- 1773952556190
-
-
-
- 1774530802482
-
-
-
- 1774530802482
-
-
-
- 1774703474044
-
-
-
- 1774703474044
-
-
-
- 1774819003821
-
-
-
- 1774819003821
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
From c2e3f98ea282cffd646da60339fba05da23ef931 Mon Sep 17 00:00:00 2001
From: Relism
Date: Sun, 26 Apr 2026 12:27:59 +0200
Subject: [PATCH 2/2] 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 extends RequestHandler> 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