From 9391f80f76362a1319eb81dcc6fce238195a0e29 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 13 Aug 2026 18:04:22 +0000 Subject: [PATCH] feat(core): add HTTP/2 response path --- README.md | 16 + flash/docs/http2/DECISIONS.md | 22 + flash/docs/http2/HPACK.md | 19 + flash/docs/http2/IMPLEMENTATION-PLAN.md | 38 +- .../message/Http2ResponseWriterBenchmark.java | 44 + .../flash/extension/FlashConfiguration.java | 6 + .../dev/relism/flash/http/ContentType.java | 119 ++- .../dev/relism/flash/http/DateHeader.java | 88 +- .../dev/relism/flash/http/HttpStatus.java | 238 +++-- .../flash/http2/hpack/HpackEncoder.java | 94 ++ .../http2/message/Http2ResponseWriter.java | 239 +++++ .../relism/flash/models/PreEncodedHeader.java | 79 +- .../dev/relism/flash/models/Response.java | 877 ++++++++++-------- .../flash/models/ResponseSerializer.java | 76 +- .../flash/http/ContentTypeHpackTest.java | 34 + .../dev/relism/flash/http/DateHeaderTest.java | 15 + .../flash/http/HttpStatusHpackTest.java | 38 + .../flash/http2/hpack/HpackEncoderTest.java | 80 ++ .../message/Http2ResponseWriterTest.java | 159 ++++ .../models/ResponseSerializerParityTest.java | 66 ++ 20 files changed, 1684 insertions(+), 663 deletions(-) create mode 100644 flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java create mode 100644 flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java create mode 100644 flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java 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 of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder + - // char[] + String + getBytes() chain per header(String,String) call). Two backing stores, - // unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully - // pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to - // decompose into the same region: - // tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4) - // tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines - private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back - private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen) - private int headerQuadCount; - private List rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim - private byte[] headerTags; // one entry per header(), in call order: 0 or 1 - private int[] headerRefs; // one entry per header(), in call order: index into the tag's store - private int headerCount; // total header() calls this response has recorded + // of a List of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder + + // char[] + String + getBytes() chain per header(String,String) call). Two backing stores, + // unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully + // pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to + // decompose into the same region: + // tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4) + // tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines + private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back + private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen) + private int headerQuadCount; + private List rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim + private byte[] headerTags; // one entry per header(), in call order: 0 or 1 + private int[] headerRefs; // one entry per header(), in call order: index into the tag's store + private int headerCount; // total header() calls this response has recorded - private boolean active = true; - private static volatile boolean poisoningEnabled = Flash.DEV; + private boolean active = true; + private static volatile boolean poisoningEnabled = Flash.DEV; - /** Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. */ - static void setPoisoningEnabledForTesting(boolean enabled) { - poisoningEnabled = enabled; + /** + * Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. + */ + static void setPoisoningEnabledForTesting(boolean enabled) { + poisoningEnabled = enabled; + } + + private void checkActive() { + if (poisoningEnabled && !active) { + throw new IllegalStateException( + "Response used after the handler returned — do not retain a Response past the handler"); } + } - private void checkActive() { - if (poisoningEnabled && !active) { - throw new IllegalStateException( - "Response used after the handler returned — do not retain a Response past the handler"); - } + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + public Response(int statusCode, ContentType contentType) { + this.statusCode = statusCode; + this.contentType = contentType.getBytes(); + } + + public Response(int statusCode, byte[] body, ContentType contentType) { + this(statusCode, contentType); + this.body = body; + } + + public Response(int statusCode, String text, ContentType contentType) { + this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType); + } + + // ------------------------------------------------------------------------- + // 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; + } + + /** + * 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; + } + + // ------------------------------------------------------------------------- + // 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; + } + + public Response body(String text) { + return body(text.getBytes(StandardCharsets.UTF_8)); + } + + /** 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. + * + *

{@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. + * + *

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. - * - *

{@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. - * - *

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 getHeaders() { - checkActive(); - if (headerCount == 0) return List.of(); - List result = new ArrayList<>(headerCount); - for (int i = 0; i < headerCount; i++) { - if (headerTags[i] == 1) { - result.add(rawHeaderLines.get(headerRefs[i])); - } else { - int base = headerRefs[i] * 4; - byte[] region = headerRegion.array(); - int nameOff = headerQuads[base], nameLen = headerQuads[base + 1]; - int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3]; - byte[] line = new byte[nameLen + 2 + valLen + 2]; - int p = 0; - System.arraycopy(region, nameOff, line, p, nameLen); p += nameLen; - line[p++] = ':'; line[p++] = ' '; - System.arraycopy(region, valOff, line, p, valLen); p += valLen; - line[p++] = '\r'; line[p] = '\n'; - result.add(line); - } - } - return result; - } - - /** - * Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} — - * This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below - * (the {@code OutputStream} equivalent) exists for the streaming-body write paths that - * cannot fold their whole write into one scratch buffer. - */ - public void writeHeadersInto(ByteWriter head) { - for (int i = 0; i < headerCount; i++) { - if (headerTags[i] == 1) { - head.writeBytes(rawHeaderLines.get(headerRefs[i])); - } else { - int base = headerRefs[i] * 4; - byte[] region = headerRegion.array(); - head.writeBytes(region, headerQuads[base], headerQuads[base + 1]); - head.writeByte((byte) ':'); head.writeByte((byte) ' '); - head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]); - head.writeByte((byte) '\r'); head.writeByte((byte) '\n'); - } - } - } - - /** Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers are set or on a warm region. */ - public void writeHeaders(OutputStream out) throws IOException { - for (int i = 0; i < headerCount; i++) { - if (headerTags[i] == 1) { - out.write(rawHeaderLines.get(headerRefs[i])); - } else { - int base = headerRefs[i] * 4; - byte[] region = headerRegion.array(); - out.write(region, headerQuads[base], headerQuads[base + 1]); - out.write(':'); out.write(' '); - out.write(region, headerQuads[base + 2], headerQuads[base + 3]); - out.write('\r'); out.write('\n'); - } - } - } - - // ── Internal: name/value field enumeration for ResponseSerializer ────────── - - /** - * Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as - * a structured (name, value) byte range — not the {@code header(byte[])} legacy - * entries, which have no such structure (see that method's own Javadoc). Package-private: - * {@link ResponseSerializer} is this method's only caller. - */ - void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) { - if (headerQuadCount == 0) return; + /** + * 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 getHeaders() { + checkActive(); + if (headerCount == 0) return List.of(); + List result = new ArrayList<>(headerCount); + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + result.add(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; byte[] region = headerRegion.array(); - for (int i = 0; i < headerQuadCount; i++) { - int base = i * 4; - consumer.accept(region, headerQuads[base], headerQuads[base + 1], - region, headerQuads[base + 2], headerQuads[base + 3]); - } + int nameOff = headerQuads[base], nameLen = headerQuads[base + 1]; + int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3]; + byte[] line = new byte[nameLen + 2 + valLen + 2]; + int p = 0; + System.arraycopy(region, nameOff, line, p, nameLen); + p += nameLen; + line[p++] = ':'; + line[p++] = ' '; + System.arraycopy(region, valOff, line, p, valLen); + p += valLen; + line[p++] = '\r'; + line[p] = '\n'; + result.add(line); + } } + return result; + } - @Override - public String toString() { - return "Response(statusCode=" + statusCode + ", contentType=" - + (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + ")"; + /** + * Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} — This is + * what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below (the {@code + * OutputStream} equivalent) exists for the streaming-body write paths that cannot fold their + * whole write into one scratch buffer. + */ + public void writeHeadersInto(ByteWriter head) { + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + head.writeBytes(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; + byte[] region = headerRegion.array(); + head.writeBytes(region, headerQuads[base], headerQuads[base + 1]); + head.writeByte((byte) ':'); + head.writeByte((byte) ' '); + head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]); + head.writeByte((byte) '\r'); + head.writeByte((byte) '\n'); + } } + } + + /** + * Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers + * are set or on a warm region. + */ + public void writeHeaders(OutputStream out) throws IOException { + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + out.write(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; + byte[] region = headerRegion.array(); + out.write(region, headerQuads[base], headerQuads[base + 1]); + out.write(':'); + out.write(' '); + out.write(region, headerQuads[base + 2], headerQuads[base + 3]); + out.write('\r'); + out.write('\n'); + } + } + } + + // ── Internal: name/value field enumeration for ResponseSerializer ────────── + + /** + * Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as a + * structured (name, value) byte range — not the {@code header(byte[])} legacy entries, + * which have no such structure (see that method's own Javadoc). Package-private: {@link + * ResponseSerializer} is this method's only caller. + */ + void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) { + if (headerQuadCount == 0) return; + byte[] region = headerRegion.array(); + for (int i = 0; i < headerQuadCount; i++) { + int base = i * 4; + consumer.accept( + region, + headerQuads[base], + headerQuads[base + 1], + region, + headerQuads[base + 2], + headerQuads[base + 3]); + } + } + + @Override + public String toString() { + return "Response(statusCode=" + + statusCode + + ", contentType=" + + (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + + ")"; + } } diff --git a/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java b/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java index d8c3fa0..70488af 100644 --- a/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java +++ b/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java @@ -4,48 +4,54 @@ import java.nio.charset.StandardCharsets; /** * The protocol-neutral enumeration of a {@link Response}'s header fields — one source of truth - * consumed by every protocol's own writer, so {@code Content-Type}/custom-header logic is never - * duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future HTTP/2 encoder - * encoder will render the same fields via HPACK. + * consumed by every protocol's writer, so field selection cannot drift between HTTP/1 and HTTP/2. * *

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 decoded = new AtomicReference<>(); + byte[] block = type.getHpackBytes(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> decoded.set(text(name) + "=" + text(value))); + assertEquals( + "content-type=" + new String(type.getBytes(), StandardCharsets.US_ASCII), decoded.get()); + } + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java b/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java new file mode 100644 index 0000000..7d26051 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java @@ -0,0 +1,15 @@ +package dev.relism.flash.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; + +class DateHeaderTest { + @Test + void imfFixdateAlwaysUsesTwoDigitDayOfMonth() { + ZonedDateTime thirdOfMonth = ZonedDateTime.of(2026, 8, 3, 7, 5, 9, 0, ZoneOffset.UTC); + assertEquals("Mon, 03 Aug 2026 07:05:09 GMT", DateHeader.format(thirdOfMonth)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java b/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java new file mode 100644 index 0000000..c77af34 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java @@ -0,0 +1,38 @@ +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 HttpStatusHpackTest { + @Test + void everyStatusHasAValidPrecompiledField() { + for (HttpStatus status : HttpStatus.values()) { + AtomicReference decoded = new AtomicReference<>(); + byte[] block = status.hpackBytes(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> decoded.set(text(name) + "=" + text(value))); + assertEquals(":status=" + status.code(), decoded.get()); + } + } + + @Test + void commonStaticStatusIsOneByte() { + assertEquals(1, HttpStatus.OK.hpackBytes().length); + assertEquals(0x88, HttpStatus.OK.hpackBytes()[0] & 0xff); + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java new file mode 100644 index 0000000..d652f36 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java @@ -0,0 +1,80 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HpackEncoderTest { + @Test + void status200IsOneIndexedByte() { + ByteWriter out = new ByteWriter(16); + HpackEncoder.writeIndexed(out, 8); + assertEquals(1, out.length()); + assertEquals(0x88, out.array()[0] & 0xff); + } + + @Test + void representationsRoundTripThroughDecoder() { + ByteWriter out = new ByteWriter(128); + HpackEncoder.writeDynamicTableSizeUpdateZero(out); + HpackEncoder.writeIndexed(out, 8); + HpackEncoder.writeLiteralWithNameIndex(out, 31, ascii("application/json"), true); + HpackEncoder.writeLiteral(out, ascii("X-Trace"), ascii("abc123")); + HpackEncoder.writeLiteralNeverIndexed(out, ascii("authorization"), ascii("secret"), false); + + List fields = new ArrayList<>(); + List sensitive = new ArrayList<>(); + new HpackDecoder() + .decode( + out.array(), + 0, + out.length(), + (name, value, never) -> { + fields.add(text(name) + "=" + text(value)); + sensitive.add(never); + }); + + assertEquals( + List.of( + ":status=200", + "content-type=application/json", + "x-trace=abc123", + "authorization=secret"), + fields); + assertEquals(List.of(false, false, false, true), sensitive); + } + + @Test + void tableSizeUpdateZeroIsCanonical() { + ByteWriter out = new ByteWriter(16); + HpackEncoder.writeDynamicTableSizeUpdateZero(out); + assertArrayEquals(new byte[] {0x20}, java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void huffmanLiteralIsSmallerForTypicalValue() { + byte[] value = ascii("application/json"); + ByteWriter raw = new ByteWriter(32); + ByteWriter compressed = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(raw, 31, value, false); + HpackEncoder.writeLiteralWithNameIndex(compressed, 31, value, true); + assertTrue(compressed.length() < raw.length()); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static String text(ByteView value) { + byte[] bytes = new byte[value.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java b/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java new file mode 100644 index 0000000..6b695ce --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java @@ -0,0 +1,159 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.http.ContentType; +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.hpack.HpackDecoder; +import dev.relism.flash.models.Response; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2ResponseWriterTest { + @Test + void serializesOrderedHeadersAndOneDataFrame() { + Response response = + new Response(200, "hello", ContentType.TEXT_PLAIN) + .header("X-Trace", "abc") + .header("Connection", "close") + .header("Upgrade", "websocket"); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + assertTrue(writer.prepare(response, 3, false, false, true, false, true, 16_384, 4096, 65_535)); + Parsed parsed = parse(writer); + + assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types); + assertEquals(FrameFlags.END_HEADERS, parsed.flags.get(0)); + assertEquals(FrameFlags.END_STREAM, parsed.flags.get(1)); + assertEquals("hello", new String(parsed.data, StandardCharsets.US_ASCII)); + assertEquals( + List.of(":status=200", "content-type=text/plain", "content-length=5", "x-trace=abc"), + decode(parsed.headerBlock)); + } + + @Test + void splitsHeaderBlockIntoAdjacentContinuationFrames() { + Response response = + new Response(200, ContentType.NONE) + .header("x-long", "abcdefghijklmnopqrstuvwxyz0123456789"); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + assertTrue(writer.prepare(response, 1, false, false, false, false, false, 12, 4096, 65_535)); + Parsed parsed = parse(writer); + + assertTrue(parsed.types.size() > 1); + assertEquals(FrameType.HEADERS, parsed.types.get(0)); + for (int i = 1; i < parsed.types.size(); i++) { + assertEquals(FrameType.CONTINUATION, parsed.types.get(i)); + } + assertEquals(0, parsed.flags.get(0) & FrameFlags.END_HEADERS); + assertTrue((parsed.flags.get(parsed.flags.size() - 1) & FrameFlags.END_HEADERS) != 0); + assertEquals( + List.of(":status=200", "x-long=abcdefghijklmnopqrstuvwxyz0123456789"), + decode(parsed.headerBlock)); + } + + @Test + void headAndBodyForbiddenStatusesEndOnHeaders() { + for (Response response : + List.of( + new Response(200, "body", ContentType.TEXT_PLAIN), + new Response(204, "body", ContentType.TEXT_PLAIN), + new Response(304, "body", ContentType.TEXT_PLAIN))) { + boolean head = response.getStatusCode() == 200; + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertTrue( + writer.prepare(response, 1, head, false, true, false, false, 16_384, 4096, 65_535)); + Parsed parsed = parse(writer); + assertEquals(List.of(FrameType.HEADERS), parsed.types); + assertTrue((parsed.flags.get(0) & FrameFlags.END_STREAM) != 0); + List fields = decode(parsed.headerBlock); + if (head) assertTrue(fields.contains("content-length=4")); + else assertFalse(fields.stream().anyMatch(value -> value.startsWith("content-length="))); + } + } + + @Test + void insufficientWindowDefersWithoutProducingPartialResponse() { + Response response = new Response(200, "body", ContentType.TEXT_PLAIN); + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertFalse(writer.prepare(response, 1, false, false, true, false, false, 16_384, 3, 3)); + assertEquals(0, writer.length()); + } + + @Test + void peerHeaderListLimitFailsTheStream() { + Response response = new Response(200, ContentType.TEXT_PLAIN); + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertThrows( + Http2StreamException.class, + () -> writer.prepare(response, 5, false, false, true, false, false, 16_384, 41, 65_535)); + } + + private static Parsed parse(Http2ResponseWriter writer) { + Parsed parsed = new Parsed(); + byte[] wire = writer.buffer(); + int position = 0; + while (position < writer.length()) { + int length = + ((wire[position] & 0xff) << 16) + | ((wire[position + 1] & 0xff) << 8) + | (wire[position + 2] & 0xff); + FrameType type = FrameType.fromCode(wire[position + 3] & 0xff); + int flags = wire[position + 4] & 0xff; + byte[] payload = Arrays.copyOfRange(wire, position + 9, position + 9 + length); + parsed.types.add(type); + parsed.flags.add(flags); + if (type == FrameType.HEADERS || type == FrameType.CONTINUATION) { + parsed.appendHeaders(payload); + } else if (type == FrameType.DATA) { + parsed.data = payload; + } + position += 9 + length; + } + parsed.headerBlock = Arrays.copyOf(parsed.headerBlock, parsed.headerLength); + return parsed; + } + + private static List decode(byte[] block) { + List fields = new ArrayList<>(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> fields.add(text(name) + "=" + text(value))); + return fields; + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } + + private static final class Parsed { + final List types = new ArrayList<>(); + final List flags = new ArrayList<>(); + byte[] headerBlock = new byte[64]; + int headerLength; + byte[] data = new byte[0]; + + void appendHeaders(byte[] fragment) { + if (headerLength + fragment.length > headerBlock.length) { + headerBlock = Arrays.copyOf(headerBlock, (headerLength + fragment.length) * 2); + } + System.arraycopy(fragment, 0, headerBlock, headerLength, fragment.length); + headerLength += fragment.length; + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java new file mode 100644 index 0000000..0689964 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java @@ -0,0 +1,66 @@ +package dev.relism.flash.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http1.Http1ResponseWriter; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.message.Http2ResponseWriter; +import dev.relism.flash.transport.ScratchPool; +import dev.relism.fpr.core.ByteView; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ResponseSerializerParityTest { + @Test + void bothProtocolsRenderTheSameResponseFields() throws Exception { + Response response = + new Response(201, "created", ContentType.JSON) + .header("Cache-Control", "no-store") + .header(new PreEncodedHeader("X-Trace", "abc")); + + ByteArrayOutputStream http1 = new ByteArrayOutputStream(); + Http1ResponseWriter.writeResponse( + http1, response, HttpMethod.GET, true, false, new ScratchPool().acquire()); + Map http1Fields = parseHttp1(http1.toString(StandardCharsets.US_ASCII)); + http1Fields.remove("connection"); + + Http2ResponseWriter writer = new Http2ResponseWriter(); + writer.prepare(response, 1, false, false, true, false, false, 16_384, 4096, 65_535); + int headerLength = + ((writer.buffer()[0] & 0xff) << 16) + | ((writer.buffer()[1] & 0xff) << 8) + | (writer.buffer()[2] & 0xff); + Map http2Fields = new LinkedHashMap<>(); + new HpackDecoder() + .decode( + writer.buffer(), + 9, + headerLength, + (name, value, never) -> http2Fields.put(text(name), text(value))); + http2Fields.remove(":status"); + + assertEquals(http1Fields, http2Fields); + } + + private static Map parseHttp1(String message) { + Map fields = new LinkedHashMap<>(); + int end = message.indexOf("\r\n\r\n"); + String[] lines = message.substring(0, end).split("\r\n"); + for (int i = 1; i < lines.length; i++) { + int colon = lines[i].indexOf(':'); + fields.put(lines[i].substring(0, colon).toLowerCase(), lines[i].substring(colon + 2)); + } + return fields; + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +}