feat: Vite extension and Maven plugin; data pools close on stop #19
@@ -55,6 +55,7 @@ in agreement.
|
|||||||
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
|
| `root(Path)` | `web` | The Vite project, relative to the working directory. DEV only. |
|
||||||
| `devPort(int)` | `5173` | Vite's port in DEV. |
|
| `devPort(int)` | `5173` | Vite's port in DEV. |
|
||||||
| `basePath(String)` | `/` | Where the frontend is served. Vite's `base` must match it. |
|
| `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 | |
|
| Plugin parameter | Default | |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -103,11 +104,18 @@ prefers specific routes over the wildcard.
|
|||||||
| `basePath` itself (`/`) | `index.html`, whatever the client accepts |
|
| `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) |
|
| 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` |
|
| any other built file (`index.html`, `favicon.svg`, …) | `Cache-Control: no-cache`, revalidated by `ETag` |
|
||||||
| no such file, and `Accept` names `text/html` (a browser navigating to `/content/2`) | `index.html`, so the client-side router takes it |
|
| no such file, 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` |
|
| 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 |
|
| `If-None-Match` with the current `ETag` | `304` with `ETag` and `Cache-Control`, no body |
|
||||||
| `HEAD` | the `GET` headers, `Content-Length` included, 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
|
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
|
more, and it is sent to clients whose `Accept-Encoding` allows gzip (`gzip;q=0` does not), with
|
||||||
`Vary: Accept-Encoding`.
|
`Vary: Accept-Encoding`.
|
||||||
|
|||||||
+53
-20
@@ -5,6 +5,7 @@ import dev.relism.flash.http.HttpStatus;
|
|||||||
import dev.relism.flash.models.PreEncodedHeader;
|
import dev.relism.flash.models.PreEncodedHeader;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
@@ -59,13 +60,22 @@ final class Assets {
|
|||||||
Map.entry("otf", "font/otf"),
|
Map.entry("otf", "font/otf"),
|
||||||
Map.entry("wasm", "application/wasm"));
|
Map.entry("wasm", "application/wasm"));
|
||||||
|
|
||||||
record Asset(byte[] raw, byte[] gzip, byte[] type, PreEncodedHeader cache, PreEncodedHeader etag, String tag) {}
|
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<String, Asset> byPath;
|
final Map<String, Asset> byPath;
|
||||||
private final Asset index;
|
private final Asset index;
|
||||||
|
private final boolean navigationOnly;
|
||||||
|
|
||||||
/** Every file under {@link ViteExtension#CLASSPATH} on {@code loader}'s classpath, routed under {@code basePath}. */
|
/**
|
||||||
Assets(ClassLoader loader, String basePath) {
|
* 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");
|
URL index = loader.getResource(ViteExtension.CLASSPATH + "/index.html");
|
||||||
if (index == null) {
|
if (index == null) {
|
||||||
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
|
throw new IllegalStateException("No built frontend on the classpath (" + ViteExtension.CLASSPATH + "/index.html): "
|
||||||
@@ -107,22 +117,20 @@ final class Assets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The asset at the request's path. A path that is none is a client-side route when a browser
|
* The asset at the request's path. A path that is none gets {@code index.html} when a browser
|
||||||
* navigates to it ({@code Accept} names HTML), so it gets {@code index.html}; for anything
|
* navigates to it, so the client-side router takes it; anything else, an API call or a missing
|
||||||
* else, an API call or a missing script, it is the app's own 404.
|
* script, gets the app's own 404. Headers are read as bytes, so nothing here allocates.
|
||||||
*/
|
*/
|
||||||
void serve(Request req, Response res) {
|
void serve(Request req, Response res) {
|
||||||
Asset asset = byPath.get(req.path());
|
Asset asset = byPath.get(req.path());
|
||||||
if (asset == null) {
|
if (asset == null) {
|
||||||
String accept = req.header("Accept");
|
if (navigationOnly && !navigation(req)) throw HttpException.notFound(req.path());
|
||||||
if (accept == null || !accept.contains("text/html")) throw HttpException.notFound(req.path());
|
|
||||||
asset = index;
|
asset = index;
|
||||||
}
|
}
|
||||||
res.header(asset.cache);
|
res.header(asset.cache);
|
||||||
if (asset.etag != null) {
|
if (asset.etag != null) {
|
||||||
res.header(asset.etag);
|
res.header(asset.etag);
|
||||||
String known = req.header("If-None-Match");
|
if (indexOf(req.headerView("If-None-Match"), asset.tag, 0) >= 0) {
|
||||||
if (known != null && known.contains(asset.tag)) {
|
|
||||||
res.status(HttpStatus.NOT_MODIFIED);
|
res.status(HttpStatus.NOT_MODIFIED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,7 +138,7 @@ final class Assets {
|
|||||||
res.type(asset.type);
|
res.type(asset.type);
|
||||||
if (asset.gzip != null) {
|
if (asset.gzip != null) {
|
||||||
res.header(VARY);
|
res.header(VARY);
|
||||||
if (acceptsGzip(req.header("Accept-Encoding"))) {
|
if (acceptsGzip(req.headerView("Accept-Encoding"))) {
|
||||||
res.header(GZIP).body(asset.gzip);
|
res.header(GZIP).body(asset.gzip);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,33 +146,58 @@ final class Assets {
|
|||||||
res.body(asset.raw);
|
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. */
|
/** Listed in {@code Accept-Encoding}, and not with a q of zero. */
|
||||||
static boolean acceptsGzip(String accept) {
|
static boolean acceptsGzip(ByteView accept) {
|
||||||
if (accept == null) return false;
|
int at = indexOf(accept, GZIP_TOKEN, 0);
|
||||||
int at = accept.indexOf("gzip");
|
|
||||||
if (at < 0) return false;
|
if (at < 0) return false;
|
||||||
int end = accept.indexOf(',', at);
|
int end = at;
|
||||||
if (end < 0) end = accept.length();
|
while (end < accept.length() && accept.byteAt(end) != ',') end++;
|
||||||
int q = accept.indexOf("q=", at);
|
int q = indexOf(accept, Q, at);
|
||||||
if (q < 0 || q > end) return true;
|
if (q < 0 || q > end) return true;
|
||||||
for (int i = q + 2; i < end; i++) {
|
for (int i = q + 2; i < end; i++) {
|
||||||
char c = accept.charAt(i);
|
byte c = accept.byteAt(i);
|
||||||
if (c >= '1' && c <= '9') return true;
|
if (c >= '1' && c <= '9') return true;
|
||||||
if (c != '0' && c != '.') break;
|
if (c != '0' && c != '.') break;
|
||||||
}
|
}
|
||||||
return false;
|
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
|
* 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.
|
* 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) {
|
private static Asset asset(String path, byte[] raw, byte[] gzip) {
|
||||||
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
|
String extension = path.substring(path.lastIndexOf('.') + 1).toLowerCase();
|
||||||
byte[] type = TYPES.getOrDefault(extension, "application/octet-stream").getBytes(StandardCharsets.US_ASCII);
|
byte[] type = ascii(TYPES.getOrDefault(extension, "application/octet-stream"));
|
||||||
if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
|
if (path.startsWith("assets/")) return new Asset(raw, gzip, type, IMMUTABLE, null, null);
|
||||||
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
|
String tag = "\"" + HexFormat.of().formatHex(sha1(raw)) + "\"";
|
||||||
return new Asset(raw, gzip, type, REVALIDATE, new PreEncodedHeader("ETag", tag), tag);
|
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) {
|
private static byte[] sha1(byte[] raw) {
|
||||||
|
|||||||
+12
-1
@@ -20,6 +20,7 @@ public final class ViteExtension implements FlashExtension {
|
|||||||
private Path root = Path.of("web");
|
private Path root = Path.of("web");
|
||||||
private int devPort = 5173;
|
private int devPort = 5173;
|
||||||
private String basePath = "/";
|
private String basePath = "/";
|
||||||
|
private boolean navigationOnly = true;
|
||||||
|
|
||||||
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
|
/** The Vite project, {@code web} (relative to the working directory) by default. Read in DEV only. */
|
||||||
public ViteExtension root(Path root) {
|
public ViteExtension root(Path root) {
|
||||||
@@ -40,13 +41,23 @@ public final class ViteExtension implements FlashExtension {
|
|||||||
return this;
|
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
|
@Override
|
||||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
if (Flash.DEV) {
|
if (Flash.DEV) {
|
||||||
ctx.onClose(new DevServer(root, devPort)::close);
|
ctx.onClose(new DevServer(root, devPort)::close);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath);
|
Assets assets = new Assets(ViteExtension.class.getClassLoader(), basePath, navigationOnly);
|
||||||
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
|
String everything = "/".equals(basePath) ? "/**" : basePath + "/**";
|
||||||
ctx.onReady(() -> {
|
ctx.onReady(() -> {
|
||||||
app.get(everything, (req, res) -> {
|
app.get(everything, (req, res) -> {
|
||||||
|
|||||||
+25
-7
@@ -1,5 +1,6 @@
|
|||||||
package dev.relism.flash.ext.vite;
|
package dev.relism.flash.ext.vite;
|
||||||
|
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
@@ -33,24 +34,41 @@ class AssetsTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
|
try (URLClassLoader loader = new URLClassLoader(new URL[]{jar.toUri().toURL()}, null)) {
|
||||||
assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/").byPath.keySet());
|
assertEquals(Set.of("/", "/index.html", "/assets/a-12345678.css"), new Assets(loader, "/", true).byPath.keySet());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void noBuildFailsTheBootNamingThePlugin() throws IOException {
|
void noBuildFailsTheBootNamingThePlugin() throws IOException {
|
||||||
try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) {
|
try (URLClassLoader empty = new URLClassLoader(new URL[0], null)) {
|
||||||
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/")).getMessage().contains("flash-ext-vite-maven-plugin"));
|
assertTrue(assertThrows(IllegalStateException.class, () -> new Assets(empty, "/", true)).getMessage().contains("flash-ext-vite-maven-plugin"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void gzipIsAcceptedUnlessRefused() {
|
void gzipIsAcceptedUnlessRefused() {
|
||||||
assertTrue(Assets.acceptsGzip("gzip, deflate, br"));
|
assertTrue(Assets.acceptsGzip(view("gzip, deflate, br")));
|
||||||
assertTrue(Assets.acceptsGzip("br;q=1.0, gzip;q=0.5"));
|
assertTrue(Assets.acceptsGzip(view("br;q=1.0, GZIP;q=0.5")));
|
||||||
assertFalse(Assets.acceptsGzip("gzip;q=0, br"));
|
assertFalse(Assets.acceptsGzip(view("gzip;q=0, br")));
|
||||||
assertFalse(Assets.acceptsGzip("gzip;q=0.000"));
|
assertFalse(Assets.acceptsGzip(view("gzip;q=0.000")));
|
||||||
assertFalse(Assets.acceptsGzip("br"));
|
assertFalse(Assets.acceptsGzip(view("br")));
|
||||||
assertFalse(Assets.acceptsGzip(null));
|
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]; }
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -16,6 +16,9 @@ class ViteExtensionTest {
|
|||||||
@RegisterExtension
|
@RegisterExtension
|
||||||
static final FlashTest app = FlashTest.of(flash -> flash.install(new ViteExtension()));
|
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
|
@RegisterExtension
|
||||||
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
|
static final FlashTest nested = FlashTest.of(flash -> flash.install(new ViteExtension().basePath("/app/")));
|
||||||
|
|
||||||
@@ -41,6 +44,7 @@ class ViteExtensionTest {
|
|||||||
for (String route : new String[]{"/content/2", "/users/ada.lovelace"}) {
|
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("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", "application/json").get("/api/nope").expectStatus(404);
|
||||||
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
|
app.request().header("Accept", "*/*").get("/assets/missing.js").expectStatus(404);
|
||||||
app.get("/content/2").expectStatus(404);
|
app.get("/content/2").expectStatus(404);
|
||||||
@@ -48,6 +52,13 @@ class ViteExtensionTest {
|
|||||||
nested.get("/app").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
|
@Test
|
||||||
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
|
void anUnchangedFileIsNotSentAgainAHashedOneIsNeverAsked() {
|
||||||
String etag = app.get("/favicon.svg").header("ETag");
|
String etag = app.get("/favicon.svg").header("ETag");
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ public class Request {
|
|||||||
*/
|
*/
|
||||||
public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); }
|
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.
|
* Returns all values of header {@code name} in declaration order.
|
||||||
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
||||||
|
|||||||
Reference in New Issue
Block a user