feat(core): add HTTP/2 response path
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user