feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
20 changed files with 1684 additions and 663 deletions
Showing only changes of commit 9391f80f76 - Show all commits
+16
View File
@@ -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
```
+22
View File
@@ -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.
---
+19
View File
@@ -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.
+23 -15
View File
@@ -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 19
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 19,
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).
---
@@ -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();
}
}
@@ -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
@@ -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_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"),
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_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_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_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();
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;
}
}
@@ -1,29 +1,34 @@
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);
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) {}
private static volatile Snapshot current = encode();
static {
Thread refresher = new Thread(() -> {
Thread refresher =
new Thread(
() -> {
while (true) {
try {
Thread.sleep(1000);
@@ -33,21 +38,40 @@ public final class DateHeader {
}
current = encode();
}
}, "flash-date-header");
},
"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';
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.
* 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;
return current.http1;
}
/** Current precompiled HPACK {@code date} field. */
public static byte[] hpackBytes() {
return current.hpack;
}
}
@@ -1,62 +1,63 @@
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"),
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"),
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"),
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"),
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");
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
@@ -64,6 +65,7 @@ public enum HttpStatus {
// 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 {
@@ -71,9 +73,11 @@ public enum HttpStatus {
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;
}
}
@@ -81,25 +85,38 @@ public enum HttpStatus {
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);
this.hpackBytes = encodeHpack(code);
}
/** Numeric status code (e.g. {@code 200}). */
public int code() { return code; }
public int code() {
return code;
}
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
public byte[] bytes() { return bytes; }
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; }
public String reason() {
return reason;
}
/**
* Returns pre-compiled status bytes for the given code.
* Access is O(1) and generates zero garbage.
* 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) {
@@ -115,4 +132,33 @@ public enum HttpStatus {
}
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);
}
return java.util.Arrays.copyOf(out.array(), out.length());
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -4,23 +4,12 @@ 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 <em>separately</em>
* (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.
* <p>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;
@@ -31,19 +20,21 @@ public final class PreEncodedHeader {
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. */
/** 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. */
/** 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);
return new String(nameBytes, StandardCharsets.US_ASCII)
+ ": "
+ new String(valueBytes, StandardCharsets.US_ASCII);
}
@Override
@@ -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,13 +28,13 @@ import java.util.List;
* }</pre>
*
* 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). <b>A handler that returns a different {@code Response} instance</b>
* (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). <b>A handler that returns a different {@code Response} instance</b> (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;
@@ -64,7 +63,9 @@ public class Response {
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. */
/**
* Test-only override of the dev-mode poisoning check mirrors {@code Request}'s identical hook.
*/
static void setPoisoningEnabledForTesting(boolean enabled) {
poisoningEnabled = enabled;
}
@@ -99,10 +100,10 @@ public class Response {
// -------------------------------------------------------------------------
/**
* 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.
* 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;
@@ -132,16 +133,43 @@ public class Response {
// -------------------------------------------------------------------------
/** 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; }
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); }
/**
* 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; }
/**
* 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();
@@ -174,8 +202,8 @@ public class Response {
}
/**
* 302 Found redirect. Clears the body, sets status and {@code Location} header.
* Encoded once at call time; zero-alloc on the write path.
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
* call time; zero-alloc on the write path.
*
* <pre>{@code
* return res.redirect("/login");
@@ -186,9 +214,9 @@ public class Response {
}
/**
* 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.
* 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.
*
* <pre>{@code
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
@@ -237,8 +265,7 @@ public class Response {
/**
* 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.
* re-encode. Preserving the field structure makes it usable by both HTTP versions.
*/
public Response header(PreEncodedHeader preEncoded) {
checkActive();
@@ -267,14 +294,14 @@ public class Response {
}
/**
* 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.
* 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.
*
* <p><b>h1-only</b>: 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.
* <p><b>h1-only</b>: 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();
@@ -288,15 +315,19 @@ public class Response {
/** 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
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(...)");
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(...)");
}
}
@@ -326,42 +357,83 @@ public class Response {
// 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; }
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.
* 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()
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.
* 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<byte[]> getHeaders() {
checkActive();
@@ -377,10 +449,14 @@ public class Response {
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';
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);
}
}
@@ -388,10 +464,10 @@ public class Response {
}
/**
* 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.
* 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++) {
@@ -401,14 +477,19 @@ public class Response {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
head.writeByte((byte) ':'); head.writeByte((byte) ' ');
head.writeByte((byte) ':');
head.writeByte((byte) ' ');
head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]);
head.writeByte((byte) '\r'); head.writeByte((byte) '\n');
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. */
/**
* 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) {
@@ -417,9 +498,11 @@ public class Response {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
out.write(region, headerQuads[base], headerQuads[base + 1]);
out.write(':'); out.write(' ');
out.write(':');
out.write(' ');
out.write(region, headerQuads[base + 2], headerQuads[base + 3]);
out.write('\r'); out.write('\n');
out.write('\r');
out.write('\n');
}
}
}
@@ -427,24 +510,32 @@ public class Response {
// 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 <b>not</b> 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.
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as a
* structured (name, value) byte range <b>not</b> 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]);
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) + ")";
return "Response(statusCode="
+ statusCode
+ ", contentType="
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null)
+ ")";
}
}
@@ -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.
*
* <h3>Scope: response-object fields only, not connection framing</h3>
* Deliberately does <b>not</b> 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 <b>not</b> 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.
*
* <h3>Scope: excludes {@link Response#header(byte[])}'s legacy entries</h3>
* 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() {}
/** 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. */
/**
* 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);
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);
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.
* 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);
}
/** Enumerates structured custom fields only, excluding {@code content-type}. */
public static void forEachCustomField(Response response, FieldConsumer consumer) {
response.forEachStructuredField(consumer);
}
}
@@ -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<String> 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);
}
}
@@ -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));
}
}
@@ -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<String> 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);
}
}
@@ -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<String> fields = new ArrayList<>();
List<Boolean> 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);
}
}
@@ -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<String> 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<String> decode(byte[] block) {
List<String> 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<FrameType> types = new ArrayList<>();
final List<Integer> 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;
}
}
}
@@ -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<String, String> 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<String, String> 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<String, String> parseHttp1(String message) {
Map<String, String> 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);
}
}