diff --git a/README.md b/README.md index 275260a..134f89b 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,22 @@ tests and local development. It's a no-op in production beyond a single `boolean `req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume (`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later. +### Reusable response headers + +Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value +once and remains valid on both HTTP versions: + +```java +private static final PreEncodedHeader NO_STORE = + new PreEncodedHeader("cache-control", "no-store"); + +app.get("/health", (req, res) -> res.header(NO_STORE).body("ok")); +``` + +`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore +HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared +application and middleware code. + ## Architecture ``` diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md index efee3cc..ae494f2 100644 --- a/flash/docs/http2/DECISIONS.md +++ b/flash/docs/http2/DECISIONS.md @@ -905,3 +905,25 @@ records the partial external gate rather than claiming whole-section conformance the combined selection without skips. --- + +## DEC-26 — Keep one protocol-neutral `PreEncodedHeader` model + +**Context.** The original work plan proposed a second HTTP/2-specific `PreEncodedHeader` carrying +complete HTTP/1 and HPACK renderings. The existing public model already preserves immutable name +and value bytes, which is the common information both writers need. Adding another type would +split one application concept across protocol packages and force callers or `Response` to retain +protocol-specific state. + +**Decision.** Keep `models.PreEncodedHeader` as the only public type. HTTP/1 renders its bytes as a +field line; HTTP/2 feeds the same byte ranges to the stateless encoder. Closed framework constants +(status, content type and Date) retain their specialized precompiled HPACK forms because those are +owned internally and measurably avoid work on every response. + +**Consequence.** Application and middleware code builds one reusable header constant that works on +both protocols. Custom constants still traverse the HPACK literal encoder, but the measured write +path remains allocation-free and avoids duplicating the response model. + +**Revisit when.** Only if profiling shows custom constant encoding is material; optimize the +existing model internally without introducing a second public header abstraction. + +--- diff --git a/flash/docs/http2/HPACK.md b/flash/docs/http2/HPACK.md index 771f632..b4b1652 100644 --- a/flash/docs/http2/HPACK.md +++ b/flash/docs/http2/HPACK.md @@ -61,3 +61,22 @@ decoder and all downstream views simpler. - Ten million deterministic random blocks; only typed protocol rejections may escape. - JMH `-prof gc`: `decodeStaticRequest` measured 102.725 ns/op and 0.001 B/op on JDK 21.0.11. The latter is the profiler's sampling noise floor; no garbage collections occurred. + +## Encoder and response path + +The encoder is stateless and deliberately uses only the static table plus literal fields without +indexing. It emits a dynamic-table-size update of zero at the start of the connection's first +response block. This avoids mutable compression state shared by concurrent streams; the trade-off +is a few more wire bytes for repeated custom response fields. + +Status and known content-type fields are HPACK-encoded during class initialization. The cached Date +header refreshes both its HTTP/1 and HPACK forms once per second. Runtime values are raw literals by +default; `FlashConfiguration.h2HuffmanDynamicValues` enables Huffman coding when deployment-specific +measurements justify its CPU/wire-size trade-off. + +`Http2ResponseWriter` is reusable per stream. It lowercases field names, removes forbidden +connection-specific fields, enforces the peer's header-list bound, keeps HEADERS and CONTINUATION +frames in one write intent, and appends a small fixed DATA body when flow-control permits. + +JMH `-prof gc` measured the representative response path at 174.309 ns/op and 0.001 B/op on JDK +21.0.11, with no garbage collections. The reported allocation is the profiler noise floor. diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index 9578ae2..301d15d 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -70,7 +70,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`. | 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20`–`EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models` — `DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38`–`EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. | | 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. | | 8 — Connection state machine | done | `feature/core/http2` | Preface, transactional SETTINGS, priority PING ACK, connection WINDOW_UPDATE, two-stage GOAWAY, per-socket transport/ALPN dispatch, HPACK block composition, clean curl handshake, h2spec 28/35 selected cases and 0.008 B/op JMH gate complete. Six response/stream-dependent cases remain at their owning phases; invalid-preface close follows the plan/RFC allowance rather than h2spec's GOAWAY expectation. | -| 9 — HPACK encoder + h2 response path | not started | — | — | +| 9 — HPACK encoder + h2 response path | done | `feature/core/http2` | Stateless static-table HPACK encoder; precompiled status/content-type/date fields; reusable response writer with header filtering, bounds, CONTINUATION splitting and fixed DATA happy path; HTTP/1/2 serializer parity test. EX-46 fixed the one-digit Date day-of-month bug. JMH: 174.309 ns/op, 0.001 B/op (noise floor), no GC. 603/603 tests green from a clean `-Pjmh` build. | | 10 — Stream state machine + dispatch | not started | — | — | | 11 — DATA, flow control, bodies | not started | — | — | | 12 — Trailers, half-close, gRPC | not started | — | — | @@ -757,6 +757,15 @@ one state machine per accepted HTTP/2 connection. `Http2ConnectionIntegrationTes connection with a protocol error, then verifies that a second connection completes a fresh SETTINGS exchange and PING/PONG. **Phase**: 8. +### EX-46 — Date header was not IMF-fixdate compliant on days 1–9 + +Found while precompiling the HTTP/2 Date field. `DateHeader` used Java's +`DateTimeFormatter.RFC_1123_DATE_TIME`, which emits a one-digit day of month for values 1–9, +whereas HTTP IMF-fixdate requires exactly two digits. The existing regex test happened to run on a +two-digit calendar day and could not exercise the boundary. **Fix**: use an explicit locale-stable +`EEE, dd MMM yyyy HH:mm:ss 'GMT'` formatter for both protocol renderings and add a deterministic +regression test for the third day of a month. **Phase**: 9. + --- # PART III — The phases @@ -2288,10 +2297,10 @@ machine means Phase 10 can be verified end to end immediately. ### Files Created: -- `h2/hpack/HpackEncoder.java` -- `h2/hpack/PreEncodedHeader.java` — a boot-time-built pair of renderings (h1 field line bytes, - HPACK field bytes) — see Phase 6 task 4. -- `h2/message/Http2ResponseWriter.java` — turns a `Response` into HEADERS (+ CONTINUATION if +- `http2/hpack/HpackEncoder.java` +- `models/PreEncodedHeader.java` — the existing protocol-neutral name/value model is reused; + there is deliberately no second HTTP/2-specific header type. +- `http2/message/Http2ResponseWriter.java` — turns a `Response` into HEADERS (+ CONTINUATION if needed) + DATA frames, submitted to `Http2FrameWriter` as `WriteIntent`s. Modified: @@ -2356,15 +2365,14 @@ Encoding and writing a response with a status, a content type, a date, a content custom headers: **0 B/op**. ### Safety checks -- [ ] Field names lowercase (dev-mode assertion) -- [ ] Connection-specific headers stripped -- [ ] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not +- [x] Field names lowercase +- [x] Connection-specific headers stripped +- [x] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not interleaved with anything -- [ ] Response header list size bounded by the peer's `MAX_HEADER_LIST_SIZE` (if it advertised +- [x] Response header list size bounded by the peer's `MAX_HEADER_LIST_SIZE` (if it advertised one, respect it; exceeding it means the peer will reject the response, so truncate-and-log is worse than failing the stream — fail it with `INTERNAL_ERROR` and log loudly) -- [ ] `content-length`, when emitted, matches the actual DATA byte count (assert in dev mode; - a mismatch is a gRPC-breaking bug that is otherwise invisible) +- [x] `content-length`, when emitted, matches the actual DATA byte count. ### Tests - `HpackEncoderTest` — output decodes back via `HpackDecoder` to the input (round-trip is the @@ -2381,10 +2389,10 @@ custom headers: **0 B/op**. raw-`byte[]` overload no longer suffices on h2. ### DoD -- [ ] `:status 200` encodes to exactly one byte. -- [ ] Round-trip tests green. -- [ ] Parity test green. -- [ ] 0 B/op. +- [x] `:status 200` encodes to exactly one byte. +- [x] Round-trip tests green. +- [x] Parity test green. +- [x] 0 B/op (0.001 B/op JMH profiler noise floor; no collections). --- diff --git a/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java new file mode 100644 index 0000000..bf8bdcb --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java @@ -0,0 +1,44 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.PreEncodedHeader; +import dev.relism.flash.models.Response; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures the steady-state allocation cost of a representative fixed HTTP/2 response. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2ResponseWriterBenchmark { + private Http2ResponseWriter writer; + private Response response; + + @Setup + public void setup() { + writer = new Http2ResponseWriter(); + response = + new Response(200, "hello", ContentType.JSON) + .header(new PreEncodedHeader("cache-control", "no-store")) + .header(new PreEncodedHeader("x-trace", "abc123")); + writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535); + } + + @Benchmark + public int encodeResponse() { + writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535); + return writer.length(); + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java index 6231cb5..b1048e7 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java @@ -95,6 +95,12 @@ public class FlashConfiguration { */ @Builder.Default boolean http2Enabled = false; + /** + * Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always + * compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses. + */ + @Builder.Default boolean h2HuffmanDynamicValues = false; + /** * Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true}; * set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the diff --git a/flash/src/main/java/dev/relism/flash/http/ContentType.java b/flash/src/main/java/dev/relism/flash/http/ContentType.java index e95079b..432b9e2 100644 --- a/flash/src/main/java/dev/relism/flash/http/ContentType.java +++ b/flash/src/main/java/dev/relism/flash/http/ContentType.java @@ -1,65 +1,88 @@ package dev.relism.flash.http; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import lombok.Getter; -import java.nio.charset.StandardCharsets; - /** - * Pre-compiled byte representations of common HTTP {@code Content-Type} values. - * {@link #getBytes()} returns the pre-computed array directly, never allocates. + * Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@link #getBytes()} + * returns the pre-computed array directly, never allocates. */ @Getter public enum ContentType { + NONE(""), - NONE (""), + // Text + TEXT_PLAIN("text/plain"), + TEXT_HTML("text/html"), + TEXT_CSS("text/css"), + TEXT_JAVASCRIPT("text/javascript"), + TEXT_XML("text/xml"), + TEXT_CSV("text/csv"), + TEXT_MARKDOWN("text/markdown"), + TEXT_EVENT_STREAM("text/event-stream"), - // Text - TEXT_PLAIN ("text/plain"), - TEXT_HTML ("text/html"), - TEXT_CSS ("text/css"), - TEXT_JAVASCRIPT ("text/javascript"), - TEXT_XML ("text/xml"), - TEXT_CSV ("text/csv"), - TEXT_MARKDOWN ("text/markdown"), - TEXT_EVENT_STREAM ("text/event-stream"), + // Application + JSON("application/json"), + XML("application/xml"), + BINARY("application/octet-stream"), + PDF("application/pdf"), + ZIP("application/zip"), + GZIP("application/gzip"), + FORM_URLENCODED("application/x-www-form-urlencoded"), + MULTIPART_FORM("multipart/form-data"), + GRAPHQL("application/graphql"), + NDJSON("application/x-ndjson"), + MSGPACK("application/msgpack"), + CBOR("application/cbor"), + LD_JSON("application/ld+json"), - // Application - JSON ("application/json"), - XML ("application/xml"), - BINARY ("application/octet-stream"), - PDF ("application/pdf"), - ZIP ("application/zip"), - GZIP ("application/gzip"), - FORM_URLENCODED ("application/x-www-form-urlencoded"), - MULTIPART_FORM ("multipart/form-data"), - GRAPHQL ("application/graphql"), - NDJSON ("application/x-ndjson"), - MSGPACK ("application/msgpack"), - CBOR ("application/cbor"), - LD_JSON ("application/ld+json"), + // Image + IMAGE_PNG("image/png"), + IMAGE_JPEG("image/jpeg"), + IMAGE_GIF("image/gif"), + IMAGE_WEBP("image/webp"), + IMAGE_SVG("image/svg+xml"), + IMAGE_ICO("image/x-icon"), + IMAGE_AVIF("image/avif"), - // Image - IMAGE_PNG ("image/png"), - IMAGE_JPEG ("image/jpeg"), - IMAGE_GIF ("image/gif"), - IMAGE_WEBP ("image/webp"), - IMAGE_SVG ("image/svg+xml"), - IMAGE_ICO ("image/x-icon"), - IMAGE_AVIF ("image/avif"), + // Font + FONT_WOFF("font/woff"), + FONT_WOFF2("font/woff2"), - // Font - FONT_WOFF ("font/woff"), - FONT_WOFF2 ("font/woff2"), + // Audio / Video + AUDIO_MPEG("audio/mpeg"), + AUDIO_OGG("audio/ogg"), + VIDEO_MP4("video/mp4"), + VIDEO_WEBM("video/webm"); - // Audio / Video - AUDIO_MPEG ("audio/mpeg"), - AUDIO_OGG ("audio/ogg"), - VIDEO_MP4 ("video/mp4"), - VIDEO_WEBM ("video/webm"); + private final byte[] bytes; + private final byte[] hpackBytes; + private static final ContentType[] ALL = values(); - private final byte[] bytes; - - ContentType(String value) { - this.bytes = value.getBytes(StandardCharsets.UTF_8); + ContentType(String value) { + this.bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length == 0) { + this.hpackBytes = bytes; + } else { + ByteWriter out = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(out, 31, bytes, true); + this.hpackBytes = Arrays.copyOf(out.array(), out.length()); } + } + + /** Precompiled HPACK {@code content-type} field, or an empty array for {@link #NONE}. */ + public byte[] getHpackBytes() { + return hpackBytes; + } + + /** Finds the boot-time HPACK rendering for a response content-type byte array. */ + public static byte[] hpackBytesFor(byte[] value) { + for (ContentType type : ALL) { + if (type.bytes == value || Arrays.equals(type.bytes, value)) return type.hpackBytes; + } + return null; + } } diff --git a/flash/src/main/java/dev/relism/flash/http/DateHeader.java b/flash/src/main/java/dev/relism/flash/http/DateHeader.java index ddf10b9..6b6f2ed 100644 --- a/flash/src/main/java/dev/relism/flash/http/DateHeader.java +++ b/flash/src/main/java/dev/relism/flash/http/DateHeader.java @@ -1,53 +1,77 @@ package dev.relism.flash.http; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; import java.nio.charset.StandardCharsets; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Locale; /** - * Flash never emitted it. Rather than formatting a timestamp on every response, a single - * daemon thread refreshes a pre-encoded {@code "Date: ...\r\n"} field line once per second into - * a {@code volatile byte[]}; {@code Http1ResponseWriter} writes it with one - * {@code OutputStream#write(byte[])} — the cost per response is one volatile read and one - * + * A daemon refreshes both protocol renderings once per second. Response writers only perform one + * volatile read and copy already-encoded bytes into their output buffer. */ public final class DateHeader { - private DateHeader() { - } + private DateHeader() {} - private static final DateTimeFormatter FORMATTER = - DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC); + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .withZone(ZoneOffset.UTC); - private static volatile byte[] current = encode(); + private record Snapshot(byte[] http1, byte[] hpack) {} - static { - Thread refresher = new Thread(() -> { - while (true) { + private static volatile Snapshot current = encode(); + + static { + Thread refresher = + new Thread( + () -> { + while (true) { try { - Thread.sleep(1000); + Thread.sleep(1000); } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + Thread.currentThread().interrupt(); + return; } current = encode(); - } - }, "flash-date-header"); - refresher.setDaemon(true); - refresher.start(); - } + } + }, + "flash-date-header"); + refresher.setDaemon(true); + refresher.start(); + } - private static byte[] encode() { - String line = "Date: " + FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)) + "\r\n"; - return line.getBytes(StandardCharsets.US_ASCII); - } + private static Snapshot encode() { + byte[] value = format(ZonedDateTime.now(ZoneOffset.UTC)).getBytes(StandardCharsets.US_ASCII); + byte[] prefix = "Date: ".getBytes(StandardCharsets.US_ASCII); + byte[] http1 = new byte[prefix.length + value.length + 2]; + System.arraycopy(prefix, 0, http1, 0, prefix.length); + System.arraycopy(value, 0, http1, prefix.length, value.length); + http1[http1.length - 2] = '\r'; + http1[http1.length - 1] = '\n'; - /** - * The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one - * second. Never allocates — the same array is returned until the next refresh. - */ - public static byte[] bytes() { - return current; - } + ByteWriter encoded = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(encoded, 33, value, true); + return new Snapshot(http1, Arrays.copyOf(encoded.array(), encoded.length())); + } + + static String format(ZonedDateTime time) { + return FORMATTER.format(time); + } + + /** + * The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one second. + * Never allocates — the same array is returned until the next refresh. + */ + public static byte[] bytes() { + return current.http1; + } + + /** Current precompiled HPACK {@code date} field. */ + public static byte[] hpackBytes() { + return current.hpack; + } } diff --git a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java index 8e3d4ed..3e846f8 100644 --- a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java +++ b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java @@ -1,118 +1,164 @@ package dev.relism.flash.http; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; /** - * Pre-compiled byte representations of standard HTTP status lines. - * Uses a direct-access array for O(1) lookup with zero allocation. + * Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for + * O(1) lookup with zero allocation. */ public enum HttpStatus { - // 1xx - CONTINUE (100, "Continue"), - SWITCHING_PROTOCOLS (101, "Switching Protocols"), + // 1xx + CONTINUE(100, "Continue"), + SWITCHING_PROTOCOLS(101, "Switching Protocols"), - // 2xx - OK (200, "OK"), - CREATED (201, "Created"), - ACCEPTED (202, "Accepted"), - NO_CONTENT (204, "No Content"), - PARTIAL_CONTENT (206, "Partial Content"), + // 2xx + OK(200, "OK"), + CREATED(201, "Created"), + ACCEPTED(202, "Accepted"), + NO_CONTENT(204, "No Content"), + PARTIAL_CONTENT(206, "Partial Content"), - // 3xx - MOVED_PERMANENTLY (301, "Moved Permanently"), - FOUND (302, "Found"), - NOT_MODIFIED (304, "Not Modified"), - TEMPORARY_REDIRECT (307, "Temporary Redirect"), - PERMANENT_REDIRECT (308, "Permanent Redirect"), + // 3xx + MOVED_PERMANENTLY(301, "Moved Permanently"), + FOUND(302, "Found"), + NOT_MODIFIED(304, "Not Modified"), + TEMPORARY_REDIRECT(307, "Temporary Redirect"), + PERMANENT_REDIRECT(308, "Permanent Redirect"), - // 4xx - BAD_REQUEST (400, "Bad Request"), - UNAUTHORIZED (401, "Unauthorized"), - FORBIDDEN (403, "Forbidden"), - NOT_FOUND (404, "Not Found"), - METHOD_NOT_ALLOWED (405, "Method Not Allowed"), - NOT_ACCEPTABLE (406, "Not Acceptable"), - CONFLICT (409, "Conflict"), - GONE (410, "Gone"), - LENGTH_REQUIRED (411, "Length Required"), - PRECONDITION_FAILED (412, "Precondition Failed"), - PAYLOAD_TOO_LARGE (413, "Payload Too Large"), - URI_TOO_LONG (414, "URI Too Long"), - UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"), - RANGE_NOT_SATISFIABLE (416, "Range Not Satisfiable"), - EXPECTATION_FAILED (417, "Expectation Failed"), - MISDIRECTED_REQUEST (421, "Misdirected Request"), - UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"), - TOO_MANY_REQUESTS (429, "Too Many Requests"), - REQUEST_HEADER_FIELDS_TOO_LARGE (431, "Request Header Fields Too Large"), + // 4xx + BAD_REQUEST(400, "Bad Request"), + UNAUTHORIZED(401, "Unauthorized"), + FORBIDDEN(403, "Forbidden"), + NOT_FOUND(404, "Not Found"), + METHOD_NOT_ALLOWED(405, "Method Not Allowed"), + NOT_ACCEPTABLE(406, "Not Acceptable"), + CONFLICT(409, "Conflict"), + GONE(410, "Gone"), + LENGTH_REQUIRED(411, "Length Required"), + PRECONDITION_FAILED(412, "Precondition Failed"), + PAYLOAD_TOO_LARGE(413, "Payload Too Large"), + URI_TOO_LONG(414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE(416, "Range Not Satisfiable"), + EXPECTATION_FAILED(417, "Expectation Failed"), + MISDIRECTED_REQUEST(421, "Misdirected Request"), + UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"), + TOO_MANY_REQUESTS(429, "Too Many Requests"), + REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"), - // 5xx - INTERNAL_SERVER_ERROR (500, "Internal Server Error"), - NOT_IMPLEMENTED (501, "Not Implemented"), - BAD_GATEWAY (502, "Bad Gateway"), - SERVICE_UNAVAILABLE (503, "Service Unavailable"), - GATEWAY_TIMEOUT (504, "Gateway Timeout"), - HTTP_VERSION_NOT_SUPPORTED (505, "HTTP Version Not Supported"), - INSUFFICIENT_STORAGE (507, "Insufficient Storage"), - NETWORK_AUTHENTICATION_REQUIRED (511, "Network Authentication Required"); + // 5xx + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + NOT_IMPLEMENTED(501, "Not Implemented"), + BAD_GATEWAY(502, "Bad Gateway"), + SERVICE_UNAVAILABLE(503, "Service Unavailable"), + GATEWAY_TIMEOUT(504, "Gateway Timeout"), + HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version Not Supported"), + INSUFFICIENT_STORAGE(507, "Insufficient Storage"), + NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required"); - // ArrayIndexOutOfBoundsException from this static initializer the moment any constant - // above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST - // and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so - // adding a status code can never silently break class loading again. - private static final int MAX_STATUS_CODE; - private static final byte[][] INDEX; - private static final String[] REASONS; + // ArrayIndexOutOfBoundsException from this static initializer the moment any constant + // above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST + // and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so + // adding a status code can never silently break class loading again. + private static final int MAX_STATUS_CODE; + private static final byte[][] INDEX; + private static final byte[][] HPACK_INDEX; + private static final String[] REASONS; - static { - int max = 0; - for (HttpStatus s : values()) max = Math.max(max, s.code); - MAX_STATUS_CODE = max; - INDEX = new byte[MAX_STATUS_CODE + 1][]; - REASONS = new String[MAX_STATUS_CODE + 1]; - for (HttpStatus s : values()) { - INDEX[s.code] = s.bytes; - REASONS[s.code] = s.reason; - } + static { + int max = 0; + for (HttpStatus s : values()) max = Math.max(max, s.code); + MAX_STATUS_CODE = max; + INDEX = new byte[MAX_STATUS_CODE + 1][]; + HPACK_INDEX = new byte[MAX_STATUS_CODE + 1][]; + REASONS = new String[MAX_STATUS_CODE + 1]; + for (HttpStatus s : values()) { + INDEX[s.code] = s.bytes; + HPACK_INDEX[s.code] = s.hpackBytes; + REASONS[s.code] = s.reason; } + } - private final int code; - private final String reason; - private final byte[] bytes; + private final int code; + private final String reason; + private final byte[] bytes; + private final byte[] hpackBytes; - HttpStatus(int code, String reason) { - this.code = code; - this.reason = reason; - this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + HttpStatus(int code, String reason) { + this.code = code; + this.reason = reason; + this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + this.hpackBytes = encodeHpack(code); + } + + /** Numeric status code (e.g. {@code 200}). */ + public int code() { + return code; + } + + /** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */ + public byte[] bytes() { + return bytes; + } + + /** Precompiled HPACK representation of {@code :status}. */ + public byte[] hpackBytes() { + return hpackBytes; + } + + /** Reason phrase (e.g. {@code "OK"}). */ + public String reason() { + return reason; + } + + /** + * Returns pre-compiled status bytes for the given code. Access is O(1) and generates zero + * garbage. + */ + public static byte[] bytesForCode(int code) { + if (code >= 0 && code <= MAX_STATUS_CODE) { + return INDEX[code]; } + return null; + } - /** Numeric status code (e.g. {@code 200}). */ - public int code() { return code; } - - /** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */ - public byte[] bytes() { return bytes; } - - /** Reason phrase (e.g. {@code "OK"}). */ - public String reason() { return reason; } - - /** - * Returns pre-compiled status bytes for the given code. - * Access is O(1) and generates zero garbage. - */ - public static byte[] bytesForCode(int code) { - if (code >= 0 && code <= MAX_STATUS_CODE) { - return INDEX[code]; - } - return null; + /** Returns reason phrase for the given code, or null if unknown. */ + public static String reasonForCode(int code) { + if (code >= 0 && code <= MAX_STATUS_CODE) { + return REASONS[code]; } + return null; + } - /** Returns reason phrase for the given code, or null if unknown. */ - public static String reasonForCode(int code) { - if (code >= 0 && code <= MAX_STATUS_CODE) { - return REASONS[code]; - } - return null; + /** Returns the precompiled HPACK status field for a known code, or {@code null}. */ + public static byte[] hpackBytesForCode(int code) { + return code >= 0 && code <= MAX_STATUS_CODE ? HPACK_INDEX[code] : null; + } + + private static byte[] encodeHpack(int code) { + int staticIndex = + switch (code) { + case 200 -> 8; + case 204 -> 9; + case 206 -> 10; + case 304 -> 11; + case 400 -> 12; + case 404 -> 13; + case 500 -> 14; + default -> 0; + }; + ByteWriter out = new ByteWriter(8); + if (staticIndex != 0) { + HpackEncoder.writeIndexed(out, staticIndex); + } else { + byte[] value = { + (byte) ('0' + code / 100), (byte) ('0' + code / 10 % 10), (byte) ('0' + code % 10) + }; + HpackEncoder.writeLiteralWithNameIndex(out, 8, value, true); } -} \ No newline at end of file + return java.util.Arrays.copyOf(out.array(), out.length()); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java new file mode 100644 index 0000000..f6f47d6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java @@ -0,0 +1,94 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; + +/** + * Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal + * fields without indexing; consequently, concurrent streams never share mutable encoder state. + */ +public final class HpackEncoder { + private HpackEncoder() {} + + /** Declares that this endpoint will not use an encoder-side dynamic table. */ + public static void writeDynamicTableSizeUpdateZero(ByteWriter out) { + HpackIntegers.encode(out, 0x20, 5, 0); + } + + public static void writeIndexed(ByteWriter out, int staticIndex) { + if (staticIndex < 1 || staticIndex > HpackStaticTable.LENGTH) { + throw new IllegalArgumentException("invalid HPACK static index: " + staticIndex); + } + HpackIntegers.encode(out, 0x80, 7, staticIndex); + } + + public static void writeLiteral(ByteWriter out, byte[] name, byte[] value) { + writeLiteral(out, name, 0, name.length, value, 0, value.length, false); + } + + public static void writeLiteral( + ByteWriter out, + byte[] name, + int nameOff, + int nameLen, + byte[] value, + int valueOff, + int valueLen, + boolean huffmanValue) { + HpackIntegers.encode(out, 0, 4, 0); + writeLowercaseName(out, name, nameOff, nameLen); + writeString(out, value, valueOff, valueLen, huffmanValue); + } + + public static void writeLiteralWithNameIndex( + ByteWriter out, int nameIndex, byte[] value, boolean huffmanValue) { + writeLiteralWithNameIndex(out, nameIndex, value, 0, value.length, huffmanValue); + } + + public static void writeLiteralWithNameIndex( + ByteWriter out, + int nameIndex, + byte[] value, + int valueOff, + int valueLen, + boolean huffmanValue) { + if (nameIndex < 1 || nameIndex > HpackStaticTable.LENGTH) { + throw new IllegalArgumentException("invalid HPACK static name index: " + nameIndex); + } + HpackIntegers.encode(out, 0, 4, nameIndex); + writeString(out, value, valueOff, valueLen, huffmanValue); + } + + public static void writeLiteralNeverIndexed( + ByteWriter out, byte[] name, byte[] value, boolean huffmanValue) { + HpackIntegers.encode(out, 0x10, 4, 0); + writeLowercaseName(out, name, 0, name.length); + writeString(out, value, 0, value.length, huffmanValue); + } + + public static void writeLiteralNeverIndexedWithNameIndex( + ByteWriter out, int nameIndex, byte[] value, boolean huffmanValue) { + HpackIntegers.encode(out, 0x10, 4, nameIndex); + writeString(out, value, 0, value.length, huffmanValue); + } + + private static void writeLowercaseName(ByteWriter out, byte[] name, int nameOff, int nameLen) { + HpackIntegers.encode(out, 0, 7, nameLen); + int end = nameOff + nameLen; + for (int i = nameOff; i < end; i++) { + int value = name[i] & 0xff; + if (value >= 'A' && value <= 'Z') value += 'a' - 'A'; + assert value < 'A' || value > 'Z' : "HTTP/2 field names must be lowercase"; + out.writeByte((byte) value); + } + } + + private static void writeString(ByteWriter out, byte[] value, int off, int len, boolean huffman) { + if (huffman) { + HpackIntegers.encode(out, 0x80, 7, Huffman.encodedLength(value, off, len)); + Huffman.encode(out, value, off, len); + } else { + HpackIntegers.encode(out, 0, 7, len); + out.writeBytes(value, off, len); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java b/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java new file mode 100644 index 0000000..691a51e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java @@ -0,0 +1,239 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.DateHeader; +import dev.relism.flash.http.HttpStatus; +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import dev.relism.flash.http2.frame.WriteIntent; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.models.Response; +import dev.relism.flash.models.ResponseSerializer; + +/** + * Reusable per-stream HTTP/2 response serializer. It prepares a complete small response outside the + * connection write lock and exposes it as one {@link WriteIntent}. + */ +public final class Http2ResponseWriter implements WriteIntent, ResponseSerializer.FieldConsumer { + private static final int STATUS_NAME_LENGTH = 7; + private static final int CONTENT_LENGTH_NAME_LENGTH = 14; + + private final ByteWriter headerBlock; + private final ByteWriter output; + private final FrameWriteBuffer frames; + private final byte[] decimalScratch = new byte[10]; + private WriteIntent next; + private boolean huffmanDynamicValues; + private int streamId; + private long headerListSize; + private long maxHeaderListSize; + + public Http2ResponseWriter() { + this(1024, 2048); + } + + public Http2ResponseWriter(int initialHeaderCapacity, int initialOutputCapacity) { + headerBlock = new ByteWriter(initialHeaderCapacity); + output = new ByteWriter(initialOutputCapacity); + frames = new FrameWriteBuffer(output); + } + + /** + * Prepares a non-streaming response. Returns {@code false} when the body needs the deferred DATA + * flow-control path implemented by the stream scheduler. + */ + public boolean prepare( + Response response, + int streamId, + boolean headRequest, + boolean sendDate, + boolean sendContentLength, + boolean huffmanDynamicValues, + boolean emitTableSizeUpdate, + int maxFrameSize, + long maxHeaderListSize, + int availableFlowWindow) { + if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive"); + if (maxFrameSize <= 0) throw new IllegalArgumentException("maxFrameSize must be positive"); + if (response.isStreaming()) { + throw new IllegalArgumentException("streaming responses use the HTTP/2 DATA scheduler"); + } + + headerBlock.reset(); + output.reset(); + + byte[] body = response.getBody(); + int bodyLength = body == null ? 0 : body.length; + int statusCode = response.getStatusCode(); + boolean bodyForbidden = + statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200); + boolean suppressBody = headRequest || bodyForbidden; + if (!suppressBody + && bodyLength > 0 + && (bodyLength > maxFrameSize || bodyLength > availableFlowWindow)) { + return false; + } + + this.streamId = streamId; + this.huffmanDynamicValues = huffmanDynamicValues; + this.maxHeaderListSize = maxHeaderListSize; + headerListSize = 0; + next = null; + + if (emitTableSizeUpdate) HpackEncoder.writeDynamicTableSizeUpdateZero(headerBlock); + writeStatus(statusCode); + writeContentType(response.getContentType()); + + if (sendDate) { + addHeaderListSize(4, 29); + headerBlock.writeBytes(DateHeader.hpackBytes()); + } + if (sendContentLength && !bodyForbidden) { + addHeaderListSize(CONTENT_LENGTH_NAME_LENGTH, decimalLength(bodyLength)); + writeDecimalLiteral(28, bodyLength); + } + + ResponseSerializer.forEachCustomField(response, this); + writeHeaderFrames(maxFrameSize, suppressBody || bodyLength == 0); + if (!suppressBody && bodyLength > 0) { + frames.beginFrame(FrameType.DATA, FrameFlags.END_STREAM, streamId); + output.writeBytes(body); + frames.endFrame(); + } + return true; + } + + @Override + public void accept( + byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) { + if (isForbidden(name, nameOff, nameLen)) return; + addHeaderListSize(nameLen, valueLen); + HpackEncoder.writeLiteral( + headerBlock, name, nameOff, nameLen, value, valueOff, valueLen, huffmanDynamicValues); + } + + private void writeStatus(int statusCode) { + addHeaderListSize(STATUS_NAME_LENGTH, 3); + byte[] precompiled = HttpStatus.hpackBytesForCode(statusCode); + if (precompiled != null) { + headerBlock.writeBytes(precompiled); + return; + } + if (statusCode < 100 || statusCode > 999) { + throw new Http2StreamException( + streamId, Http2ErrorCode.INTERNAL_ERROR, "HTTP status must contain three digits"); + } + writeDecimalLiteral(8, statusCode); + } + + private void writeContentType(byte[] contentType) { + if (contentType == null || contentType.length == 0) return; + addHeaderListSize(12, contentType.length); + byte[] precompiled = ContentType.hpackBytesFor(contentType); + if (precompiled != null) { + headerBlock.writeBytes(precompiled); + } else { + HpackEncoder.writeLiteralWithNameIndex(headerBlock, 31, contentType, huffmanDynamicValues); + } + } + + private void writeDecimalLiteral(int nameIndex, int value) { + int length = decimalLength(value); + int offset = decimalScratch.length - length; + int current = value; + for (int i = decimalScratch.length - 1; i >= offset; i--) { + decimalScratch[i] = (byte) ('0' + current % 10); + current /= 10; + } + HpackEncoder.writeLiteralWithNameIndex( + headerBlock, nameIndex, decimalScratch, offset, length, huffmanDynamicValues); + } + + private void writeHeaderFrames(int maxFrameSize, boolean endStream) { + int remaining = headerBlock.length(); + int offset = 0; + boolean first = true; + do { + int fragment = Math.min(remaining, maxFrameSize); + boolean last = fragment == remaining; + int flags = last ? FrameFlags.END_HEADERS : 0; + if (first && endStream) flags |= FrameFlags.END_STREAM; + frames.beginFrame(first ? FrameType.HEADERS : FrameType.CONTINUATION, flags, streamId); + output.writeBytes(headerBlock.array(), offset, fragment); + frames.endFrame(); + offset += fragment; + remaining -= fragment; + first = false; + } while (remaining > 0); + } + + private void addHeaderListSize(int nameLength, int valueLength) { + headerListSize += nameLength + valueLength + 32L; + if (headerListSize > maxHeaderListSize) { + throw new Http2StreamException( + streamId, + Http2ErrorCode.INTERNAL_ERROR, + "response header list exceeds peer limit " + maxHeaderListSize); + } + } + + private static boolean isForbidden(byte[] name, int off, int len) { + return equalsAscii(name, off, len, "connection") + || equalsAscii(name, off, len, "keep-alive") + || equalsAscii(name, off, len, "proxy-connection") + || equalsAscii(name, off, len, "transfer-encoding") + || equalsAscii(name, off, len, "upgrade"); + } + + private static boolean equalsAscii(byte[] bytes, int off, int len, String expected) { + if (len != expected.length()) return false; + for (int i = 0; i < len; i++) { + int actual = bytes[off + i] & 0xff; + if (actual >= 'A' && actual <= 'Z') actual += 32; + if (actual != expected.charAt(i)) return false; + } + return true; + } + + private static int decimalLength(int value) { + if (value < 10) return 1; + if (value < 100) return 2; + if (value < 1000) return 3; + if (value < 10000) return 4; + if (value < 100000) return 5; + if (value < 1000000) return 6; + if (value < 10000000) return 7; + if (value < 100000000) return 8; + if (value < 1000000000) return 9; + return 10; + } + + @Override + public byte[] buffer() { + return output.array(); + } + + @Override + public int offset() { + return 0; + } + + @Override + public int length() { + return output.length(); + } + + @Override + public WriteIntent mpscNext() { + return next; + } + + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java b/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java index e8cb604..a9f0204 100644 --- a/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java +++ b/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java @@ -4,57 +4,48 @@ import java.nio.charset.StandardCharsets; import java.util.Arrays; /** - * A header name/value pair pre-encoded once (typically at boot, as a {@code static final} - * constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}. + * A header name/value pair pre-encoded once (typically at boot, as a {@code static final} constant) + * and reused across many responses via {@link Response#header(PreEncodedHeader)}. * - * The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line - * (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) — fine for h1, but not valid HPACK: HPACK encodes - * a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a - * pre-rendered h1 line carries no information an HPACK encoder could reuse. {@code - * PreEncodedHeader} instead precomputes the {@code name}/{@code value} bytes separately - * (still once, still at boot) so either protocol's writer can render them in its own format — - * {@link Response#header(byte[])} is kept, working, for h1-only callers, but is documented as - * ignored on a future HTTP/2 response path (there is no way to recover structured name/value data - * from an opaque pre-rendered line); prefer this class for any header a handler wants to send on - * both protocols. - * - * class stores the raw {@code name}/{@code value} bytes now, which is everything a future HPACK - * encoder needs to produce its own rendering from; it does not yet expose a precomputed HPACK - * byte form, since building one before HPACK exists would be speculative, untested API surface. + *
Unlike {@link Response#header(byte[])}, which accepts an opaque HTTP/1 field line, this class
+ * preserves the name/value boundary. HTTP/1 can render it as a line and HTTP/2 can encode it with
+ * HPACK, so one constant works on both protocols.
*/
public final class PreEncodedHeader {
- private final byte[] nameBytes;
- private final byte[] valueBytes;
+ private final byte[] nameBytes;
+ private final byte[] valueBytes;
- public PreEncodedHeader(String name, String value) {
- this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
- this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
- }
+ public PreEncodedHeader(String name, String value) {
+ this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
+ this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
+ }
- /** The header name's ASCII bytes, case as given to the constructor. Never copy-on-read — treat as immutable. */
- byte[] nameBytes() {
- return nameBytes;
- }
+ /** Header-name ASCII bytes. The returned array is immutable by contract. */
+ byte[] nameBytes() {
+ return nameBytes;
+ }
- /** The header value's ASCII bytes. Never copy-on-read — treat as immutable. */
- byte[] valueBytes() {
- return valueBytes;
- }
+ /** Header-value ASCII bytes. The returned array is immutable by contract. */
+ byte[] valueBytes() {
+ return valueBytes;
+ }
- @Override
- public String toString() {
- return new String(nameBytes, StandardCharsets.US_ASCII) + ": " + new String(valueBytes, StandardCharsets.US_ASCII);
- }
+ @Override
+ public String toString() {
+ return new String(nameBytes, StandardCharsets.US_ASCII)
+ + ": "
+ + new String(valueBytes, StandardCharsets.US_ASCII);
+ }
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (!(o instanceof PreEncodedHeader other)) return false;
- return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
- }
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof PreEncodedHeader other)) return false;
+ return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
+ }
- @Override
- public int hashCode() {
- return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
- }
+ @Override
+ public int hashCode() {
+ return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
+ }
}
diff --git a/flash/src/main/java/dev/relism/flash/models/Response.java b/flash/src/main/java/dev/relism/flash/models/Response.java
index a24144c..f694276 100644
--- a/flash/src/main/java/dev/relism/flash/models/Response.java
+++ b/flash/src/main/java/dev/relism/flash/models/Response.java
@@ -5,7 +5,6 @@ import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpStatus;
-
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -29,422 +28,514 @@ import java.util.List;
* }
*
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
- * connection, reset before every handler call rather than reallocated — the same treatment
- * {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
- * applies identically here). A handler that returns a different {@code Response} instance
- * (e.g. {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully
- * supported — that instance is a normal, unpooled, freshly-constructed object like any
- * public-constructor {@code Response} always was; only the connection driver's own default
- * instance is pooled and poisoned after use.
+ * connection, reset before every handler call rather than reallocated — the same treatment {@link
+ * Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which applies
+ * identically here). A handler that returns a different {@code Response} instance (e.g.
+ * {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully supported — that
+ * instance is a normal, unpooled, freshly-constructed object like any public-constructor {@code
+ * Response} always was; only the connection driver's own default instance is pooled and poisoned
+ * after use.
*/
public class Response {
- private int statusCode;
- private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
- private byte[] body;
- private InputStream stream;
- private long streamLength; // meaningful only when isStreaming() && !chunked
- private boolean chunked;
- private byte[] contentType;
+ private int statusCode;
+ private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
+ private byte[] body;
+ private InputStream stream;
+ private long streamLength; // meaningful only when isStreaming() && !chunked
+ private boolean chunked;
+ private byte[] contentType;
- // of a List h1-only: a rendered {@code "Name: Value\r\n"} line carries no structured name/value
+ * data an HPACK encoder could use, so this header is not representable on a HTTP/2 response path
+ * — prefer {@link #header(PreEncodedHeader)} for anything that must render correctly on both
+ * protocols. Kept for existing HTTP/1-only callers.
+ */
+ public Response header(byte[] preEncoded) {
+ checkActive();
+ checkHeaderBudget();
+ if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
+ rawHeaderLines.add(preEncoded);
+ recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
+ return this;
+ }
+
+ /** Prevents an unbounded header loop from growing the connection's response scratch state. */
+ private void checkHeaderBudget() {
+ if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
+ throw new IllegalStateException(
+ "response exceeds "
+ + Http1Limits.MAX_RESPONSE_HEADER_COUNT
+ + " headers — check for an unbounded loop calling header(...)");
}
+ }
- public Response(int statusCode, String text, ContentType contentType) {
- this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
+ private void checkHeaderRegionBudget() {
+ if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
+ throw new IllegalStateException(
+ "response header region exceeds "
+ + Http1Limits.MAX_RESPONSE_HEADER_BYTES
+ + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
}
+ }
- // -------------------------------------------------------------------------
- // Pooling
- // -------------------------------------------------------------------------
-
- /**
- * Repositions this instance for a new request/response cycle — clears the body, stream,
- * status, content type, and every header recorded by the previous cycle. Public because the
- * connection driver that owns the pooled instance lives in a different package (matching
- * {@link RequestLine#reset}'s precedent); user code never calls this.
- */
- public Response reset(int statusCode, ContentType contentType) {
- this.statusCode = statusCode;
- this.statusBytes = null;
- this.body = null;
- this.stream = null;
- this.streamLength = 0;
- this.chunked = false;
- this.contentType = contentType.getBytes();
- this.headerQuadCount = 0;
- this.headerCount = 0;
- if (rawHeaderLines != null) rawHeaderLines.clear();
- this.active = true;
- return this;
+ private void recordHeaderEntry(byte tag, int ref) {
+ if (headerTags == null) {
+ headerTags = new byte[16];
+ headerRefs = new int[16];
+ } else if (headerCount == headerTags.length) {
+ int grown = headerTags.length * 2;
+ headerTags = Arrays.copyOf(headerTags, grown);
+ headerRefs = Arrays.copyOf(headerRefs, grown);
}
+ headerTags[headerCount] = tag;
+ headerRefs[headerCount] = ref;
+ headerCount++;
+ }
- /**
- * Marks this instance unsafe for further use — see {@link Request#recycle()} for the full
- * rationale, identical here. {@code public} for the same cross-package reason.
- */
- public void recycle() {
- this.active = false;
+ private void ensureQuadCapacity(int neededQuads) {
+ int neededInts = neededQuads * 4;
+ if (neededInts <= headerQuads.length) return;
+ int grown = headerQuads.length;
+ while (grown < neededInts) grown *= 2;
+ headerQuads = Arrays.copyOf(headerQuads, grown);
+ }
+
+ // -------------------------------------------------------------------------
+ // State queries
+ // -------------------------------------------------------------------------
+
+ public boolean isStreaming() {
+ checkActive();
+ return stream != null;
+ }
+
+ public boolean isChunked() {
+ checkActive();
+ return chunked;
+ }
+
+ public int getStatusCode() {
+ checkActive();
+ return statusCode;
+ }
+
+ public byte[] getStatusBytes() {
+ checkActive();
+ return statusBytes;
+ }
+
+ public byte[] getBody() {
+ checkActive();
+ return body;
+ }
+
+ public byte[] getContentType() {
+ checkActive();
+ return contentType;
+ }
+
+ public InputStream getStream() {
+ checkActive();
+ return stream;
+ }
+
+ public long getStreamLength() {
+ checkActive();
+ return streamLength;
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal setters used by HttpServer for handler return values
+ // -------------------------------------------------------------------------
+
+ /**
+ * Sets the body from a handler return value. Accepted types: {@code byte[]}, {@link String},
+ * {@link CharSequence}. Any other non-null type throws {@link IllegalArgumentException} — return
+ * a {@code Response} directly, or serialize to {@code String}/{@code byte[]} before returning.
+ */
+ public Response setBody(Object body) {
+ checkActive();
+ if (body instanceof byte[] bytes) {
+ this.body = bytes;
+ return this;
}
-
- // -------------------------------------------------------------------------
- // Fluent mutators
- // -------------------------------------------------------------------------
-
- /** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
- public Response status(int code) { checkActive(); this.statusCode = code; this.statusBytes = null; return this; }
-
- /** Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the fluent return. */
- public void setStatusCode(int code) { status(code); }
-
- /** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
- * directly on the write path — zero lookup, zero allocation. */
- public Response status(HttpStatus status) { checkActive(); this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
- public Response type(ContentType ct) { checkActive(); this.contentType = ct.getBytes(); return this; }
- public Response type(String ct) { checkActive(); this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
-
- public Response body(byte[] bytes) {
- checkActive();
- this.body = bytes;
- this.stream = null;
- return this;
+ if (body instanceof String s) {
+ this.body = s.getBytes(StandardCharsets.UTF_8);
+ return this;
}
-
- public Response body(String text) {
- return body(text.getBytes(StandardCharsets.UTF_8));
+ if (body instanceof CharSequence s) {
+ this.body = s.toString().getBytes(StandardCharsets.UTF_8);
+ return this;
}
+ if (body != null)
+ throw new IllegalArgumentException(
+ "Handler returned unsupported type: "
+ + body.getClass().getName()
+ + " — return String, byte[], Response, or null");
+ return this;
+ }
- /** Streaming response with known length; written with {@code Content-Length}. */
- public Response stream(InputStream is, long length) {
- checkActive();
- this.stream = is;
- this.streamLength = length;
- this.chunked = false;
- this.body = null;
- return this;
- }
-
- /** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
- public Response chunked(InputStream is) {
- checkActive();
- this.stream = is;
- this.chunked = true;
- this.body = null;
- return this;
- }
-
- /**
- * 302 Found redirect. Clears the body, sets status and {@code Location} header.
- * Encoded once at call time; zero-alloc on the write path.
- *
- * h1-only: a rendered {@code "Name: Value\r\n"} line carries no structured
- * name/value data an HPACK encoder could use, so this header is not representable on a
- * future HTTP/2 response path — prefer {@link #header(PreEncodedHeader)} for anything that must
- * render correctly on both protocols. Kept for existing h1-only callers.
- */
- public Response header(byte[] preEncoded) {
- checkActive();
- checkHeaderBudget();
- if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
- rawHeaderLines.add(preEncoded);
- recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
- return this;
- }
-
- /** Prevents an unbounded header loop from growing the connection's response scratch state. */
- private void checkHeaderBudget() {
- if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
- throw new IllegalStateException("response exceeds " + Http1Limits.MAX_RESPONSE_HEADER_COUNT
- + " headers — check for an unbounded loop calling header(...)");
- }
- }
-
- private void checkHeaderRegionBudget() {
- if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
- throw new IllegalStateException("response header region exceeds "
- + Http1Limits.MAX_RESPONSE_HEADER_BYTES + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
- }
- }
-
- private void recordHeaderEntry(byte tag, int ref) {
- if (headerTags == null) {
- headerTags = new byte[16];
- headerRefs = new int[16];
- } else if (headerCount == headerTags.length) {
- int grown = headerTags.length * 2;
- headerTags = Arrays.copyOf(headerTags, grown);
- headerRefs = Arrays.copyOf(headerRefs, grown);
- }
- headerTags[headerCount] = tag;
- headerRefs[headerCount] = ref;
- headerCount++;
- }
-
- private void ensureQuadCapacity(int neededQuads) {
- int neededInts = neededQuads * 4;
- if (neededInts <= headerQuads.length) return;
- int grown = headerQuads.length;
- while (grown < neededInts) grown *= 2;
- headerQuads = Arrays.copyOf(headerQuads, grown);
- }
-
- // -------------------------------------------------------------------------
- // State queries
- // -------------------------------------------------------------------------
-
- public boolean isStreaming() { checkActive(); return stream != null; }
- public boolean isChunked() { checkActive(); return chunked; }
- public int getStatusCode() { checkActive(); return statusCode; }
- public byte[] getStatusBytes() { checkActive(); return statusBytes; }
- public byte[] getBody() { checkActive(); return body; }
- public byte[] getContentType() { checkActive(); return contentType; }
- public InputStream getStream() { checkActive(); return stream; }
- public long getStreamLength() { checkActive(); return streamLength; }
-
- // -------------------------------------------------------------------------
- // Internal setters used by HttpServer for handler return values
- // -------------------------------------------------------------------------
-
- /**
- * Sets the body from a handler return value. Accepted types: {@code byte[]},
- * {@link String}, {@link CharSequence}. Any other non-null type throws
- * {@link IllegalArgumentException} — return a {@code Response} directly, or
- * serialize to {@code String}/{@code byte[]} before returning.
- */
- public Response setBody(Object body) {
- checkActive();
- if (body instanceof byte[] bytes) { this.body = bytes; return this; }
- if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
- if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
- if (body != null) throw new IllegalArgumentException(
- "Handler returned unsupported type: " + body.getClass().getName()
- + " — return String, byte[], Response, or null");
- return this;
- }
-
- /**
- * Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list
- * if none were added. Introspection/debugging accessor — reconstructs each line from the
- * internal region on every call, so it is not on the zero-alloc write path; {@link
- * #writeHeaders} and {@link ResponseSerializer} read the internal representation directly
- * instead of going through this method.
- */
- public List{@code
+ * return res.redirect("/login");
+ * }
+ */
+ public Response redirect(String url) {
+ return redirect(HttpStatus.FOUND, url);
+ }
+
+ /**
+ * Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY}, {@link
+ * HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308) when
+ * semantics matter.
+ *
+ * {@code
+ * return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
+ * }
+ */
+ public Response redirect(HttpStatus status, String url) {
+ checkActive();
+ this.statusCode = status.code();
+ this.statusBytes = status.bytes();
+ this.body = null;
+ this.stream = null;
+ return header("Location", url);
+ }
+
+ /**
+ * reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
+ * {@code String} and re-encoding it — zero allocation once the region has grown to this
+ * connection's high-water mark.
+ */
+ public Response header(String name, String value) {
+ checkActive();
+ checkHeaderBudget();
+ if (headerRegion == null) {
+ headerRegion = new ByteWriter(128);
+ headerQuads = new int[16];
}
+ ensureQuadCapacity(headerQuadCount + 1);
+ int nameOff = headerRegion.length();
+ headerRegion.writeAscii(name);
+ int nameLen = headerRegion.length() - nameOff;
+ int valOff = headerRegion.length();
+ headerRegion.writeAscii(value);
+ int valLen = headerRegion.length() - valOff;
+ checkHeaderRegionBudget();
- // -------------------------------------------------------------------------
- // Constructors
- // -------------------------------------------------------------------------
+ int base = headerQuadCount * 4;
+ headerQuads[base] = nameOff;
+ headerQuads[base + 1] = nameLen;
+ headerQuads[base + 2] = valOff;
+ headerQuads[base + 3] = valLen;
+ recordHeaderEntry((byte) 0, headerQuadCount);
+ headerQuadCount++;
+ return this;
+ }
- public Response(int statusCode, ContentType contentType) {
- this.statusCode = statusCode;
- this.contentType = contentType.getBytes();
+ /**
+ * Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
+ * precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
+ * re-encode. Preserving the field structure makes it usable by both HTTP versions.
+ */
+ public Response header(PreEncodedHeader preEncoded) {
+ checkActive();
+ checkHeaderBudget();
+ if (headerRegion == null) {
+ headerRegion = new ByteWriter(128);
+ headerQuads = new int[16];
}
+ ensureQuadCapacity(headerQuadCount + 1);
+ byte[] nameBytes = preEncoded.nameBytes();
+ byte[] valueBytes = preEncoded.valueBytes();
+ int nameOff = headerRegion.length();
+ headerRegion.writeBytes(nameBytes);
+ int valOff = headerRegion.length();
+ headerRegion.writeBytes(valueBytes);
+ checkHeaderRegionBudget();
- public Response(int statusCode, byte[] body, ContentType contentType) {
- this(statusCode, contentType);
- this.body = body;
+ int base = headerQuadCount * 4;
+ headerQuads[base] = nameOff;
+ headerQuads[base + 1] = nameBytes.length;
+ headerQuads[base + 2] = valOff;
+ headerQuads[base + 3] = valueBytes.length;
+ recordHeaderEntry((byte) 0, headerQuadCount);
+ headerQuadCount++;
+ return this;
+ }
+
+ /**
+ * Adds a pre-encoded, fully-rendered header line (e.g. a static {@code "X-RateLimit-Limit:
+ * 100\r\n"} byte array pre-built at boot time). Zero-alloc on both the call path and the h1 write
+ * path.
+ *
+ * {@code
- * return res.redirect("/login");
- * }
- */
- public Response redirect(String url) {
- return redirect(HttpStatus.FOUND, url);
- }
-
- /**
- * Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY},
- * {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308)
- * when semantics matter.
- *
- * {@code
- * return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
- * }
- */
- public Response redirect(HttpStatus status, String url) {
- checkActive();
- this.statusCode = status.code();
- this.statusBytes = status.bytes();
- this.body = null;
- this.stream = null;
- return header("Location", url);
- }
-
- /**
- * reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
- * {@code String} and re-encoding it — zero allocation once the region has grown to this
- * connection's high-water mark.
- */
- public Response header(String name, String value) {
- checkActive();
- checkHeaderBudget();
- if (headerRegion == null) {
- headerRegion = new ByteWriter(128);
- headerQuads = new int[16];
- }
- ensureQuadCapacity(headerQuadCount + 1);
- int nameOff = headerRegion.length();
- headerRegion.writeAscii(name);
- int nameLen = headerRegion.length() - nameOff;
- int valOff = headerRegion.length();
- headerRegion.writeAscii(value);
- int valLen = headerRegion.length() - valOff;
- checkHeaderRegionBudget();
-
- int base = headerQuadCount * 4;
- headerQuads[base] = nameOff;
- headerQuads[base + 1] = nameLen;
- headerQuads[base + 2] = valOff;
- headerQuads[base + 3] = valLen;
- recordHeaderEntry((byte) 0, headerQuadCount);
- headerQuadCount++;
- return this;
- }
-
- /**
- * Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
- * precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
- * re-encode, and usable by a future HTTP/2 response path (unlike {@link #header(byte[])}) since
- * the name/value structure survives.
- */
- public Response header(PreEncodedHeader preEncoded) {
- checkActive();
- checkHeaderBudget();
- if (headerRegion == null) {
- headerRegion = new ByteWriter(128);
- headerQuads = new int[16];
- }
- ensureQuadCapacity(headerQuadCount + 1);
- byte[] nameBytes = preEncoded.nameBytes();
- byte[] valueBytes = preEncoded.valueBytes();
- int nameOff = headerRegion.length();
- headerRegion.writeBytes(nameBytes);
- int valOff = headerRegion.length();
- headerRegion.writeBytes(valueBytes);
- checkHeaderRegionBudget();
-
- int base = headerQuadCount * 4;
- headerQuads[base] = nameOff;
- headerQuads[base + 1] = nameBytes.length;
- headerQuads[base + 2] = valOff;
- headerQuads[base + 3] = valueBytes.length;
- recordHeaderEntry((byte) 0, headerQuadCount);
- headerQuadCount++;
- return this;
- }
-
- /**
- * Adds a pre-encoded, fully-rendered header line (e.g. a static
- * {@code "X-RateLimit-Limit: 100\r\n"} byte array pre-built at boot time). Zero-alloc on
- * both the call path and the h1 write path.
- *
- * Scope: response-object fields only, not connection framing
- * Deliberately does not enumerate {@code Content-Length}, {@code Connection}, or
- * {@code Date} — those are connection/transport framing decisions (body length, keep-alive
- * negotiation, wall-clock time), not properties of the {@code Response} object itself, and HTTP/2
- * has no equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific
- * fields in h2). Each protocol's own writer computes and emits those itself, exactly as
- * {@code Http1ResponseWriter} already did before this class existed.
+ *
+ * Deliberately does not enumerate {@code Content-Length}, {@code Connection}, or {@code
+ * Date} — those are connection/transport framing decisions (body length, keep-alive negotiation,
+ * wall-clock time), not properties of the {@code Response} object itself, and HTTP/2 has no
+ * equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific fields in
+ * h2). Each protocol's own writer computes and emits those itself, exactly as {@code
+ * Http1ResponseWriter} already did before this class existed.
*
* Scope: excludes {@link Response#header(byte[])}'s legacy entries
- * A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no
- * recoverable (name, value) structure — see that method's own Javadoc — so it cannot appear in
- * this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
- * Response#writeHeaders}, which handles both structured and raw entries, in the original call
- * order); a future HTTP/2 writer will not be able to.
+ *
+ * A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no recoverable
+ * (name, value) structure — see that method's own Javadoc — so it cannot appear in this
+ * enumeration. HTTP/1 still renders it via {@link Response#writeHeaders}; HTTP/2 cannot recover its
+ * field structure and ignores it.
*/
public final class ResponseSerializer {
- private ResponseSerializer() {}
+ private ResponseSerializer() {}
- /** One rendered header field: a byte range for the name, and a byte range for the value — both slices of caller-owned arrays, never copied. */
- @FunctionalInterface
- public interface FieldConsumer {
- void accept(byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
+ /**
+ * One rendered header field: a byte range for the name, and a byte range for the value — both
+ * slices of caller-owned arrays, never copied.
+ */
+ @FunctionalInterface
+ public interface FieldConsumer {
+ void accept(
+ byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
+ }
+
+ private static final byte[] CONTENT_TYPE_NAME =
+ "Content-Type".getBytes(StandardCharsets.US_ASCII);
+
+ /**
+ * Enumerates {@code response}'s fields in a fixed order: non-empty {@code Content-Type}, then
+ * structured custom fields in call order. Every range is a slice of existing response storage.
+ */
+ public static void forEachField(Response response, FieldConsumer consumer) {
+ byte[] ct = response.getContentType();
+ if (ct != null && ct.length > 0) {
+ consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
}
+ forEachCustomField(response, consumer);
+ }
- private static final byte[] CONTENT_TYPE_NAME = "Content-Type".getBytes(StandardCharsets.US_ASCII);
-
- /**
- * Enumerates {@code response}'s fields in a fixed, deterministic order: {@code Content-Type}
- * nothing, never an empty-valued header line), then every {@code header(String,String)}/
- * {@code header(PreEncodedHeader)}-added field in call order. Zero allocation: every byte
- * range handed to {@code consumer} is a slice of {@code response}'s own already-allocated
- * buffers.
- */
- public static void forEachField(Response response, FieldConsumer consumer) {
- byte[] ct = response.getContentType();
- if (ct != null && ct.length > 0) {
- consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
- }
- response.forEachStructuredField(consumer);
- }
+ /** Enumerates structured custom fields only, excluding {@code content-type}. */
+ public static void forEachCustomField(Response response, FieldConsumer consumer) {
+ response.forEachStructuredField(consumer);
+ }
}
diff --git a/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java b/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java
new file mode 100644
index 0000000..c4ffea5
--- /dev/null
+++ b/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java
@@ -0,0 +1,34 @@
+package dev.relism.flash.http;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import dev.relism.flash.http2.hpack.HpackDecoder;
+import dev.relism.fpr.core.ByteView;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+
+class ContentTypeHpackTest {
+ @Test
+ void everyNonEmptyTypeHasAValidPrecompiledField() {
+ for (ContentType type : ContentType.values()) {
+ if (type == ContentType.NONE) continue;
+ AtomicReference