feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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
|
`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.
|
(`.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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -905,3 +905,25 @@ records the partial external gate rather than claiming whole-section conformance
|
|||||||
the combined selection without skips.
|
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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -61,3 +61,22 @@ decoder and all downstream views simpler.
|
|||||||
- Ten million deterministic random blocks; only typed protocol rejections may escape.
|
- 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
|
- 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.
|
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.
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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 | — | — |
|
| 10 — Stream state machine + dispatch | not started | — | — |
|
||||||
| 11 — DATA, flow control, bodies | not started | — | — |
|
| 11 — DATA, flow control, bodies | not started | — | — |
|
||||||
| 12 — Trailers, half-close, gRPC | 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
|
connection with a protocol error, then verifies that a second connection completes a fresh SETTINGS
|
||||||
exchange and PING/PONG. **Phase**: 8.
|
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
|
# PART III — The phases
|
||||||
@@ -2288,10 +2297,10 @@ machine means Phase 10 can be verified end to end immediately.
|
|||||||
### Files
|
### Files
|
||||||
|
|
||||||
Created:
|
Created:
|
||||||
- `h2/hpack/HpackEncoder.java`
|
- `http2/hpack/HpackEncoder.java`
|
||||||
- `h2/hpack/PreEncodedHeader.java` — a boot-time-built pair of renderings (h1 field line bytes,
|
- `models/PreEncodedHeader.java` — the existing protocol-neutral name/value model is reused;
|
||||||
HPACK field bytes) — see Phase 6 task 4.
|
there is deliberately no second HTTP/2-specific header type.
|
||||||
- `h2/message/Http2ResponseWriter.java` — turns a `Response` into HEADERS (+ CONTINUATION if
|
- `http2/message/Http2ResponseWriter.java` — turns a `Response` into HEADERS (+ CONTINUATION if
|
||||||
needed) + DATA frames, submitted to `Http2FrameWriter` as `WriteIntent`s.
|
needed) + DATA frames, submitted to `Http2FrameWriter` as `WriteIntent`s.
|
||||||
|
|
||||||
Modified:
|
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**.
|
custom headers: **0 B/op**.
|
||||||
|
|
||||||
### Safety checks
|
### Safety checks
|
||||||
- [ ] Field names lowercase (dev-mode assertion)
|
- [x] Field names lowercase
|
||||||
- [ ] Connection-specific headers stripped
|
- [x] Connection-specific headers stripped
|
||||||
- [ ] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not
|
- [x] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not
|
||||||
interleaved with anything
|
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
|
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)
|
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;
|
- [x] `content-length`, when emitted, matches the actual DATA byte count.
|
||||||
a mismatch is a gRPC-breaking bug that is otherwise invisible)
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- `HpackEncoderTest` — output decodes back via `HpackDecoder` to the input (round-trip is the
|
- `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.
|
raw-`byte[]` overload no longer suffices on h2.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] `:status 200` encodes to exactly one byte.
|
- [x] `:status 200` encodes to exactly one byte.
|
||||||
- [ ] Round-trip tests green.
|
- [x] Round-trip tests green.
|
||||||
- [ ] Parity test green.
|
- [x] Parity test green.
|
||||||
- [ ] 0 B/op.
|
- [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;
|
@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};
|
* 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
|
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
package dev.relism.flash.http;
|
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 lombok.Getter;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
|
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@link #getBytes()}
|
||||||
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
|
* returns the pre-computed array directly, never allocates.
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
public enum ContentType {
|
public enum ContentType {
|
||||||
|
|
||||||
NONE(""),
|
NONE(""),
|
||||||
|
|
||||||
// Text
|
// Text
|
||||||
@@ -58,8 +59,30 @@ public enum ContentType {
|
|||||||
VIDEO_WEBM("video/webm");
|
VIDEO_WEBM("video/webm");
|
||||||
|
|
||||||
private final byte[] bytes;
|
private final byte[] bytes;
|
||||||
|
private final byte[] hpackBytes;
|
||||||
|
private static final ContentType[] ALL = values();
|
||||||
|
|
||||||
ContentType(String value) {
|
ContentType(String value) {
|
||||||
this.bytes = value.getBytes(StandardCharsets.UTF_8);
|
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;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.time.ZonedDateTime;
|
import java.time.ZonedDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
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
|
* A daemon refreshes both protocol renderings once per second. Response writers only perform one
|
||||||
* daemon thread refreshes a pre-encoded {@code "Date: ...\r\n"} field line once per second into
|
* volatile read and copy already-encoded bytes into their output buffer.
|
||||||
* a {@code volatile byte[]}; {@code Http1ResponseWriter} writes it with one
|
|
||||||
* {@code OutputStream#write(byte[])} — the cost per response is one volatile read and one
|
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
public final class DateHeader {
|
public final class DateHeader {
|
||||||
|
|
||||||
private DateHeader() {
|
private DateHeader() {}
|
||||||
}
|
|
||||||
|
|
||||||
private static final DateTimeFormatter FORMATTER =
|
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 {
|
static {
|
||||||
Thread refresher = new Thread(() -> {
|
Thread refresher =
|
||||||
|
new Thread(
|
||||||
|
() -> {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(1000);
|
Thread.sleep(1000);
|
||||||
@@ -33,21 +38,40 @@ public final class DateHeader {
|
|||||||
}
|
}
|
||||||
current = encode();
|
current = encode();
|
||||||
}
|
}
|
||||||
}, "flash-date-header");
|
},
|
||||||
|
"flash-date-header");
|
||||||
refresher.setDaemon(true);
|
refresher.setDaemon(true);
|
||||||
refresher.start();
|
refresher.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] encode() {
|
private static Snapshot encode() {
|
||||||
String line = "Date: " + FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)) + "\r\n";
|
byte[] value = format(ZonedDateTime.now(ZoneOffset.UTC)).getBytes(StandardCharsets.US_ASCII);
|
||||||
return line.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
|
* The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one second.
|
||||||
* second. Never allocates — the same array is returned until the next refresh.
|
* Never allocates — the same array is returned until the next refresh.
|
||||||
*/
|
*/
|
||||||
public static byte[] bytes() {
|
public static byte[] bytes() {
|
||||||
return current;
|
return current.http1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current precompiled HPACK {@code date} field. */
|
||||||
|
public static byte[] hpackBytes() {
|
||||||
|
return current.hpack;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package dev.relism.flash.http;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of standard HTTP status lines.
|
* Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for
|
||||||
* Uses a direct-access array for O(1) lookup with zero allocation.
|
* O(1) lookup with zero allocation.
|
||||||
*/
|
*/
|
||||||
public enum HttpStatus {
|
public enum HttpStatus {
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ public enum HttpStatus {
|
|||||||
// adding a status code can never silently break class loading again.
|
// adding a status code can never silently break class loading again.
|
||||||
private static final int MAX_STATUS_CODE;
|
private static final int MAX_STATUS_CODE;
|
||||||
private static final byte[][] INDEX;
|
private static final byte[][] INDEX;
|
||||||
|
private static final byte[][] HPACK_INDEX;
|
||||||
private static final String[] REASONS;
|
private static final String[] REASONS;
|
||||||
|
|
||||||
static {
|
static {
|
||||||
@@ -71,9 +73,11 @@ public enum HttpStatus {
|
|||||||
for (HttpStatus s : values()) max = Math.max(max, s.code);
|
for (HttpStatus s : values()) max = Math.max(max, s.code);
|
||||||
MAX_STATUS_CODE = max;
|
MAX_STATUS_CODE = max;
|
||||||
INDEX = new byte[MAX_STATUS_CODE + 1][];
|
INDEX = new byte[MAX_STATUS_CODE + 1][];
|
||||||
|
HPACK_INDEX = new byte[MAX_STATUS_CODE + 1][];
|
||||||
REASONS = new String[MAX_STATUS_CODE + 1];
|
REASONS = new String[MAX_STATUS_CODE + 1];
|
||||||
for (HttpStatus s : values()) {
|
for (HttpStatus s : values()) {
|
||||||
INDEX[s.code] = s.bytes;
|
INDEX[s.code] = s.bytes;
|
||||||
|
HPACK_INDEX[s.code] = s.hpackBytes;
|
||||||
REASONS[s.code] = s.reason;
|
REASONS[s.code] = s.reason;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,25 +85,38 @@ public enum HttpStatus {
|
|||||||
private final int code;
|
private final int code;
|
||||||
private final String reason;
|
private final String reason;
|
||||||
private final byte[] bytes;
|
private final byte[] bytes;
|
||||||
|
private final byte[] hpackBytes;
|
||||||
|
|
||||||
HttpStatus(int code, String reason) {
|
HttpStatus(int code, String reason) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
|
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
|
||||||
|
this.hpackBytes = encodeHpack(code);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Numeric status code (e.g. {@code 200}). */
|
/** 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. */
|
/** 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"}). */
|
/** 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.
|
* Returns pre-compiled status bytes for the given code. Access is O(1) and generates zero
|
||||||
* Access is O(1) and generates zero garbage.
|
* garbage.
|
||||||
*/
|
*/
|
||||||
public static byte[] bytesForCode(int code) {
|
public static byte[] bytesForCode(int code) {
|
||||||
if (code >= 0 && code <= MAX_STATUS_CODE) {
|
if (code >= 0 && code <= MAX_STATUS_CODE) {
|
||||||
@@ -115,4 +132,33 @@ public enum HttpStatus {
|
|||||||
}
|
}
|
||||||
return null;
|
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;
|
import java.util.Arrays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final}
|
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final} constant)
|
||||||
* constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}.
|
* and reused across many responses via {@link Response#header(PreEncodedHeader)}.
|
||||||
*
|
*
|
||||||
* The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line
|
* <p>Unlike {@link Response#header(byte[])}, which accepts an opaque HTTP/1 field line, this class
|
||||||
* (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) — fine for h1, but not valid HPACK: HPACK encodes
|
* preserves the name/value boundary. HTTP/1 can render it as a line and HTTP/2 can encode it with
|
||||||
* a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a
|
* HPACK, so one constant works on both protocols.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
public final class PreEncodedHeader {
|
public final class PreEncodedHeader {
|
||||||
private final byte[] nameBytes;
|
private final byte[] nameBytes;
|
||||||
@@ -31,19 +20,21 @@ public final class PreEncodedHeader {
|
|||||||
this.valueBytes = value.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. */
|
/** Header-name ASCII bytes. The returned array is immutable by contract. */
|
||||||
byte[] nameBytes() {
|
byte[] nameBytes() {
|
||||||
return 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() {
|
byte[] valueBytes() {
|
||||||
return valueBytes;
|
return valueBytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
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
|
@Override
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import dev.relism.flash.bytes.ByteWriter;
|
|||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.Http1Limits;
|
import dev.relism.flash.http.Http1Limits;
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
@@ -29,13 +28,13 @@ import java.util.List;
|
|||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
|
* 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
|
* connection, reset before every handler call rather than reallocated — the same treatment {@link
|
||||||
* {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
|
* Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which applies
|
||||||
* applies identically here). <b>A handler that returns a different {@code Response} instance</b>
|
* identically here). <b>A handler that returns a different {@code Response} instance</b> (e.g.
|
||||||
* (e.g. {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully
|
* {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully supported — that
|
||||||
* supported — that instance is a normal, unpooled, freshly-constructed object like any
|
* instance is a normal, unpooled, freshly-constructed object like any public-constructor {@code
|
||||||
* public-constructor {@code Response} always was; only the connection driver's own default
|
* Response} always was; only the connection driver's own default instance is pooled and poisoned
|
||||||
* instance is pooled and poisoned after use.
|
* after use.
|
||||||
*/
|
*/
|
||||||
public class Response {
|
public class Response {
|
||||||
private int statusCode;
|
private int statusCode;
|
||||||
@@ -64,7 +63,9 @@ public class Response {
|
|||||||
private boolean active = true;
|
private boolean active = true;
|
||||||
private static volatile boolean poisoningEnabled = Flash.DEV;
|
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) {
|
static void setPoisoningEnabledForTesting(boolean enabled) {
|
||||||
poisoningEnabled = enabled;
|
poisoningEnabled = enabled;
|
||||||
}
|
}
|
||||||
@@ -99,10 +100,10 @@ public class Response {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repositions this instance for a new request/response cycle — clears the body, stream,
|
* Repositions this instance for a new request/response cycle — clears the body, stream, status,
|
||||||
* status, content type, and every header recorded by the previous cycle. Public because the
|
* content type, and every header recorded by the previous cycle. Public because the connection
|
||||||
* connection driver that owns the pooled instance lives in a different package (matching
|
* driver that owns the pooled instance lives in a different package (matching {@link
|
||||||
* {@link RequestLine#reset}'s precedent); user code never calls this.
|
* RequestLine#reset}'s precedent); user code never calls this.
|
||||||
*/
|
*/
|
||||||
public Response reset(int statusCode, ContentType contentType) {
|
public Response reset(int statusCode, ContentType contentType) {
|
||||||
this.statusCode = statusCode;
|
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. */
|
/** 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. */
|
* Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used directly on
|
||||||
public Response status(HttpStatus status) { checkActive(); this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
|
* the write path — zero lookup, zero allocation.
|
||||||
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 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) {
|
public Response body(byte[] bytes) {
|
||||||
checkActive();
|
checkActive();
|
||||||
@@ -174,8 +202,8 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 302 Found redirect. Clears the body, sets status and {@code Location} header.
|
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
|
||||||
* Encoded once at call time; zero-alloc on the write path.
|
* call time; zero-alloc on the write path.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* return res.redirect("/login");
|
* return res.redirect("/login");
|
||||||
@@ -186,9 +214,9 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY},
|
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY}, {@link
|
||||||
* {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308)
|
* HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308) when
|
||||||
* when semantics matter.
|
* semantics matter.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
|
* 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
|
* 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
|
* 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
|
* re-encode. Preserving the field structure makes it usable by both HTTP versions.
|
||||||
* the name/value structure survives.
|
|
||||||
*/
|
*/
|
||||||
public Response header(PreEncodedHeader preEncoded) {
|
public Response header(PreEncodedHeader preEncoded) {
|
||||||
checkActive();
|
checkActive();
|
||||||
@@ -267,14 +294,14 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds a pre-encoded, fully-rendered header line (e.g. a static
|
* Adds a pre-encoded, fully-rendered header line (e.g. a static {@code "X-RateLimit-Limit:
|
||||||
* {@code "X-RateLimit-Limit: 100\r\n"} byte array pre-built at boot time). Zero-alloc on
|
* 100\r\n"} byte array pre-built at boot time). Zero-alloc on both the call path and the h1 write
|
||||||
* both the call path and the h1 write path.
|
* path.
|
||||||
*
|
*
|
||||||
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured
|
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured name/value
|
||||||
* name/value data an HPACK encoder could use, so this header is not representable on a
|
* data an HPACK encoder could use, so this header is not representable on a HTTP/2 response path
|
||||||
* future HTTP/2 response path — prefer {@link #header(PreEncodedHeader)} for anything that must
|
* — prefer {@link #header(PreEncodedHeader)} for anything that must render correctly on both
|
||||||
* render correctly on both protocols. Kept for existing h1-only callers.
|
* protocols. Kept for existing HTTP/1-only callers.
|
||||||
*/
|
*/
|
||||||
public Response header(byte[] preEncoded) {
|
public Response header(byte[] preEncoded) {
|
||||||
checkActive();
|
checkActive();
|
||||||
@@ -288,15 +315,19 @@ public class Response {
|
|||||||
/** Prevents an unbounded header loop from growing the connection's response scratch state. */
|
/** Prevents an unbounded header loop from growing the connection's response scratch state. */
|
||||||
private void checkHeaderBudget() {
|
private void checkHeaderBudget() {
|
||||||
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
|
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(...)");
|
+ " headers — check for an unbounded loop calling header(...)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void checkHeaderRegionBudget() {
|
private void checkHeaderRegionBudget() {
|
||||||
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
|
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
|
||||||
throw new IllegalStateException("response header region exceeds "
|
throw new IllegalStateException(
|
||||||
+ Http1Limits.MAX_RESPONSE_HEADER_BYTES + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
|
"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
|
// State queries
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
public boolean isStreaming() { checkActive(); return stream != null; }
|
public boolean isStreaming() {
|
||||||
public boolean isChunked() { checkActive(); return chunked; }
|
checkActive();
|
||||||
public int getStatusCode() { checkActive(); return statusCode; }
|
return stream != null;
|
||||||
public byte[] getStatusBytes() { checkActive(); return statusBytes; }
|
}
|
||||||
public byte[] getBody() { checkActive(); return body; }
|
|
||||||
public byte[] getContentType() { checkActive(); return contentType; }
|
public boolean isChunked() {
|
||||||
public InputStream getStream() { checkActive(); return stream; }
|
checkActive();
|
||||||
public long getStreamLength() { checkActive(); return streamLength; }
|
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
|
// Internal setters used by HttpServer for handler return values
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the body from a handler return value. Accepted types: {@code byte[]},
|
* Sets the body from a handler return value. Accepted types: {@code byte[]}, {@link String},
|
||||||
* {@link String}, {@link CharSequence}. Any other non-null type throws
|
* {@link CharSequence}. Any other non-null type throws {@link IllegalArgumentException} — return
|
||||||
* {@link IllegalArgumentException} — return a {@code Response} directly, or
|
* a {@code Response} directly, or serialize to {@code String}/{@code byte[]} before returning.
|
||||||
* serialize to {@code String}/{@code byte[]} before returning.
|
|
||||||
*/
|
*/
|
||||||
public Response setBody(Object body) {
|
public Response setBody(Object body) {
|
||||||
checkActive();
|
checkActive();
|
||||||
if (body instanceof byte[] bytes) { this.body = bytes; return this; }
|
if (body instanceof byte[] bytes) {
|
||||||
if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
|
this.body = bytes;
|
||||||
if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
|
return this;
|
||||||
if (body != null) throw new IllegalArgumentException(
|
}
|
||||||
"Handler returned unsupported type: " + body.getClass().getName()
|
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 String, byte[], Response, or null");
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list
|
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list if
|
||||||
* if none were added. Introspection/debugging accessor — reconstructs each line from the
|
* none were added. Introspection/debugging accessor — reconstructs each line from the internal
|
||||||
* internal region on every call, so it is not on the zero-alloc write path; {@link
|
* region on every call, so it is not on the zero-alloc write path; {@link #writeHeaders} and
|
||||||
* #writeHeaders} and {@link ResponseSerializer} read the internal representation directly
|
* {@link ResponseSerializer} read the internal representation directly instead of going through
|
||||||
* instead of going through this method.
|
* this method.
|
||||||
*/
|
*/
|
||||||
public List<byte[]> getHeaders() {
|
public List<byte[]> getHeaders() {
|
||||||
checkActive();
|
checkActive();
|
||||||
@@ -377,10 +449,14 @@ public class Response {
|
|||||||
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
|
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
|
||||||
byte[] line = new byte[nameLen + 2 + valLen + 2];
|
byte[] line = new byte[nameLen + 2 + valLen + 2];
|
||||||
int p = 0;
|
int p = 0;
|
||||||
System.arraycopy(region, nameOff, line, p, nameLen); p += nameLen;
|
System.arraycopy(region, nameOff, line, p, nameLen);
|
||||||
line[p++] = ':'; line[p++] = ' ';
|
p += nameLen;
|
||||||
System.arraycopy(region, valOff, line, p, valLen); p += valLen;
|
line[p++] = ':';
|
||||||
line[p++] = '\r'; line[p] = '\n';
|
line[p++] = ' ';
|
||||||
|
System.arraycopy(region, valOff, line, p, valLen);
|
||||||
|
p += valLen;
|
||||||
|
line[p++] = '\r';
|
||||||
|
line[p] = '\n';
|
||||||
result.add(line);
|
result.add(line);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -388,10 +464,10 @@ public class Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} —
|
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} — This is
|
||||||
* This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below
|
* what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below (the {@code
|
||||||
* (the {@code OutputStream} equivalent) exists for the streaming-body write paths that
|
* OutputStream} equivalent) exists for the streaming-body write paths that cannot fold their
|
||||||
* cannot fold their whole write into one scratch buffer.
|
* whole write into one scratch buffer.
|
||||||
*/
|
*/
|
||||||
public void writeHeadersInto(ByteWriter head) {
|
public void writeHeadersInto(ByteWriter head) {
|
||||||
for (int i = 0; i < headerCount; i++) {
|
for (int i = 0; i < headerCount; i++) {
|
||||||
@@ -401,14 +477,19 @@ public class Response {
|
|||||||
int base = headerRefs[i] * 4;
|
int base = headerRefs[i] * 4;
|
||||||
byte[] region = headerRegion.array();
|
byte[] region = headerRegion.array();
|
||||||
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
|
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.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 {
|
public void writeHeaders(OutputStream out) throws IOException {
|
||||||
for (int i = 0; i < headerCount; i++) {
|
for (int i = 0; i < headerCount; i++) {
|
||||||
if (headerTags[i] == 1) {
|
if (headerTags[i] == 1) {
|
||||||
@@ -417,9 +498,11 @@ public class Response {
|
|||||||
int base = headerRefs[i] * 4;
|
int base = headerRefs[i] * 4;
|
||||||
byte[] region = headerRegion.array();
|
byte[] region = headerRegion.array();
|
||||||
out.write(region, headerQuads[base], headerQuads[base + 1]);
|
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(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 ──────────
|
// ── Internal: name/value field enumeration for ResponseSerializer ──────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as
|
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as a
|
||||||
* a structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy
|
* structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy entries,
|
||||||
* entries, which have no such structure (see that method's own Javadoc). Package-private:
|
* which have no such structure (see that method's own Javadoc). Package-private: {@link
|
||||||
* {@link ResponseSerializer} is this method's only caller.
|
* ResponseSerializer} is this method's only caller.
|
||||||
*/
|
*/
|
||||||
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
|
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
|
||||||
if (headerQuadCount == 0) return;
|
if (headerQuadCount == 0) return;
|
||||||
byte[] region = headerRegion.array();
|
byte[] region = headerRegion.array();
|
||||||
for (int i = 0; i < headerQuadCount; i++) {
|
for (int i = 0; i < headerQuadCount; i++) {
|
||||||
int base = i * 4;
|
int base = i * 4;
|
||||||
consumer.accept(region, headerQuads[base], headerQuads[base + 1],
|
consumer.accept(
|
||||||
region, headerQuads[base + 2], headerQuads[base + 3]);
|
region,
|
||||||
|
headerQuads[base],
|
||||||
|
headerQuads[base + 1],
|
||||||
|
region,
|
||||||
|
headerQuads[base + 2],
|
||||||
|
headerQuads[base + 3]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return "Response(statusCode=" + statusCode + ", contentType="
|
return "Response(statusCode="
|
||||||
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + ")";
|
+ 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
|
* 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
|
* consumed by every protocol's writer, so field selection cannot drift between HTTP/1 and HTTP/2.
|
||||||
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future HTTP/2 encoder
|
|
||||||
* encoder will render the same fields via HPACK.
|
|
||||||
*
|
*
|
||||||
* <h3>Scope: response-object fields only, not connection framing</h3>
|
* <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
|
* Deliberately does <b>not</b> enumerate {@code Content-Length}, {@code Connection}, or {@code
|
||||||
* negotiation, wall-clock time), not properties of the {@code Response} object itself, and HTTP/2
|
* Date} — those are connection/transport framing decisions (body length, keep-alive negotiation,
|
||||||
* has no equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific
|
* wall-clock time), not properties of the {@code Response} object itself, and HTTP/2 has no
|
||||||
* fields in h2). Each protocol's own writer computes and emits those itself, exactly as
|
* equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific fields in
|
||||||
* {@code Http1ResponseWriter} already did before this class existed.
|
* 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>
|
* <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
|
* A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no recoverable
|
||||||
* this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
|
* (name, value) structure — see that method's own Javadoc — so it cannot appear in this
|
||||||
* Response#writeHeaders}, which handles both structured and raw entries, in the original call
|
* enumeration. HTTP/1 still renders it via {@link Response#writeHeaders}; HTTP/2 cannot recover its
|
||||||
* order); a future HTTP/2 writer will not be able to.
|
* field structure and ignores it.
|
||||||
*/
|
*/
|
||||||
public final class ResponseSerializer {
|
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. */
|
/**
|
||||||
|
* 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
|
@FunctionalInterface
|
||||||
public interface FieldConsumer {
|
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}
|
* Enumerates {@code response}'s fields in a fixed order: non-empty {@code Content-Type}, then
|
||||||
* nothing, never an empty-valued header line), then every {@code header(String,String)}/
|
* structured custom fields in call order. Every range is a slice of existing response storage.
|
||||||
* {@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) {
|
public static void forEachField(Response response, FieldConsumer consumer) {
|
||||||
byte[] ct = response.getContentType();
|
byte[] ct = response.getContentType();
|
||||||
if (ct != null && ct.length > 0) {
|
if (ct != null && ct.length > 0) {
|
||||||
consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
|
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);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user