feat(core): add HTTP/2 response path

This commit is contained in:
Zakaria El Orche
2026-08-13 18:04:22 +00:00
parent cfa192e689
commit 9391f80f76
20 changed files with 1684 additions and 663 deletions
@@ -0,0 +1,44 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Response;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures the steady-state allocation cost of a representative fixed HTTP/2 response. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2ResponseWriterBenchmark {
private Http2ResponseWriter writer;
private Response response;
@Setup
public void setup() {
writer = new Http2ResponseWriter();
response =
new Response(200, "hello", ContentType.JSON)
.header(new PreEncodedHeader("cache-control", "no-store"))
.header(new PreEncodedHeader("x-trace", "abc123"));
writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535);
}
@Benchmark
public int encodeResponse() {
writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535);
return writer.length();
}
}
@@ -95,6 +95,12 @@ public class FlashConfiguration {
*/
@Builder.Default boolean http2Enabled = false;
/**
* Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always
* compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses.
*/
@Builder.Default boolean h2HuffmanDynamicValues = false;
/**
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true};
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
@@ -1,65 +1,88 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
/**
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@link #getBytes()}
* returns the pre-computed array directly, never allocates.
*/
@Getter
public enum ContentType {
NONE(""),
NONE (""),
// Text
TEXT_PLAIN("text/plain"),
TEXT_HTML("text/html"),
TEXT_CSS("text/css"),
TEXT_JAVASCRIPT("text/javascript"),
TEXT_XML("text/xml"),
TEXT_CSV("text/csv"),
TEXT_MARKDOWN("text/markdown"),
TEXT_EVENT_STREAM("text/event-stream"),
// Text
TEXT_PLAIN ("text/plain"),
TEXT_HTML ("text/html"),
TEXT_CSS ("text/css"),
TEXT_JAVASCRIPT ("text/javascript"),
TEXT_XML ("text/xml"),
TEXT_CSV ("text/csv"),
TEXT_MARKDOWN ("text/markdown"),
TEXT_EVENT_STREAM ("text/event-stream"),
// Application
JSON("application/json"),
XML("application/xml"),
BINARY("application/octet-stream"),
PDF("application/pdf"),
ZIP("application/zip"),
GZIP("application/gzip"),
FORM_URLENCODED("application/x-www-form-urlencoded"),
MULTIPART_FORM("multipart/form-data"),
GRAPHQL("application/graphql"),
NDJSON("application/x-ndjson"),
MSGPACK("application/msgpack"),
CBOR("application/cbor"),
LD_JSON("application/ld+json"),
// Application
JSON ("application/json"),
XML ("application/xml"),
BINARY ("application/octet-stream"),
PDF ("application/pdf"),
ZIP ("application/zip"),
GZIP ("application/gzip"),
FORM_URLENCODED ("application/x-www-form-urlencoded"),
MULTIPART_FORM ("multipart/form-data"),
GRAPHQL ("application/graphql"),
NDJSON ("application/x-ndjson"),
MSGPACK ("application/msgpack"),
CBOR ("application/cbor"),
LD_JSON ("application/ld+json"),
// Image
IMAGE_PNG("image/png"),
IMAGE_JPEG("image/jpeg"),
IMAGE_GIF("image/gif"),
IMAGE_WEBP("image/webp"),
IMAGE_SVG("image/svg+xml"),
IMAGE_ICO("image/x-icon"),
IMAGE_AVIF("image/avif"),
// Image
IMAGE_PNG ("image/png"),
IMAGE_JPEG ("image/jpeg"),
IMAGE_GIF ("image/gif"),
IMAGE_WEBP ("image/webp"),
IMAGE_SVG ("image/svg+xml"),
IMAGE_ICO ("image/x-icon"),
IMAGE_AVIF ("image/avif"),
// Font
FONT_WOFF("font/woff"),
FONT_WOFF2("font/woff2"),
// Font
FONT_WOFF ("font/woff"),
FONT_WOFF2 ("font/woff2"),
// Audio / Video
AUDIO_MPEG("audio/mpeg"),
AUDIO_OGG("audio/ogg"),
VIDEO_MP4("video/mp4"),
VIDEO_WEBM("video/webm");
// Audio / Video
AUDIO_MPEG ("audio/mpeg"),
AUDIO_OGG ("audio/ogg"),
VIDEO_MP4 ("video/mp4"),
VIDEO_WEBM ("video/webm");
private final byte[] bytes;
private final byte[] hpackBytes;
private static final ContentType[] ALL = values();
private final byte[] bytes;
ContentType(String value) {
this.bytes = value.getBytes(StandardCharsets.UTF_8);
ContentType(String value) {
this.bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0) {
this.hpackBytes = bytes;
} else {
ByteWriter out = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(out, 31, bytes, true);
this.hpackBytes = Arrays.copyOf(out.array(), out.length());
}
}
/** Precompiled HPACK {@code content-type} field, or an empty array for {@link #NONE}. */
public byte[] getHpackBytes() {
return hpackBytes;
}
/** Finds the boot-time HPACK rendering for a response content-type byte array. */
public static byte[] hpackBytesFor(byte[] value) {
for (ContentType type : ALL) {
if (type.bytes == value || Arrays.equals(type.bytes, value)) return type.hpackBytes;
}
return null;
}
}
@@ -1,53 +1,77 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Locale;
/**
* Flash never emitted it. Rather than formatting a timestamp on every response, a single
* daemon thread refreshes a pre-encoded {@code "Date: ...\r\n"} field line once per second into
* a {@code volatile byte[]}; {@code Http1ResponseWriter} writes it with one
* {@code OutputStream#write(byte[])} — the cost per response is one volatile read and one
*
* A daemon refreshes both protocol renderings once per second. Response writers only perform one
* volatile read and copy already-encoded bytes into their output buffer.
*/
public final class DateHeader {
private DateHeader() {
}
private DateHeader() {}
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
.withZone(ZoneOffset.UTC);
private static volatile byte[] current = encode();
private record Snapshot(byte[] http1, byte[] hpack) {}
static {
Thread refresher = new Thread(() -> {
while (true) {
private static volatile Snapshot current = encode();
static {
Thread refresher =
new Thread(
() -> {
while (true) {
try {
Thread.sleep(1000);
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
Thread.currentThread().interrupt();
return;
}
current = encode();
}
}, "flash-date-header");
refresher.setDaemon(true);
refresher.start();
}
}
},
"flash-date-header");
refresher.setDaemon(true);
refresher.start();
}
private static byte[] encode() {
String line = "Date: " + FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)) + "\r\n";
return line.getBytes(StandardCharsets.US_ASCII);
}
private static Snapshot encode() {
byte[] value = format(ZonedDateTime.now(ZoneOffset.UTC)).getBytes(StandardCharsets.US_ASCII);
byte[] prefix = "Date: ".getBytes(StandardCharsets.US_ASCII);
byte[] http1 = new byte[prefix.length + value.length + 2];
System.arraycopy(prefix, 0, http1, 0, prefix.length);
System.arraycopy(value, 0, http1, prefix.length, value.length);
http1[http1.length - 2] = '\r';
http1[http1.length - 1] = '\n';
/**
* The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one
* second. Never allocates — the same array is returned until the next refresh.
*/
public static byte[] bytes() {
return current;
}
ByteWriter encoded = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(encoded, 33, value, true);
return new Snapshot(http1, Arrays.copyOf(encoded.array(), encoded.length()));
}
static String format(ZonedDateTime time) {
return FORMATTER.format(time);
}
/**
* The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one second.
* Never allocates — the same array is returned until the next refresh.
*/
public static byte[] bytes() {
return current.http1;
}
/** Current precompiled HPACK {@code date} field. */
public static byte[] hpackBytes() {
return current.hpack;
}
}
@@ -1,118 +1,164 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Pre-compiled byte representations of standard HTTP status lines.
* Uses a direct-access array for O(1) lookup with zero allocation.
* Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for
* O(1) lookup with zero allocation.
*/
public enum HttpStatus {
// 1xx
CONTINUE (100, "Continue"),
SWITCHING_PROTOCOLS (101, "Switching Protocols"),
// 1xx
CONTINUE(100, "Continue"),
SWITCHING_PROTOCOLS(101, "Switching Protocols"),
// 2xx
OK (200, "OK"),
CREATED (201, "Created"),
ACCEPTED (202, "Accepted"),
NO_CONTENT (204, "No Content"),
PARTIAL_CONTENT (206, "Partial Content"),
// 2xx
OK(200, "OK"),
CREATED(201, "Created"),
ACCEPTED(202, "Accepted"),
NO_CONTENT(204, "No Content"),
PARTIAL_CONTENT(206, "Partial Content"),
// 3xx
MOVED_PERMANENTLY (301, "Moved Permanently"),
FOUND (302, "Found"),
NOT_MODIFIED (304, "Not Modified"),
TEMPORARY_REDIRECT (307, "Temporary Redirect"),
PERMANENT_REDIRECT (308, "Permanent Redirect"),
// 3xx
MOVED_PERMANENTLY(301, "Moved Permanently"),
FOUND(302, "Found"),
NOT_MODIFIED(304, "Not Modified"),
TEMPORARY_REDIRECT(307, "Temporary Redirect"),
PERMANENT_REDIRECT(308, "Permanent Redirect"),
// 4xx
BAD_REQUEST (400, "Bad Request"),
UNAUTHORIZED (401, "Unauthorized"),
FORBIDDEN (403, "Forbidden"),
NOT_FOUND (404, "Not Found"),
METHOD_NOT_ALLOWED (405, "Method Not Allowed"),
NOT_ACCEPTABLE (406, "Not Acceptable"),
CONFLICT (409, "Conflict"),
GONE (410, "Gone"),
LENGTH_REQUIRED (411, "Length Required"),
PRECONDITION_FAILED (412, "Precondition Failed"),
PAYLOAD_TOO_LARGE (413, "Payload Too Large"),
URI_TOO_LONG (414, "URI Too Long"),
UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"),
RANGE_NOT_SATISFIABLE (416, "Range Not Satisfiable"),
EXPECTATION_FAILED (417, "Expectation Failed"),
MISDIRECTED_REQUEST (421, "Misdirected Request"),
UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"),
TOO_MANY_REQUESTS (429, "Too Many Requests"),
REQUEST_HEADER_FIELDS_TOO_LARGE (431, "Request Header Fields Too Large"),
// 4xx
BAD_REQUEST(400, "Bad Request"),
UNAUTHORIZED(401, "Unauthorized"),
FORBIDDEN(403, "Forbidden"),
NOT_FOUND(404, "Not Found"),
METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
NOT_ACCEPTABLE(406, "Not Acceptable"),
CONFLICT(409, "Conflict"),
GONE(410, "Gone"),
LENGTH_REQUIRED(411, "Length Required"),
PRECONDITION_FAILED(412, "Precondition Failed"),
PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
URI_TOO_LONG(414, "URI Too Long"),
UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
RANGE_NOT_SATISFIABLE(416, "Range Not Satisfiable"),
EXPECTATION_FAILED(417, "Expectation Failed"),
MISDIRECTED_REQUEST(421, "Misdirected Request"),
UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"),
TOO_MANY_REQUESTS(429, "Too Many Requests"),
REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"),
// 5xx
INTERNAL_SERVER_ERROR (500, "Internal Server Error"),
NOT_IMPLEMENTED (501, "Not Implemented"),
BAD_GATEWAY (502, "Bad Gateway"),
SERVICE_UNAVAILABLE (503, "Service Unavailable"),
GATEWAY_TIMEOUT (504, "Gateway Timeout"),
HTTP_VERSION_NOT_SUPPORTED (505, "HTTP Version Not Supported"),
INSUFFICIENT_STORAGE (507, "Insufficient Storage"),
NETWORK_AUTHENTICATION_REQUIRED (511, "Network Authentication Required");
// 5xx
INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
NOT_IMPLEMENTED(501, "Not Implemented"),
BAD_GATEWAY(502, "Bad Gateway"),
SERVICE_UNAVAILABLE(503, "Service Unavailable"),
GATEWAY_TIMEOUT(504, "Gateway Timeout"),
HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version Not Supported"),
INSUFFICIENT_STORAGE(507, "Insufficient Storage"),
NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required");
// ArrayIndexOutOfBoundsException from this static initializer the moment any constant
// above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST
// and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so
// adding a status code can never silently break class loading again.
private static final int MAX_STATUS_CODE;
private static final byte[][] INDEX;
private static final String[] REASONS;
// ArrayIndexOutOfBoundsException from this static initializer the moment any constant
// above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST
// and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so
// adding a status code can never silently break class loading again.
private static final int MAX_STATUS_CODE;
private static final byte[][] INDEX;
private static final byte[][] HPACK_INDEX;
private static final String[] REASONS;
static {
int max = 0;
for (HttpStatus s : values()) max = Math.max(max, s.code);
MAX_STATUS_CODE = max;
INDEX = new byte[MAX_STATUS_CODE + 1][];
REASONS = new String[MAX_STATUS_CODE + 1];
for (HttpStatus s : values()) {
INDEX[s.code] = s.bytes;
REASONS[s.code] = s.reason;
}
static {
int max = 0;
for (HttpStatus s : values()) max = Math.max(max, s.code);
MAX_STATUS_CODE = max;
INDEX = new byte[MAX_STATUS_CODE + 1][];
HPACK_INDEX = new byte[MAX_STATUS_CODE + 1][];
REASONS = new String[MAX_STATUS_CODE + 1];
for (HttpStatus s : values()) {
INDEX[s.code] = s.bytes;
HPACK_INDEX[s.code] = s.hpackBytes;
REASONS[s.code] = s.reason;
}
}
private final int code;
private final String reason;
private final byte[] bytes;
private final int code;
private final String reason;
private final byte[] bytes;
private final byte[] hpackBytes;
HttpStatus(int code, String reason) {
this.code = code;
this.reason = reason;
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
HttpStatus(int code, String reason) {
this.code = code;
this.reason = reason;
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
this.hpackBytes = encodeHpack(code);
}
/** Numeric status code (e.g. {@code 200}). */
public int code() {
return code;
}
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
public byte[] bytes() {
return bytes;
}
/** Precompiled HPACK representation of {@code :status}. */
public byte[] hpackBytes() {
return hpackBytes;
}
/** Reason phrase (e.g. {@code "OK"}). */
public String reason() {
return reason;
}
/**
* Returns pre-compiled status bytes for the given code. Access is O(1) and generates zero
* garbage.
*/
public static byte[] bytesForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return INDEX[code];
}
return null;
}
/** Numeric status code (e.g. {@code 200}). */
public int code() { return code; }
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
public byte[] bytes() { return bytes; }
/** Reason phrase (e.g. {@code "OK"}). */
public String reason() { return reason; }
/**
* Returns pre-compiled status bytes for the given code.
* Access is O(1) and generates zero garbage.
*/
public static byte[] bytesForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return INDEX[code];
}
return null;
/** Returns reason phrase for the given code, or null if unknown. */
public static String reasonForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return REASONS[code];
}
return null;
}
/** Returns reason phrase for the given code, or null if unknown. */
public static String reasonForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return REASONS[code];
}
return null;
/** Returns the precompiled HPACK status field for a known code, or {@code null}. */
public static byte[] hpackBytesForCode(int code) {
return code >= 0 && code <= MAX_STATUS_CODE ? HPACK_INDEX[code] : null;
}
private static byte[] encodeHpack(int code) {
int staticIndex =
switch (code) {
case 200 -> 8;
case 204 -> 9;
case 206 -> 10;
case 304 -> 11;
case 400 -> 12;
case 404 -> 13;
case 500 -> 14;
default -> 0;
};
ByteWriter out = new ByteWriter(8);
if (staticIndex != 0) {
HpackEncoder.writeIndexed(out, staticIndex);
} else {
byte[] value = {
(byte) ('0' + code / 100), (byte) ('0' + code / 10 % 10), (byte) ('0' + code % 10)
};
HpackEncoder.writeLiteralWithNameIndex(out, 8, value, true);
}
}
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,57 +4,48 @@ import java.nio.charset.StandardCharsets;
import java.util.Arrays;
/**
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final}
* constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}.
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final} constant)
* and reused across many responses via {@link Response#header(PreEncodedHeader)}.
*
* The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line
* (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) — fine for h1, but not valid HPACK: HPACK encodes
* a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a
* pre-rendered h1 line carries no information an HPACK encoder could reuse. {@code
* PreEncodedHeader} instead precomputes the {@code name}/{@code value} bytes <em>separately</em>
* (still once, still at boot) so either protocol's writer can render them in its own format —
* {@link Response#header(byte[])} is kept, working, for h1-only callers, but is documented as
* ignored on a future HTTP/2 response path (there is no way to recover structured name/value data
* from an opaque pre-rendered line); prefer this class for any header a handler wants to send on
* both protocols.
*
* class stores the raw {@code name}/{@code value} bytes now, which is everything a future HPACK
* encoder needs to produce its own rendering from; it does not yet expose a precomputed HPACK
* byte form, since building one before HPACK exists would be speculative, untested API surface.
* <p>Unlike {@link Response#header(byte[])}, which accepts an opaque HTTP/1 field line, this class
* preserves the name/value boundary. HTTP/1 can render it as a line and HTTP/2 can encode it with
* HPACK, so one constant works on both protocols.
*/
public final class PreEncodedHeader {
private final byte[] nameBytes;
private final byte[] valueBytes;
private final byte[] nameBytes;
private final byte[] valueBytes;
public PreEncodedHeader(String name, String value) {
this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
}
public PreEncodedHeader(String name, String value) {
this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
}
/** The header name's ASCII bytes, case as given to the constructor. Never copy-on-read — treat as immutable. */
byte[] nameBytes() {
return nameBytes;
}
/** Header-name ASCII bytes. The returned array is immutable by contract. */
byte[] nameBytes() {
return nameBytes;
}
/** The header value's ASCII bytes. Never copy-on-read — treat as immutable. */
byte[] valueBytes() {
return valueBytes;
}
/** Header-value ASCII bytes. The returned array is immutable by contract. */
byte[] valueBytes() {
return valueBytes;
}
@Override
public String toString() {
return new String(nameBytes, StandardCharsets.US_ASCII) + ": " + new String(valueBytes, StandardCharsets.US_ASCII);
}
@Override
public String toString() {
return new String(nameBytes, StandardCharsets.US_ASCII)
+ ": "
+ new String(valueBytes, StandardCharsets.US_ASCII);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PreEncodedHeader other)) return false;
return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PreEncodedHeader other)) return false;
return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
}
@Override
public int hashCode() {
return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
}
}
@@ -5,7 +5,6 @@ import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpStatus;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -29,422 +28,514 @@ import java.util.List;
* }</pre>
*
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
* connection, reset before every handler call rather than reallocated — the same treatment
* {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
* applies identically here). <b>A handler that returns a different {@code Response} instance</b>
* (e.g. {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully
* supported — that instance is a normal, unpooled, freshly-constructed object like any
* public-constructor {@code Response} always was; only the connection driver's own default
* instance is pooled and poisoned after use.
* connection, reset before every handler call rather than reallocated — the same treatment {@link
* Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which applies
* identically here). <b>A handler that returns a different {@code Response} instance</b> (e.g.
* {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully supported — that
* instance is a normal, unpooled, freshly-constructed object like any public-constructor {@code
* Response} always was; only the connection driver's own default instance is pooled and poisoned
* after use.
*/
public class Response {
private int statusCode;
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
private byte[] body;
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private byte[] contentType;
private int statusCode;
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
private byte[] body;
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private byte[] contentType;
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
// unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully
// pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to
// decompose into the same region:
// tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4)
// tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines
private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back
private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen)
private int headerQuadCount;
private List<byte[]> rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim
private byte[] headerTags; // one entry per header(), in call order: 0 or 1
private int[] headerRefs; // one entry per header(), in call order: index into the tag's store
private int headerCount; // total header() calls this response has recorded
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
// unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully
// pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to
// decompose into the same region:
// tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4)
// tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines
private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back
private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen)
private int headerQuadCount;
private List<byte[]> rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim
private byte[] headerTags; // one entry per header(), in call order: 0 or 1
private int[] headerRefs; // one entry per header(), in call order: index into the tag's store
private int headerCount; // total header() calls this response has recorded
private boolean active = true;
private static volatile boolean poisoningEnabled = Flash.DEV;
private boolean active = true;
private static volatile boolean poisoningEnabled = Flash.DEV;
/** Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. */
static void setPoisoningEnabledForTesting(boolean enabled) {
poisoningEnabled = enabled;
/**
* Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook.
*/
static void setPoisoningEnabledForTesting(boolean enabled) {
poisoningEnabled = enabled;
}
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException(
"Response used after the handler returned — do not retain a Response past the handler");
}
}
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException(
"Response used after the handler returned — do not retain a Response past the handler");
}
// -------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------
public Response(int statusCode, ContentType contentType) {
this.statusCode = statusCode;
this.contentType = contentType.getBytes();
}
public Response(int statusCode, byte[] body, ContentType contentType) {
this(statusCode, contentType);
this.body = body;
}
public Response(int statusCode, String text, ContentType contentType) {
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
}
// -------------------------------------------------------------------------
// Pooling
// -------------------------------------------------------------------------
/**
* Repositions this instance for a new request/response cycle — clears the body, stream, status,
* content type, and every header recorded by the previous cycle. Public because the connection
* driver that owns the pooled instance lives in a different package (matching {@link
* RequestLine#reset}'s precedent); user code never calls this.
*/
public Response reset(int statusCode, ContentType contentType) {
this.statusCode = statusCode;
this.statusBytes = null;
this.body = null;
this.stream = null;
this.streamLength = 0;
this.chunked = false;
this.contentType = contentType.getBytes();
this.headerQuadCount = 0;
this.headerCount = 0;
if (rawHeaderLines != null) rawHeaderLines.clear();
this.active = true;
return this;
}
/**
* Marks this instance unsafe for further use — see {@link Request#recycle()} for the full
* rationale, identical here. {@code public} for the same cross-package reason.
*/
public void recycle() {
this.active = false;
}
// -------------------------------------------------------------------------
// Fluent mutators
// -------------------------------------------------------------------------
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
public Response status(int code) {
checkActive();
this.statusCode = code;
this.statusBytes = null;
return this;
}
/**
* Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the
* fluent return.
*/
public void setStatusCode(int code) {
status(code);
}
/**
* Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used directly on
* the write path — zero lookup, zero allocation.
*/
public Response status(HttpStatus status) {
checkActive();
this.statusCode = status.code();
this.statusBytes = status.bytes();
return this;
}
public Response type(ContentType ct) {
checkActive();
this.contentType = ct.getBytes();
return this;
}
public Response type(String ct) {
checkActive();
this.contentType = ct.getBytes(StandardCharsets.UTF_8);
return this;
}
public Response body(byte[] bytes) {
checkActive();
this.body = bytes;
this.stream = null;
return this;
}
public Response body(String text) {
return body(text.getBytes(StandardCharsets.UTF_8));
}
/** Streaming response with known length; written with {@code Content-Length}. */
public Response stream(InputStream is, long length) {
checkActive();
this.stream = is;
this.streamLength = length;
this.chunked = false;
this.body = null;
return this;
}
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
public Response chunked(InputStream is) {
checkActive();
this.stream = is;
this.chunked = true;
this.body = null;
return this;
}
/**
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
* call time; zero-alloc on the write path.
*
* <pre>{@code
* return res.redirect("/login");
* }</pre>
*/
public Response redirect(String url) {
return redirect(HttpStatus.FOUND, url);
}
/**
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY}, {@link
* HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308) when
* semantics matter.
*
* <pre>{@code
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
* }</pre>
*/
public Response redirect(HttpStatus status, String url) {
checkActive();
this.statusCode = status.code();
this.statusBytes = status.bytes();
this.body = null;
this.stream = null;
return header("Location", url);
}
/**
* reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
* {@code String} and re-encoding it — zero allocation once the region has grown to this
* connection's high-water mark.
*/
public Response header(String name, String value) {
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
int nameOff = headerRegion.length();
headerRegion.writeAscii(name);
int nameLen = headerRegion.length() - nameOff;
int valOff = headerRegion.length();
headerRegion.writeAscii(value);
int valLen = headerRegion.length() - valOff;
checkHeaderRegionBudget();
// -------------------------------------------------------------------------
// Constructors
// -------------------------------------------------------------------------
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameLen;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valLen;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
public Response(int statusCode, ContentType contentType) {
this.statusCode = statusCode;
this.contentType = contentType.getBytes();
/**
* Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
* precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
* re-encode. Preserving the field structure makes it usable by both HTTP versions.
*/
public Response header(PreEncodedHeader preEncoded) {
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
byte[] nameBytes = preEncoded.nameBytes();
byte[] valueBytes = preEncoded.valueBytes();
int nameOff = headerRegion.length();
headerRegion.writeBytes(nameBytes);
int valOff = headerRegion.length();
headerRegion.writeBytes(valueBytes);
checkHeaderRegionBudget();
public Response(int statusCode, byte[] body, ContentType contentType) {
this(statusCode, contentType);
this.body = body;
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameBytes.length;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valueBytes.length;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
/**
* Adds a pre-encoded, fully-rendered header line (e.g. a static {@code "X-RateLimit-Limit:
* 100\r\n"} byte array pre-built at boot time). Zero-alloc on both the call path and the h1 write
* path.
*
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured name/value
* data an HPACK encoder could use, so this header is not representable on a HTTP/2 response path
* — prefer {@link #header(PreEncodedHeader)} for anything that must render correctly on both
* protocols. Kept for existing HTTP/1-only callers.
*/
public Response header(byte[] preEncoded) {
checkActive();
checkHeaderBudget();
if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
rawHeaderLines.add(preEncoded);
recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
return this;
}
/** Prevents an unbounded header loop from growing the connection's response scratch state. */
private void checkHeaderBudget() {
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
throw new IllegalStateException(
"response exceeds "
+ Http1Limits.MAX_RESPONSE_HEADER_COUNT
+ " headers — check for an unbounded loop calling header(...)");
}
}
public Response(int statusCode, String text, ContentType contentType) {
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
private void checkHeaderRegionBudget() {
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
throw new IllegalStateException(
"response header region exceeds "
+ Http1Limits.MAX_RESPONSE_HEADER_BYTES
+ " bytes — check for an unbounded loop or an oversized value passed to header(...)");
}
}
// -------------------------------------------------------------------------
// Pooling
// -------------------------------------------------------------------------
/**
* Repositions this instance for a new request/response cycle — clears the body, stream,
* status, content type, and every header recorded by the previous cycle. Public because the
* connection driver that owns the pooled instance lives in a different package (matching
* {@link RequestLine#reset}'s precedent); user code never calls this.
*/
public Response reset(int statusCode, ContentType contentType) {
this.statusCode = statusCode;
this.statusBytes = null;
this.body = null;
this.stream = null;
this.streamLength = 0;
this.chunked = false;
this.contentType = contentType.getBytes();
this.headerQuadCount = 0;
this.headerCount = 0;
if (rawHeaderLines != null) rawHeaderLines.clear();
this.active = true;
return this;
private void recordHeaderEntry(byte tag, int ref) {
if (headerTags == null) {
headerTags = new byte[16];
headerRefs = new int[16];
} else if (headerCount == headerTags.length) {
int grown = headerTags.length * 2;
headerTags = Arrays.copyOf(headerTags, grown);
headerRefs = Arrays.copyOf(headerRefs, grown);
}
headerTags[headerCount] = tag;
headerRefs[headerCount] = ref;
headerCount++;
}
/**
* Marks this instance unsafe for further use — see {@link Request#recycle()} for the full
* rationale, identical here. {@code public} for the same cross-package reason.
*/
public void recycle() {
this.active = false;
private void ensureQuadCapacity(int neededQuads) {
int neededInts = neededQuads * 4;
if (neededInts <= headerQuads.length) return;
int grown = headerQuads.length;
while (grown < neededInts) grown *= 2;
headerQuads = Arrays.copyOf(headerQuads, grown);
}
// -------------------------------------------------------------------------
// State queries
// -------------------------------------------------------------------------
public boolean isStreaming() {
checkActive();
return stream != null;
}
public boolean isChunked() {
checkActive();
return chunked;
}
public int getStatusCode() {
checkActive();
return statusCode;
}
public byte[] getStatusBytes() {
checkActive();
return statusBytes;
}
public byte[] getBody() {
checkActive();
return body;
}
public byte[] getContentType() {
checkActive();
return contentType;
}
public InputStream getStream() {
checkActive();
return stream;
}
public long getStreamLength() {
checkActive();
return streamLength;
}
// -------------------------------------------------------------------------
// Internal setters used by HttpServer for handler return values
// -------------------------------------------------------------------------
/**
* Sets the body from a handler return value. Accepted types: {@code byte[]}, {@link String},
* {@link CharSequence}. Any other non-null type throws {@link IllegalArgumentException} — return
* a {@code Response} directly, or serialize to {@code String}/{@code byte[]} before returning.
*/
public Response setBody(Object body) {
checkActive();
if (body instanceof byte[] bytes) {
this.body = bytes;
return this;
}
// -------------------------------------------------------------------------
// Fluent mutators
// -------------------------------------------------------------------------
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
public Response status(int code) { checkActive(); this.statusCode = code; this.statusBytes = null; return this; }
/** Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the fluent return. */
public void setStatusCode(int code) { status(code); }
/** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
* directly on the write path — zero lookup, zero allocation. */
public Response status(HttpStatus status) { checkActive(); this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
public Response type(ContentType ct) { checkActive(); this.contentType = ct.getBytes(); return this; }
public Response type(String ct) { checkActive(); this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
public Response body(byte[] bytes) {
checkActive();
this.body = bytes;
this.stream = null;
return this;
if (body instanceof String s) {
this.body = s.getBytes(StandardCharsets.UTF_8);
return this;
}
public Response body(String text) {
return body(text.getBytes(StandardCharsets.UTF_8));
if (body instanceof CharSequence s) {
this.body = s.toString().getBytes(StandardCharsets.UTF_8);
return this;
}
if (body != null)
throw new IllegalArgumentException(
"Handler returned unsupported type: "
+ body.getClass().getName()
+ " — return String, byte[], Response, or null");
return this;
}
/** Streaming response with known length; written with {@code Content-Length}. */
public Response stream(InputStream is, long length) {
checkActive();
this.stream = is;
this.streamLength = length;
this.chunked = false;
this.body = null;
return this;
}
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
public Response chunked(InputStream is) {
checkActive();
this.stream = is;
this.chunked = true;
this.body = null;
return this;
}
/**
* 302 Found redirect. Clears the body, sets status and {@code Location} header.
* Encoded once at call time; zero-alloc on the write path.
*
* <pre>{@code
* return res.redirect("/login");
* }</pre>
*/
public Response redirect(String url) {
return redirect(HttpStatus.FOUND, url);
}
/**
* Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY},
* {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308)
* when semantics matter.
*
* <pre>{@code
* return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
* }</pre>
*/
public Response redirect(HttpStatus status, String url) {
checkActive();
this.statusCode = status.code();
this.statusBytes = status.bytes();
this.body = null;
this.stream = null;
return header("Location", url);
}
/**
* reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
* {@code String} and re-encoding it — zero allocation once the region has grown to this
* connection's high-water mark.
*/
public Response header(String name, String value) {
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
int nameOff = headerRegion.length();
headerRegion.writeAscii(name);
int nameLen = headerRegion.length() - nameOff;
int valOff = headerRegion.length();
headerRegion.writeAscii(value);
int valLen = headerRegion.length() - valOff;
checkHeaderRegionBudget();
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameLen;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valLen;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
/**
* Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
* precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
* re-encode, and usable by a future HTTP/2 response path (unlike {@link #header(byte[])}) since
* the name/value structure survives.
*/
public Response header(PreEncodedHeader preEncoded) {
checkActive();
checkHeaderBudget();
if (headerRegion == null) {
headerRegion = new ByteWriter(128);
headerQuads = new int[16];
}
ensureQuadCapacity(headerQuadCount + 1);
byte[] nameBytes = preEncoded.nameBytes();
byte[] valueBytes = preEncoded.valueBytes();
int nameOff = headerRegion.length();
headerRegion.writeBytes(nameBytes);
int valOff = headerRegion.length();
headerRegion.writeBytes(valueBytes);
checkHeaderRegionBudget();
int base = headerQuadCount * 4;
headerQuads[base] = nameOff;
headerQuads[base + 1] = nameBytes.length;
headerQuads[base + 2] = valOff;
headerQuads[base + 3] = valueBytes.length;
recordHeaderEntry((byte) 0, headerQuadCount);
headerQuadCount++;
return this;
}
/**
* Adds a pre-encoded, fully-rendered header line (e.g. a static
* {@code "X-RateLimit-Limit: 100\r\n"} byte array pre-built at boot time). Zero-alloc on
* both the call path and the h1 write path.
*
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured
* name/value data an HPACK encoder could use, so this header is not representable on a
* future HTTP/2 response path — prefer {@link #header(PreEncodedHeader)} for anything that must
* render correctly on both protocols. Kept for existing h1-only callers.
*/
public Response header(byte[] preEncoded) {
checkActive();
checkHeaderBudget();
if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
rawHeaderLines.add(preEncoded);
recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
return this;
}
/** Prevents an unbounded header loop from growing the connection's response scratch state. */
private void checkHeaderBudget() {
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
throw new IllegalStateException("response exceeds " + Http1Limits.MAX_RESPONSE_HEADER_COUNT
+ " headers — check for an unbounded loop calling header(...)");
}
}
private void checkHeaderRegionBudget() {
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
throw new IllegalStateException("response header region exceeds "
+ Http1Limits.MAX_RESPONSE_HEADER_BYTES + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
}
}
private void recordHeaderEntry(byte tag, int ref) {
if (headerTags == null) {
headerTags = new byte[16];
headerRefs = new int[16];
} else if (headerCount == headerTags.length) {
int grown = headerTags.length * 2;
headerTags = Arrays.copyOf(headerTags, grown);
headerRefs = Arrays.copyOf(headerRefs, grown);
}
headerTags[headerCount] = tag;
headerRefs[headerCount] = ref;
headerCount++;
}
private void ensureQuadCapacity(int neededQuads) {
int neededInts = neededQuads * 4;
if (neededInts <= headerQuads.length) return;
int grown = headerQuads.length;
while (grown < neededInts) grown *= 2;
headerQuads = Arrays.copyOf(headerQuads, grown);
}
// -------------------------------------------------------------------------
// State queries
// -------------------------------------------------------------------------
public boolean isStreaming() { checkActive(); return stream != null; }
public boolean isChunked() { checkActive(); return chunked; }
public int getStatusCode() { checkActive(); return statusCode; }
public byte[] getStatusBytes() { checkActive(); return statusBytes; }
public byte[] getBody() { checkActive(); return body; }
public byte[] getContentType() { checkActive(); return contentType; }
public InputStream getStream() { checkActive(); return stream; }
public long getStreamLength() { checkActive(); return streamLength; }
// -------------------------------------------------------------------------
// Internal setters used by HttpServer for handler return values
// -------------------------------------------------------------------------
/**
* Sets the body from a handler return value. Accepted types: {@code byte[]},
* {@link String}, {@link CharSequence}. Any other non-null type throws
* {@link IllegalArgumentException} — return a {@code Response} directly, or
* serialize to {@code String}/{@code byte[]} before returning.
*/
public Response setBody(Object body) {
checkActive();
if (body instanceof byte[] bytes) { this.body = bytes; return this; }
if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
if (body != null) throw new IllegalArgumentException(
"Handler returned unsupported type: " + body.getClass().getName()
+ " — return String, byte[], Response, or null");
return this;
}
/**
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list
* if none were added. Introspection/debugging accessor — reconstructs each line from the
* internal region on every call, so it is not on the zero-alloc write path; {@link
* #writeHeaders} and {@link ResponseSerializer} read the internal representation directly
* instead of going through this method.
*/
public List<byte[]> getHeaders() {
checkActive();
if (headerCount == 0) return List.of();
List<byte[]> result = new ArrayList<>(headerCount);
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
result.add(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
int nameOff = headerQuads[base], nameLen = headerQuads[base + 1];
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
byte[] line = new byte[nameLen + 2 + valLen + 2];
int p = 0;
System.arraycopy(region, nameOff, line, p, nameLen); p += nameLen;
line[p++] = ':'; line[p++] = ' ';
System.arraycopy(region, valOff, line, p, valLen); p += valLen;
line[p++] = '\r'; line[p] = '\n';
result.add(line);
}
}
return result;
}
/**
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} —
* This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below
* (the {@code OutputStream} equivalent) exists for the streaming-body write paths that
* cannot fold their whole write into one scratch buffer.
*/
public void writeHeadersInto(ByteWriter head) {
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
head.writeBytes(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
head.writeByte((byte) ':'); head.writeByte((byte) ' ');
head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]);
head.writeByte((byte) '\r'); head.writeByte((byte) '\n');
}
}
}
/** Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers are set or on a warm region. */
public void writeHeaders(OutputStream out) throws IOException {
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
out.write(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
out.write(region, headerQuads[base], headerQuads[base + 1]);
out.write(':'); out.write(' ');
out.write(region, headerQuads[base + 2], headerQuads[base + 3]);
out.write('\r'); out.write('\n');
}
}
}
// ── Internal: name/value field enumeration for ResponseSerializer ──────────
/**
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as
* a structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy
* entries, which have no such structure (see that method's own Javadoc). Package-private:
* {@link ResponseSerializer} is this method's only caller.
*/
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
if (headerQuadCount == 0) return;
/**
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list if
* none were added. Introspection/debugging accessor — reconstructs each line from the internal
* region on every call, so it is not on the zero-alloc write path; {@link #writeHeaders} and
* {@link ResponseSerializer} read the internal representation directly instead of going through
* this method.
*/
public List<byte[]> getHeaders() {
checkActive();
if (headerCount == 0) return List.of();
List<byte[]> result = new ArrayList<>(headerCount);
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
result.add(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
for (int i = 0; i < headerQuadCount; i++) {
int base = i * 4;
consumer.accept(region, headerQuads[base], headerQuads[base + 1],
region, headerQuads[base + 2], headerQuads[base + 3]);
}
int nameOff = headerQuads[base], nameLen = headerQuads[base + 1];
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
byte[] line = new byte[nameLen + 2 + valLen + 2];
int p = 0;
System.arraycopy(region, nameOff, line, p, nameLen);
p += nameLen;
line[p++] = ':';
line[p++] = ' ';
System.arraycopy(region, valOff, line, p, valLen);
p += valLen;
line[p++] = '\r';
line[p] = '\n';
result.add(line);
}
}
return result;
}
@Override
public String toString() {
return "Response(statusCode=" + statusCode + ", contentType="
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + ")";
/**
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} — This is
* what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below (the {@code
* OutputStream} equivalent) exists for the streaming-body write paths that cannot fold their
* whole write into one scratch buffer.
*/
public void writeHeadersInto(ByteWriter head) {
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
head.writeBytes(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
head.writeByte((byte) ':');
head.writeByte((byte) ' ');
head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]);
head.writeByte((byte) '\r');
head.writeByte((byte) '\n');
}
}
}
/**
* Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers
* are set or on a warm region.
*/
public void writeHeaders(OutputStream out) throws IOException {
for (int i = 0; i < headerCount; i++) {
if (headerTags[i] == 1) {
out.write(rawHeaderLines.get(headerRefs[i]));
} else {
int base = headerRefs[i] * 4;
byte[] region = headerRegion.array();
out.write(region, headerQuads[base], headerQuads[base + 1]);
out.write(':');
out.write(' ');
out.write(region, headerQuads[base + 2], headerQuads[base + 3]);
out.write('\r');
out.write('\n');
}
}
}
// ── Internal: name/value field enumeration for ResponseSerializer ──────────
/**
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as a
* structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy entries,
* which have no such structure (see that method's own Javadoc). Package-private: {@link
* ResponseSerializer} is this method's only caller.
*/
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
if (headerQuadCount == 0) return;
byte[] region = headerRegion.array();
for (int i = 0; i < headerQuadCount; i++) {
int base = i * 4;
consumer.accept(
region,
headerQuads[base],
headerQuads[base + 1],
region,
headerQuads[base + 2],
headerQuads[base + 3]);
}
}
@Override
public String toString() {
return "Response(statusCode="
+ statusCode
+ ", contentType="
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null)
+ ")";
}
}
@@ -4,48 +4,54 @@ import java.nio.charset.StandardCharsets;
/**
* The protocol-neutral enumeration of a {@link Response}'s header fields — one source of truth
* consumed by every protocol's own writer, so {@code Content-Type}/custom-header logic is never
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future HTTP/2 encoder
* encoder will render the same fields via HPACK.
* consumed by every protocol's writer, so field selection cannot drift between HTTP/1 and HTTP/2.
*
* <h3>Scope: response-object fields only, not connection framing</h3>
* Deliberately does <b>not</b> enumerate {@code Content-Length}, {@code Connection}, or
* {@code Date} — those are connection/transport framing decisions (body length, keep-alive
* negotiation, wall-clock time), not properties of the {@code Response} object itself, and HTTP/2
* has no equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific
* fields in h2). Each protocol's own writer computes and emits those itself, exactly as
* {@code Http1ResponseWriter} already did before this class existed.
*
* Deliberately does <b>not</b> enumerate {@code Content-Length}, {@code Connection}, or {@code
* Date} — those are connection/transport framing decisions (body length, keep-alive negotiation,
* wall-clock time), not properties of the {@code Response} object itself, and HTTP/2 has no
* equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific fields in
* h2). Each protocol's own writer computes and emits those itself, exactly as {@code
* Http1ResponseWriter} already did before this class existed.
*
* <h3>Scope: excludes {@link Response#header(byte[])}'s legacy entries</h3>
* A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no
* recoverable (name, value) structure — see that method's own Javadoc — so it cannot appear in
* this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
* Response#writeHeaders}, which handles both structured and raw entries, in the original call
* order); a future HTTP/2 writer will not be able to.
*
* A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no recoverable
* (name, value) structure — see that method's own Javadoc — so it cannot appear in this
* enumeration. HTTP/1 still renders it via {@link Response#writeHeaders}; HTTP/2 cannot recover its
* field structure and ignores it.
*/
public final class ResponseSerializer {
private ResponseSerializer() {}
private ResponseSerializer() {}
/** One rendered header field: a byte range for the name, and a byte range for the value — both slices of caller-owned arrays, never copied. */
@FunctionalInterface
public interface FieldConsumer {
void accept(byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
/**
* One rendered header field: a byte range for the name, and a byte range for the value — both
* slices of caller-owned arrays, never copied.
*/
@FunctionalInterface
public interface FieldConsumer {
void accept(
byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
}
private static final byte[] CONTENT_TYPE_NAME =
"Content-Type".getBytes(StandardCharsets.US_ASCII);
/**
* Enumerates {@code response}'s fields in a fixed order: non-empty {@code Content-Type}, then
* structured custom fields in call order. Every range is a slice of existing response storage.
*/
public static void forEachField(Response response, FieldConsumer consumer) {
byte[] ct = response.getContentType();
if (ct != null && ct.length > 0) {
consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
}
forEachCustomField(response, consumer);
}
private static final byte[] CONTENT_TYPE_NAME = "Content-Type".getBytes(StandardCharsets.US_ASCII);
/**
* Enumerates {@code response}'s fields in a fixed, deterministic order: {@code Content-Type}
* nothing, never an empty-valued header line), then every {@code header(String,String)}/
* {@code header(PreEncodedHeader)}-added field in call order. Zero allocation: every byte
* range handed to {@code consumer} is a slice of {@code response}'s own already-allocated
* buffers.
*/
public static void forEachField(Response response, FieldConsumer consumer) {
byte[] ct = response.getContentType();
if (ct != null && ct.length > 0) {
consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
}
response.forEachStructuredField(consumer);
}
/** Enumerates structured custom fields only, excluding {@code content-type}. */
public static void forEachCustomField(Response response, FieldConsumer consumer) {
response.forEachStructuredField(consumer);
}
}
@@ -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);
}
}