Unblock::HTTP

Unblock::HTTP is the framework-neutral application layer above HTTP/1, HTTP/2, HTTP/3, and WebSocket. Protocol parsing stays in the existing engines. WebSocket sessions use the separate Unblock::WebSocket distribution; this top layer adds no XS.

Unblock::HTTP 0.02 provides reusable Server attachment, a shared Client with per-origin pools and bounded queues, and opt-in WebSocket handshakes over HTTP/1.1 Upgrade and HTTP/2/3 Extended CONNECT. Unblock::WebSocket is an optional dependency, loaded only when WebSockets are used. Cross-host Alt-Svc and post-dispatch request replay are intentionally not provided; optional framework-driven request deadlines are available. The required released engines are Unblock::HTTP1 0.12+, Unblock::HTTP2 0.12+, Unblock::HTTP3 0.13+, and Uniform::HTTP 0.06+. The separate implementation notes record development history.

One Server, many connections

Application code creates one definition:

use Unblock::HTTP::Server;

my $server = Unblock::HTTP::Server->new(
    http1 => {},
    http2 => {},
    http3 => {},
    on_request => sub {
        my ($tx, $request) = @_;
        $tx->respond(status => 200, body => "hello\n");
    },
);

An omitted protocol is disabled. Each protocol hash can carry engine tuning options. WebSocket remains disabled by default; enable it per protocol using websocket => 1 and supply on_websocket($tx, $request). HTTP/2 and HTTP/3 require a framework defer($callback) that schedules work on a later event-loop turn. The Server advertises Extended CONNECT only for explicitly enabled protocols. WebSocket acceptance affects one multiplexed stream on H2/H3, but transfers a whole HTTP1 socket.

on_headers($tx, $request) runs first. By default the engine buffers up to 1 MiB, then calls on_request($tx, $request) with the complete frozen request. Set max_buffered_body_bytes in a protocol hash to change that positive limit.

For streaming, register $tx->on_data(sub { my ($tx, $bytes) = @_; ... }) during on_headers. This suppresses full-body accumulation. on_request still runs after successful body completion. Failed or cancelled bodies do not reach it. Register $tx->on_finish(...) before the transaction is terminal to observe completion, error, or cancellation exactly once.

This requires Unblock::HTTP1 0.12+, Unblock::HTTP2 0.12+, and Unblock::HTTP3 0.13+. Attachment also checks required engine methods and gives an explicit error for an incompatible older engine.

Framework attachment

For each incoming TCP/TLS connection, the adapter attaches its stream:

my $engine = $server->attach_stream(
    transport => $stream,
    alpn => $negotiated_alpn,
);

$engine->input($bytes);

The returned object is the actual HTTP1 or HTTP2 Server engine. Transactions also come directly from that engine. There is no forwarding connection object. The framework retains the engine and continues to own its socket and buffers.

The stream supplies unblock_send, unblock_finish, and unblock_abort. unblock_send accepts the entire buffer or throws. A defined false result means the bytes were accepted but the host is congested. The framework calls resume_output() when it has capacity again.

Configure TLS using $server->alpn_protocols. This returns enabled TCP choices, with H2 preferred. Empty ALPN selects H1 only when H1 is enabled. Unknown or disabled protocols fail. For cleartext H2 prior knowledge, pass protocol => 'h2' instead of alpn.

A ready HTTP/3 QUIC connection uses:

my $engine = $server->attach_quic(connection => $ready_h3_connection);

It returns a started HTTP3 Server engine. UDP and timer events continue to go to Net::QUIC::Driver. The framework is responsible for supplying a connection negotiated for H3. A QUIC manager can attach all ready connections to the same definition:

my $endpoint = Unblock::HTTP::QUIC->server(
    server => $server,
    certificate_file => $cert,
    private_key_file => $key,
    send => $send_datagram,
    set_timeout => $replace_timer,
);
$endpoint->start;

Read perldoc Unblock::HTTP::Integration for the adapter contract.

Shared Client

The Client now owns reusable HTTP policy, request scheduling, and per-origin connection pools. An application makes one Client for many destinations:

my $op = $client->request(
    method => 'GET',
    url => 'https://example.com/data',
    on_response => sub {
        my ($tx, $response) = @_;
        print $response->status, "\\n";
    },
);

The request returns an operation handle right away. Its transaction() is undefined while waiting for a connection, then becomes the real engine Transaction after dispatch. cancel(), state(), and error() work without polling. A strict http_version => '3' requires HTTP/3, not a fallback to HTTP/2. HTTPS cold requests use H2/H1 ALPN; later requests may use H3 through an authenticated, fresh, same-host Alt-Svc advertisement.

An integration supplies connect_stream, connect_quic and a defer callback. The factory opens TCP/TLS or QUIC through its framework and calls $ready->(...). The ready callback returns the real connected engine. The adapter keeps that engine on its framework-owned stream or connection and feeds input directly to it. The defer callback schedules work in a later event-loop turn. See perldoc Unblock::HTTP::Client and perldoc Unblock::HTTP::Integration for the exact contracts.

By default the Client buffers up to 1 MiB of response data in the chosen engine, then calls on_response once after body completion. Supplying on_data automatically streams the response: on_response fires when headers arrive, then on_data($tx, $chunk), on_end($tx), and terminal on_finish($tx). Client buffering does not add another body accumulator.

A request may set timeout => $seconds to cover queuing, establishment, and its active HTTP transaction. The framework adapter supplies an optional schedule_timer($seconds, $callback) constructor callback returning a timer cancellation coderef. A timed-out active request cancels its real engine transaction and reports a timeout on the operation. A factory can return a cancellation coderef so a timed-out or manually cancelled connection attempt releases its pool slot; late factory readiness is ignored.

When HTTP/3 QUIC bidi stream credit is exhausted, an unsubmitted request stays in the bounded Client queue; the H3 engine notifies when request stream availability changes, and the Client retries admission only after framework deferral. No submitted request is replayed.

The framework may call $client->network_changed when its network identity changes. This discards idle old-network sockets and Alt-Svc hints, while active transactions continue on their original engines until completion. Pending operations are retried only BEFORE dispatch using fresh connections; a transmitted request is never replayed. $client->pool_stats returns read-only origin and pool counts to inspect queued, connecting, active, idle, and retiring connections.

Fatal hard-limit output rejection is distinct from cooperative backpressure. The repository-only Linux::Event integration tests propagate max_pending_bytes violations as transport errors; it does not replay the rejected bytes. Unblock::HTTP1 0.12+ waits until attached output is accepted before reporting a successful server terminal callback. See docs/IMPLEMENTATION.md.

Idle connection eviction is opt-in with idle_timeout => $seconds. When enabled, the framework supplies schedule_timer and may supply release_connection($host, $engine, $protocol, $reason) to close its own socket or QUIC resources. Timers are cancelled on reuse and never close connections with active streams or queued requests waiting for peer credit.

The repository-only live integration tests also exercise streamed request and response bodies exceeding 1 MiB on H1/H2/H3, with integrity checks and controlled accepted-but-blocked output resumption. A preliminary direct-engine versus unified-Client TCP loopback benchmark is included at bench/client-vs-direct.pl. HTTP/3 additionally has bench/h3-client-vs-direct.pl for serial requests and bench/h3-workload-profile.pl for isolated concurrent CPU, latency and sampled RSS workloads. Read docs/BENCHMARKING.md and docs/PERFORMANCE.md for how to run them and interpret their limitations. CI correctness tests do not enforce throughput rates.

The queue defaults to 128 pending requests, with at most four connections per origin and 32 concurrent streams per H2/H3 connection. An H1 connection runs one request at a time. The limits are adjustable. Pool idle expiry and WebSocket upgrades/Extended CONNECT are implemented; validated DNS HTTPS discovery and convenience framework connector classes remain future work. Authenticated same-host Alt-Svc now permits future requests to select HTTP/3. Advertisements are only learned when the TCP factory explicitly reports verified_origin => 1 after certificate and origin hostname verification. When advertised HTTP/3 is selected, the QUIC factory receives alternative_host and alternative_port and must confirm verified_origin => 1 before the request can be sent. A failed advertised QUIC connection may safely fall back before dispatch; a request already submitted to an engine is never automatically replayed.

WebSockets across HTTP/1, HTTP/2 and HTTP/3

For each enabled protocol, Server on_websocket returns the WebSocket session options/callbacks, or undef to reject the request with HTTP 403. The handshake is validated before creating an established Unblock::WebSocket::Server. H1 uses HTTP 101 and a socket input-consumer switch; H2/H3 use HTTP 200 and independent streaming CONNECT transactions on their existing multiplexed connections.

my $server = Unblock::HTTP::Server->new(
    http2 => { websocket => 1 },
    http3 => { websocket => 1 },
    defer => sub { $framework->defer($_[0]) },
    on_request => sub { $_[0]->respond(status => 200, body => 'HTTP') },
    on_websocket => sub {
        return {
            on_message => sub {
                my ($ws, $message, $type) = @_;
                $ws->send_text("echo:$message") if $type eq 'text';
            },
        };
    },
);

A unified Client selects its WebSocket protocol explicitly with http_version => '1.1', '2', or '3', or defaults to the first enabled WebSocket protocol in that order:

my $operation = $client->connect_websocket(
    url => 'wss://example.com/chat',
    http_version => '3',
    on_open => sub {
        my ($ws, $tx) = @_;
        $ws->send_text('hello');
    },
);

The operation's websocket() accessor holds the actual WebSocket Client once opened. H2/H3 WebSockets remain in the Client's pool until their individual streams finish, allowing ordinary HTTP to share the connection. HTTP3 Client WebSockets wait for the peer's current Extended CONNECT SETTINGS before dispatch; if QUIC bidi-stream capacity is unavailable, the original unsent operation remains queued until credit returns. A denied/invalid handshake fails only the relevant operation. Consult the WebSocket cookbook, Server and Client POD for running all three protocols, the exact transport ownership rules, backpressure, Close and callback contracts.

Examples and tests

The optional Linux::Event examples are:

Each separates application behavior from framework plumbing. The core does not require Linux::Event. The distribution requires Perl 5.20; some optional example programs use newer Perl syntax. No framework adapter is installed by this distribution.

Run isolated contract tests:

prove -lr t

Run the live tests after installing the real engines, Linux::Event and Net::QUIC:

prove -l xt/linux-event-tcp.t xt/linux-event-websocket.t xt/linux-event-quic.t

The distribution suite combines isolated contract tests with real H1/H2 WebSocket exchanges, a borrowed native H1 input handoff regression, and full localhost QUIC/TLS H3 WebSocket Server/Client tests. The optional live integration suite additionally exercises real sockets, streamed bodies, QUIC flow credit and resource bounds. CI does not enforce performance or throughput thresholds.

License

MIT License.