feat(ext-vite): recognise navigations by Sec-Fetch-Mode, read every header in place
A path that is no file falls back to index.html when the request is a navigation: Sec-Fetch-Mode: navigate, or an Accept naming text/html for older clients. That check, Accept-Encoding and If-None-Match are all matched on the header bytes through the new Request.headerView(name), so serving still allocates nothing. navigationOnly(false) drops the check for an app that wants every GET miss to get the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
680bbca8c7
commit
003fd6d1f0
@@ -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