NAME
PAGI::Spec::Www - PAGI message formats for HTTP, WebSocket, and SSE
HTTP, WebSocket & SSE PAGI Message Format
Version: 0.3 (Draft)
The HTTP, WebSocket & SSE PAGI sub-specification defines how HTTP/1.1, HTTP/2, WebSocket, and Server-Sent Events (SSE) connections are transported within PAGI.
It is designed to be a superset of the PSGI specification and specifies how to translate between PAGI and PSGI for compatible requests.
Spec Versions
- -
-
0.3: Addedpagi.transportoutbound flow-control introspection (buffered_amount, high/low watermarks,on_high_water/on_draincallbacks) and thewebsocket.http.responseWebSocket denial-response extension. Reworked disconnect handling into one abnormal-only reason vocabulary, addingon_complete(successful completion) alongsideon_disconnect(abnormal drops) onpagi.connection. Documented the server-supplied HTTP, SSE, and WebSocket behaviors and WebSocket over HTTP/2 (RFC 8441). Removedpagi.features(superseded byextensions) and the per-sendtimeoutfield (superseded by transport backpressure and keepalive timeouts). - -
-
0.2: Added keepalive events (websocket.keepalive,sse.keepalive), disconnect reasons, SSE support for all HTTP methods (not just GET) with thesse.requestreceive event, and thepagi.connectionscope key for non-destructive disconnect detection. - -
-
0.1: Initial draft, based on ASGI 2.5, including Server-Sent Events support.
Common Data Types
Headers Format
Headers are represented as ArrayRef[ArrayRef[Bytes]] - an array of 2-element tuples where each tuple contains [name, value]:
headers => [
['content-type', 'text/html; charset=utf-8'],
['x-request-id', '12345'],
['set-cookie', 'session=abc'],
['set-cookie', 'tracking=xyz'], # duplicate names allowed
]
Why tuples instead of PSGI's flat array?
PSGI uses a flat array with implicit pairs: ['Content-Type', 'text/html', 'X-Custom', 'value']. PAGI uses explicit tuples for several reasons:
Clearer iteration - Each header is a discrete unit:
# PAGI - straightforward for my $header (@$headers) { my ($name, $value) = @$header; } # PSGI - requires index math for (my $i = 0; $i < @$headers; $i += 2) { my ($name, $value) = @{$headers}[$i, $i+1]; }Explicit duplicates - Duplicate header names (common for
Set-Cookie) are visually obviousEasier manipulation - Filtering, mapping, and transforming headers works naturally with array operations:
# Remove all cookies my @filtered = grep { $_->[0] ne 'set-cookie' } @$headers; # Find a header my ($ct) = grep { $_->[0] eq 'content-type' } @$headers;ASGI compatibility - Matches the Python ASGI specification that PAGI is modeled on
Rules:
- -
-
Header names MUST be lowercase byte strings
- -
-
Header values MUST be byte strings (opaque, not decoded)
- -
-
Each inner arrayref MUST contain exactly 2 elements:
[name, value] - -
-
Duplicate header names are permitted (required for
Set-Cookie, etc.)
HTTP::Headers Compatibility:
PSGI's flat array format is more compatible with HTTP::Headers->flatten(). If you need to interoperate:
# PAGI tuples -> flat array (for HTTP::Headers)
my @flat = map { @$_ } @$pagi_headers;
my $hh = HTTP::Headers->new(@flat);
# Flat array -> PAGI tuples
my @flat = $http_headers->flatten;
my @pagi = map { [$flat[$_*2], $flat[$_*2+1]] } 0 .. ($#flat/2);
Scope Extension Keys
The scope hashref may contain additional keys beyond those defined in the HTTP, WebSocket, and SSE sections below. This allows middleware and applications to pass data through the request lifecycle.
Reserved prefixes:
- -
-
pagi.*- Reserved for PAGI spec extensions (e.g.,pagi.router,pagi.session) - -
-
Keys without a dot prefix (e.g.,
type,method,path) - Reserved for core spec use
Custom keys:
Applications and third-party middleware SHOULD use a unique key or prefix to avoid collisions. Two common patterns:
# Pattern 1: Single hashref (recommended for grouped data)
$scope->{myauth} = {
user => $user_object,
roles => ['admin', 'editor'],
};
# Pattern 2: Dotted keys (for flat/independent values)
$scope->{'myapp.request_id'} = $uuid;
$scope->{'myapp.started_at'} = time();
Either approach works - choose based on whether your data is naturally grouped or independent.
Allowed values:
Scope values may be any Perl data type:
Note: Objects in scope are NOT serializable. Do not assume scope can be passed between processes, persisted to storage, or serialized to JSON. Scope exists only for the lifetime of a single request within a single process.
Example:
# Authentication middleware - hashref pattern
$scope->{myauth} = {
user => $user_object,
roles => ['admin', 'editor'],
authenticated_at => time(),
};
# Router middleware (PAGI built-in)
$scope->{'pagi.router'} = {
params => { id => '42' },
route => '/users/:id',
};
# Application accessing middleware data
my $user = $scope->{myauth}{user};
my $id = $scope->{'pagi.router'}{params}{id};
Middleware guidelines:
- -
-
Document the keys your middleware adds to scope
- -
-
Use consistent naming within your namespace
- -
-
Don't modify keys outside your namespace
- -
-
Check for key existence before assuming middleware ran
HTTP
PAGI covers HTTP/1.0, HTTP/1.1, and HTTP/2. Protocol servers assign separate scopes for requests within the same HTTP/2 connection and multiplex responses appropriately.
HTTP/2 Stream Mapping
PAGI servers must translate HTTP/2 frames into PAGI HTTP events per stream. Applications only see structured events, not raw frames:
- -
-
HEADERS: start a new PAGI
httpscope and emit an initialhttp.requestevent with headers andmore => 1if DATA will follow, ormore => 0ifEND_STREAMwas signaled immediately. - -
-
DATA: emit subsequent
http.requestevents withbody => <chunk>andmore => 1or0depending onEND_STREAM. - -
-
END_STREAM: if no DATA frames, send an
http.requestwithbody => ''andmore => 0to signal end of request. - -
-
RST_STREAM: trigger a
http.disconnectevent and cancel any outstanding Futures for that scope. - -
-
WINDOW_UPDATE / PRIORITY: ignored by default (advanced flow control is optional).
- -
-
PUSH_PROMISE: not supported; servers must reject push promises.
Only HTTP/2 over TLS (h2) is required for the initial implementation; cleartext HTTP/2 (h2c) is optional.
The HTTP version is available in the scope. Pseudo headers (like :authority) from HTTP/2 must be removed; if :authority is present, its value must be used to populate or override the host header.
Multiple Set-Cookie headers must be preserved individually, and Cookie headers should be combined or split according to the version-specific rules (as per RFC 7230, RFC 6265, and RFC 9113).
Cookie Header Normalization
PAGI servers must normalize Cookie headers before passing them to the application.
- -
-
If multiple
Cookie:headers are received from the client (which may happen in real-world deployments despite RFC guidance), the server must: - -
-
Concatenate them using
"; "(semicolon followed by space) - -
-
Ensure only one
cookieheader appears in the PAGIheaderslist
Example:
If the client sends:
Cookie: a=1 Cookie: b=2; c=3
The PAGI scope must include:
headers => [
[ 'cookie', 'a=1; b=2; c=3' ]
]
The server does not parse the cookie string into key-value pairs -- parsing is left to middleware or application code. The server only guarantees RFC-compliant normalization.
HTTP Connection Scope
Each HTTP request has a single-request connection scope. Scope keys:
- -
-
type(String) --"http" - -
-
$scope->{pagi}{version}(String) -- The PAGI core spec version (e.g.'0.3') - -
-
$scope->{pagi}{spec_version}(String) -- The version of this HTTP sub-specification (optional; if omitted, assume it matchesversion) - -
-
http_version(String) --"1.0","1.1", or"2" - -
-
method(String) -- Uppercase HTTP method - -
-
scheme(String, default"http") -- URL scheme ("http"or"https") - -
-
path(String) -- Decoded HTTP path - -
-
raw_path(Bytes, optional) -- Original HTTP path bytes - -
-
query_string(Bytes) -- Percent-encoded query string - -
-
root_path(String, default"") -- Application mount path, equivalent toSCRIPT_NAMEin PSGI - -
-
headers(ArrayRef[ArrayRef[Bytes]]) -- Original HTTP headers. Header names must be lower-cased byte strings and header values must be opaque byte strings. - -
-
client(ArrayRef[String, Int], optional) --[host, port]of client - -
-
server(ArrayRef[String, Optional[Int]], optional) --[host, port]or[path, undef]for Unix sockets - -
-
state(HashRef, optional) -- A shallow copy of the lifespanstatenamespace. Top-level keys are private to this request; values (object references) are shared. See "Lifespan State" in PAGI::Spec::Lifespan for how to share mutable data safely. - -
-
pagi.connection(Object) -- Per-request connection-state object for disconnect detection and response progress (response_started/response_complete). See "Connection State" below. - -
-
extensions(HashRef, default{}) -- Optional server capabilities advertised for this connection (e.g.tls). Each present key names an extension mapping to a hashref of extension data; applications check for a key before relying on it. See PAGI::Spec::Extensions.
Request - receive event
Note: Chunked transfer encoding must be de-chunked by the server. Each http.request represents a de-chunked body fragment.
Keys:
- -
-
type--"http.request" - -
-
body(Bytes, default"") -- Request body chunk - -
-
more(Int, default0) --1if more body data is forthcoming, otherwise0
Response Start - send event
Note: Protocol servers are NOT required to flush on http.response.start, giving flexibility to emit an error response in case of internal application errors before data is sent.
Transfer-Encoding headers sent by the application must be ignored. Content-Encoding (e.g. gzip) is under application control.
Keys:
- -
-
type--"http.response.start" - -
-
status(Int) -- HTTP status code - -
-
headers(ArrayRef[ArrayRef[Bytes]], default[]) -- Response headers - -
-
trailers(Int, default0) --1if trailers will be sent after body viahttp.response.trailers, otherwise0
Header byte safety. Because header names and values cross into HTTP message framing, a server MUST NOT emit a header whose name or value contains CR (\x0D), LF (\x0A), or NUL (\x00), nor a name containing any other control character. The server MUST reject such a header by failing the send (the reference server raises) rather than forwarding it or silently rewriting the offending bytes: letting CR/LF through permits HTTP response splitting (CRLF injection), while silently stripping them mutates the application's data without signalling the bug. This is the same guarantee sse.send gives for its event and id fields (see "Send SSE - send event"): the server's emission path is the single point that guards the wire, so applications and frameworks MAY hold header values as opaque bytes and need not pre-validate them. (RFC 9110 S5.5; RFC 9112 S11.1.)
Response Body - send event
Keys:
- -
-
type--"http.response.body" - -
-
body(Bytes, default"") -- Response body chunk - -
-
file(String) -- Absolute path to file for server to open and stream - -
-
fh(Filehandle) -- Already-open filehandle for server to stream - -
-
offset(Int, default0) -- Byte offset to start reading from (for range requests) - -
-
length(Int, optional) -- Number of bytes to send (omit to read until EOF) - -
-
more(Int, default0) -- Indicates more body content to follow (1if true, otherwise0). Ignored forfileandfhresponses which are implicitly complete.
The body, file, and fh keys are mutually exclusive - exactly one MUST be provided per event. Applications MUST provide body as encoded bytes. For text content, this typically means UTF-8 encoding before sending. The Content-Length header (if present) MUST reflect byte length, not character length.
Note: When using file or fh, the response is implicitly complete after the file/handle contents are sent. The more key is ignored for these response types - there is no need to specify more => 0.
When file or fh is provided, servers MUST stream the file contents efficiently:
- -
-
Servers SHOULD stream large files in chunks to avoid memory bloat
- -
-
Servers MAY use zero-copy mechanisms (sendfile, splice) when appropriate
- -
-
The
offsetandlengthkeys enable range request support (e.g., HTTP 206 Partial Content) - -
-
For production file serving, consider using XSendfile middleware to delegate to a reverse proxy
- -
-
When using
file, the server opens the file, streams it, and closes it - -
-
When using
fh, the application retains ownership and MUST close the handle after the$send->()Future completes
Error Handling:
- -
-
If
filecannot be opened (not found, permission denied), the$send->()Future MUST fail with an appropriate exception - -
-
If
fhis invalid or closed, the$send->()Future MUST fail immediately - -
-
Applications SHOULD validate file existence before sending
http.response.startto avoid incomplete responses
Validation:
- -
-
offsetMUST be a non-negative integer - -
-
lengthMUST be a non-negative integer if provided - -
-
If
offsetexceeds file size, servers SHOULD send zero bytes
Examples:
# Full file streaming
await $send->({
type => 'http.response.body',
file => '/var/www/static/large-video.mp4',
});
# Range request (bytes 1000-1999)
await $send->({
type => 'http.response.body',
file => '/var/www/static/document.pdf',
offset => 1000,
length => 1000,
});
# Streaming from already-open filehandle
open my $fh, '<:raw', '/tmp/generated-report.csv' or die $!;
await $send->({
type => 'http.response.body',
fh => $fh,
});
close $fh; # Application MUST close after send Future completes
Response Trailers - send event
Only valid when http.response.start was sent with trailers => 1. After trailers are transmitted the server MUST consider the response body complete.
Keys:
- -
-
type--"http.response.trailers" - -
-
headers(ArrayRef[ArrayRef[Bytes]], default[]) -- Trailer headers encoded the same way as response headers (lower-case names, byte values)
Application Produced No Response
If an HTTP application's Future resolves without any http.response.start having been sent, and the client is still connected, the client is left waiting for a response that will never come. As a last-resort backstop the server produces a 500 response, logs it, and closes the connection so the client is not left hanging.
This is a backstop, not a completion policy. Which terminal response to produce -- a 404, a 204, an error page, or anything else -- is the application's or framework's responsibility, and a well-behaved application always produces one before its Future resolves. PAGI exposes only the observer-independent fact that no response was started -- read $conn->response_started on the pagi.connection object; it makes no judgment about whether a response is "complete". An application that does not handle the http scope may decline by raising an exception, which the server treats identically -- the same 500 backstop.
If the client has already disconnected (see "Disconnect - receive event"), this is not an application error: the server MUST NOT synthesize a 500 and MUST NOT log an error.
Server-Supplied Headers and Behaviors
Beyond the headers an application sets on http.response.start, a conforming server supplies a few headers and framing details on its own, and may originate certain responses without invoking the application. Applications and frameworks SHOULD be aware of these so they neither duplicate nor fight them.
- -
-
Date-- The server SHOULD add aDateresponse header in HTTP-date format (RFC 7231) when the application did not supply one. (The reference server adds it to both HTTP/1.1 and HTTP/2 responses.) - -
-
Body framing -- When the application provides no
Content-Lengthon an HTTP/1.1 response, the server frames the body withTransfer-Encoding: chunked. Applications SHOULD setContent-Length(byte length) when the size is known. HTTP/2 uses its own DATA-frame framing, where neither header applies. - -
-
Connection(HTTP/1.0) -- For HTTP/1.0 clients the server manages theConnectionheader:closewhen the response carries noContent-Length(the close delimits the body), orkeep-alivewhen the client requested keep-alive and aContent-Lengthis present. Applications SHOULD NOT setConnectionthemselves. - -
-
100 Continue-- When an HTTP/1.1 client sendsExpect: 100-continue, the server sends an interim100 Continuebefore the application first reads the request body, then delivers the body throughreceiveas usual. This is transparent to the application. HTTP/2 has noExpect: 100-continuemechanism.
Server-generated responses. A server MAY originate a response on its own -- before the application runs, or in place of it -- for protocol and resource errors it detects, each carrying a short text/plain body. Applications cannot intercept these (the request never reaches the application, or the application has already failed). A conforming server is expected to generate at least:
- -
-
400 Bad Request-- a malformed request line, headers, or message framing. - -
-
413 Payload Too Large-- the request body, or its declaredContent-Length, exceeds the server's configuredmax_body_size. - -
-
414 URI Too Long/431 Request Header Fields Too Large-- the request line or header section exceeds the server's limits. - -
-
500 Internal Server Error-- the application threw before sendinghttp.response.start. (Once the response has started the server can only truncate the connection -- see "Disconnected Client - sending after disconnect".) - -
-
501 Not Implemented-- an unsupported request, e.g. an unrecognizedTransfer-Encoding, or an ExtendedCONNECTwithout a supported:protocol.
The exact reason phrases and body text are server-defined; applications MUST NOT depend on them.
Disconnected Client - sending after disconnect
If the client disconnects or cancels the connection, servers MUST send an explicit http.disconnect event to the application and update the connection state (see "Connection State").
A subsequent $send is a no-op: it neither delivers data nor raises. Applications therefore detect disconnection through the http.disconnect event or the pagi.connection connection-state object (see "Connection State") -- PAGI's non-destructive, race-friendly mechanism for exactly this -- rather than by inspecting the result of $send.
Applications MUST gracefully handle disconnect events by:
- -
-
Immediately halting unnecessary processing upon disconnect
- -
-
Optionally sending minimal final acknowledgment messages
- -
-
Executing asynchronous cleanup of resources as necessary.
Disconnect - receive event
Sent to the application if receive is called after a response has been sent or after the HTTP connection has been closed.
Keys:
Connection State
The pagi.connection scope key provides a mechanism for applications to detect client disconnection without consuming messages from the receive queue. This addresses a fundamental limitation where checking for disconnect via receive() may inadvertently consume request body data.
The Problem
In PAGI's pull-based receive model, the only way to know if a client has disconnected is to consume the next message:
my $message = await $receive->();
if ($message->{type} eq 'http.disconnect') {
# Client is gone - but if it wasn't a disconnect, that data is lost!
}
The connection state object solves this with synchronous, non-destructive checks.
Connection Object Interface
The object reports two distinct ways a request can end, through separate callbacks, so an application never has to disambiguate them:
An abnormal disconnect -- the client goes away, a timeout fires, or an error occurs -- while the application is still working.
The request completing successfully -- the response was fully delivered.
When a server provides on_complete, exactly one of on_disconnect and on_complete fires for a given request.
Servers MUST provide:
is_connected() - Returns true while the connection is open, false once it has closed (for any reason).
my $connected = $conn->is_connected; # Boolean, synchronous
disconnect_reason() - Returns the abnormal-disconnect reason string (see "Standard Disconnect Reasons"), or undef if the connection is still open or completed normally.
my $reason = $conn->disconnect_reason; # String or undef
on_disconnect($callback) - Registers a callback invoked only on abnormal disconnect (client gone, timeout, error) -- not on normal completion. The callback receives the disconnect reason.
$conn->on_disconnect(sub {
my ($reason) = @_;
cancel_work(); # the client is gone; stop and clean up
});
- -
-
May be called multiple times to register multiple callbacks
- -
-
Callbacks are invoked in registration order
- -
-
If registered after disconnect already occurred, the callback is invoked immediately
response_started() - Returns true once this request's response has started -- that is, once http.response.start has been emitted for this scope -- and false before. The server sets it when it processes the response-start event, so it is true no matter who produced the response: the application, a framework, a middleware that short-circuited the request, or a server-synthesized error/backstop response (e.g. the 500 the server emits when the application produces nothing -- see "Application Produced No Response"). Read-only to the application.
response_started does not identify which producer started the response; an application response and a server-synthesized backstop set it alike. The clean-versus-abnormal outcome is reported separately by the request's terminal signal -- on_complete on a clean finish, on_disconnect with disconnect_reason 'server_error' on a server backstop.
my $started = $conn->response_started; # Boolean, synchronous
A streaming response counts as started for its whole lifetime: response_started becomes true at http.response.start and stays true while body chunks flow. Use response_complete to learn when the body has actually finished.
Servers SHOULD also provide:
on_complete($callback) - Registers a callback invoked only when the request completes successfully (the response was fully delivered without the client disconnecting). The counterpart to on_disconnect.
$conn->on_complete(sub {
commit_transaction(); # finished cleanly
});
response_complete() - Returns true once this request's response body has been fully sent -- the terminal http.response.body with more => 0, a file/fh response, or the final trailers -- false while a response is still streaming or has not started, and undef if the server does not track completion. The synchronous counterpart to on_complete, and read-only to the application. As with disconnect_future, test defined to learn whether the feature is available before relying on it.
my $done = $conn->response_complete; # undef = unsupported, else 0 or 1
This accessor is SHOULD, not MUST, on purpose. "The body has been fully sent" is not always a crisp instant: buffering, HTTP/2 flow-control windows, and intermediaries can blur exactly when the last byte reaches the client, so some servers cannot report completion precisely. The load-bearing fact -- whether a response started -- is the MUST (response_started); completion is a convenience layered on top, which is also why it shares the SHOULD level of its callback form on_complete. A future revision may promote both to MUST if implementation experience shows the timing can be pinned down reliably across transports.
disconnect_future() - Returns a Future that resolves, with the reason, on abnormal disconnect -- useful for racing against long-running work, e.g. Future->wait_any($work, $conn->disconnect_future). Returns undef if not supported.
my $future = $conn->disconnect_future; # Future or undef
my $reason = await $future;
What "connection" means here
The object is named for the connection, but it is scoped to a single request: each request gets its own pagi.connection, and the response_started / response_complete flags describe that request's response, never the transport as a whole. This matters the moment a transport carries more than one request:
HTTP/1.1 reuses a connection for requests sequentially (keep-alive); each request is a fresh scope with its own connection object.
HTTP/2 multiplexes many requests over one TCP connection as independent streams. PAGI maps each stream to its own
httpscope (see "HTTP/2 Stream Mapping"), so ten concurrent streams have ten connection objects and ten independentresponse_startedflags -- one request starting its response is never visible as another's.HTTP/3 (when supported) multiplexes streams over QUIC the same way; the per-request scoping is identical, so nothing here changes.
The response_started / response_complete flags are defined for the http scope only. SSE is treated as its own scope type (type => 'sse') even though it is really a long-lived response over HTTP: it begins with sse.start rather than http.response.start, so these HTTP flags do not apply to it. An SSE-specific equivalent may be defined later if a need arises.
Standard Disconnect Reasons
Every reason below describes an abnormal end to the request -- the connection went away, a timeout fired, or the server aborted the exchange before the response was fully delivered. These are the values reported by disconnect_reason(), passed to on_disconnect callbacks, and resolved from disconnect_future(). A request that completes successfully has no reason (disconnect_reason() returns undef and on_complete fires instead).
Servers MUST use these standard reason strings where the condition applies:
client_closed-
Client initiated clean close (TCP FIN) mid-request
client_timeout-
Client stopped responding (read timeout)
idle_timeout-
Connection idle too long before the request arrived
keepalive_timeout-
Keep-alive connection idled out between requests
write_timeout-
Response write timed out
write_error-
Socket write failed (EPIPE, ECONNRESET)
read_error-
Socket read failed
protocol_error-
HTTP parse error, invalid request
server_shutdown-
Server shutting down gracefully
server_error-
Unhandled server-side error aborted the request
body_too_large-
Request body exceeded limit
queue_overflow-
A bounded server queue (e.g. outgoing frames or buffered events) overflowed and the connection was dropped to relieve backpressure
Servers MAY define additional reasons prefixed with x- (e.g., x-rate-limited).
Server Requirements
MUST provide
pagi.connectionin scope forhttptype requestsMUST implement
is_connected(),disconnect_reason(), andon_disconnect()methodsSHOULD implement
on_complete()anddisconnect_future()MUST fire
on_disconnectcallbacks only on abnormal disconnect, andon_completecallbacks only on successful completion -- never both for the same requestMUST update connection state as soon as disconnect is detected
MUST use standard reason strings where applicable
MUST NOT transition
is_connected()back to true once false (one-way transition)
State Transition Order
When an abnormal disconnect is detected, servers MUST update state in this order:
Set
is_connected()to return falseSet
disconnect_reason()to return the reason stringResolve
disconnect_future()with the reason (if provided)Invoke
on_disconnectcallbacks in registration orderSend
http.disconnectmessage to receive queue
When a request completes successfully, servers MUST invoke on_complete callbacks in registration order and MUST leave disconnect_reason() as undef. on_disconnect callbacks and disconnect_future() MUST NOT fire on this path.
Applicability
http-
MUST provide
pagi.connection websocket-
NOT APPLICABLE (use
websocket.disconnectevents) sse-
NOT APPLICABLE (use
sse.disconnectevents)
WebSocket and SSE have dedicated disconnect events and typically use handler objects (like PAGI::WebSocket, PAGI::SSE) that already manage connection state.
Example: Basic Connection Check
async sub handler {
my ($scope, $receive, $send) = @_;
my $conn = $scope->{'pagi.connection'};
# Check before expensive work
return unless $conn->is_connected;
my $result = await expensive_operation();
# Check again before responding
return unless $conn->is_connected;
await $send->({ type => 'http.response.start', status => 200, headers => [] });
await $send->({ type => 'http.response.body', body => $result, more => 0 });
}
Example: Cleanup on Disconnect vs. Completion
async sub handler {
my ($scope, $receive, $send) = @_;
my $conn = $scope->{'pagi.connection'};
my $temp_file = create_temp_file();
# Abnormal end: client vanished, or a timeout/error fired mid-request.
$conn->on_disconnect(sub {
my ($reason) = @_;
$temp_file->unlink;
log_info("Aborted, client gone: $reason");
});
# Clean end: the response was fully delivered.
$conn->on_complete(sub {
$temp_file->unlink;
log_info("Delivered OK");
});
my $result = await process_data($temp_file);
await send_response($send, $result);
}
Exactly one of the two callbacks runs, so the temp file is always removed exactly once -- no manual cleanup in the request body, and no risk of a double-unlink.
Example: Racing Against Disconnect
async sub long_poll_handler {
my ($scope, $receive, $send) = @_;
my $conn = $scope->{'pagi.connection'};
my $disconnect_future = $conn->disconnect_future;
my $event_future = wait_for_event();
if ($disconnect_future) {
# Race: wait for event OR disconnect
await Future->wait_any($disconnect_future, $event_future);
return unless $conn->is_connected;
}
my $event = $event_future->get;
await send_response($send, $event);
}
WebSocket
WebSocket servers handle fragmentation and PING/PONG messages. Servers MUST wait for a reply to websocket.connect before completing the handshake. If websocket.close is sent instead of websocket.accept, the server MUST reject the connection with HTTP 403.
WebSocket Connection Scope
- -
-
type(String) --"websocket" - -
-
$scope->{pagi}{version}(String) -- The PAGI core spec version (e.g.'0.3') - -
-
$scope->{pagi}{spec_version}(String) -- The version of this WebSocket sub-specification (optional; if omitted, assume it matchesversion) - -
-
http_version(String, default"1.1") -- HTTP version used for handshake - -
-
scheme(String, default"ws") -- URL scheme ("ws"or"wss") - -
-
path(String) -- Decoded path string - -
-
raw_path(Bytes, optional) -- Original path bytes from request - -
-
query_string(Bytes) -- Percent-encoded query string - -
-
root_path(String, default"") -- Mount point for application - -
-
headers(ArrayRef[ArrayRef[Bytes]]) -- Original headers - -
-
client(ArrayRef[String, Int], optional) - -
-
server(ArrayRef[String, Optional[Int]], optional) - -
-
subprotocols(ArrayRef[String], default[]) - -
-
state(HashRef, optional) - -
-
extensions(HashRef, default{}) -- Optional server capabilities advertised for this connection (e.g.tls,websocket.http.response). See PAGI::Spec::Extensions.
Handshake Headers and Subprotocols
The headers arrayref must include all WebSocket handshake headers as raw byte strings, lower-cased, for example:
- -
-
upgrade,connection,sec-websocket-key,sec-websocket-version,host, etc. - -
-
sec-websocket-protocol(if present)
The subprotocols key is an arrayref of strings parsed from the Sec-WebSocket-Protocol header by splitting on commas and trimming whitespace. If the header is absent, subprotocols MUST be an empty arrayref.
WebSocket Events
Connect - receive event
Accept - send event
- -
-
type--"websocket.accept" - -
-
subprotocol(String, optional) - -
-
headers(ArrayRef[ArrayRef[Bytes]], optional)
Receive - receive event
Exactly one must be non-null.
The server must UTF-8 decode incoming text frames into Unicode characters for text, and UTF-8 encode outgoing text values to wire format. Binary frames pass through as raw bytes without encoding transformation.
If a text frame contains invalid UTF-8, the server must fail the WebSocket connection with close code 1007 (Invalid frame payload data) per RFC 6455.
Send - send event
Exactly one of bytes or text must be non-null.
Keepalive - send event
Enables WebSocket protocol-level ping/pong keepalive. The server sends ping frames (opcode 0x9) at the specified interval. Clients automatically respond with pong frames per RFC 6455 - no application code required.
- -
-
type--"websocket.keepalive" - -
-
interval(Number) -- Seconds between ping frames.0disables keepalive. - -
-
timeout(Number, optional) -- Seconds to wait for pong response. If no pong is received within this time, the connection is closed with code 1006 and the application receives awebsocket.disconnectevent withreason => 'keepalive_timeout'.
Behavior:
- -
-
Multiple
websocket.keepaliveevents update settings (last wins) - -
-
Omitting
timeoutenables keepalive without dead connection detection (useful for high-latency connections) - -
-
Setting
interval => 0stops the keepalive timer
Example:
# Enable keepalive with 30s ping interval, 20s pong timeout
await $send->({
type => 'websocket.keepalive',
interval => 30,
timeout => 20,
});
Disconnect - receive event
Sent when the WebSocket connection is closed, either by the client, server, or due to error conditions.
- -
-
type--"websocket.disconnect" - -
-
code(Int) -- WebSocket close code per RFC 6455. When the peer sent a close frame, this is its code (or1005, "no status received", if the frame carried none). When the connection dropped or was aborted with no close handshake -- timeout, write error, server shutdown -- servers MUST report1006("abnormal closure"). When the server itself closes after a protocol violation,codeis the close code it sent (e.g.1002for a framing error or1007for an invalid UTF-8 payload). - -
-
reason(String, default empty) -- For a server-detected abnormal close, the matching standard token from "Standard Disconnect Reasons". When the peer sent the Close frame, this is the peer's own reason text, which MAY be empty.
The code carries the RFC 6455 close code and reason supplements it with a standard token (see "Standard Disconnect Reasons"), so an application can branch on one reason vocabulary across HTTP, WebSocket, and SSE. How the two pair up depends on how the connection ended:
The peer sent a Close frame.
codeis the peer's own close code (1005if the frame carried none) andreasonis the peer's reason text, commonly empty. The server does not substitute a PAGI token in this case.Abnormal drop with no close handshake -- a bare TCP FIN, a timeout, or a write failure.
codeis1006("abnormal closure") andreasonis the token for the condition:client_closed,keepalive_timeout,write_timeout,write_error, orserver_shutdown.Server-initiated protocol close -- the server detected a framing or protocol violation and sent the Close frame itself.
codeis the RFC 6455 code for the fault (1002for a framing/protocol error,1007for an invalid UTF-8 payload) withreasonprotocol_error; a bounded-queue overflow closes with1008andreasonqueue_overflow.
These pairings describe the reference server. The binding contract is only that code is a valid RFC 6455 close code and reason, when the server can name one, is a token from "Standard Disconnect Reasons"; applications SHOULD branch on reason rather than code.
Disconnected Client - sending after disconnect
A $send after the connection has closed is a no-op; applications detect a closed WebSocket through the websocket.disconnect event delivered to $receive.
Close - send event
WebSocket Protocol Enforcement
A conforming server enforces the framing rules of RFC 6455 and fails the connection with the appropriate close code when a client violates them. The reference server sends:
- -
-
1002 (Protocol Error) -- a reserved RSV bit is set, a reserved or unknown opcode is used, a control frame exceeds 125 bytes, or a
Closeframe carries a malformed length or an out-of-range close code. - -
-
1007 (Invalid Frame Payload Data) -- a text frame, or a
Closeframe's reason, is not valid UTF-8. - -
-
1008 (Policy Violation) -- the application is not draining messages and the server's inbound queue limit is reached (backpressure shedding).
A frame whose payload exceeds the server's configured max_ws_frame_size fails the connection; the application observes a websocket.disconnect with code 1006 (a server MAY instead send 1009 Message Too Big).
Transport note: over HTTP/2 (below) some of these checks are delegated to the HTTP/2 and frame-parser layers, so the exact code for a given malformation MAY differ; the UTF-8 (1007) and Close-frame validations apply on both transports.
WebSocket over HTTP/2 (RFC 8441)
A server MAY accept WebSocket connections over HTTP/2 using the bootstrapping mechanism of RFC 8441. This is transparent to the application: the websocket scope and the websocket.* events are identical to HTTP/1.1 and http_version is "2". The differences are confined to the handshake and live entirely in the server:
- -
-
Detection -- instead of an HTTP/1.1
Upgrade, the client opens an ExtendedCONNECTstream (:method=CONNECT,:protocol=websocket), which the server enables by advertisingSETTINGS_ENABLE_CONNECT_PROTOCOL. - -
-
Accept --
websocket.acceptis answered with HTTP status 200, not the HTTP/1.1101 Switching Protocols;subprotocolstill maps toSec-WebSocket-Protocol, and there is noUpgradeorSec-WebSocket-Acceptheader. - -
-
Data and close -- WebSocket frames travel as HTTP/2
DATAframes on the stream. Closing the stream (a clientRST_STREAM, or END_STREAM) surfaces as awebsocket.disconnect: an abnormal teardown reports code1006, while a clean WebSocketCloseframe carries its own code and reason as on HTTP/1.1.
Servers that do not implement RFC 8441 simply never produce a websocket scope over HTTP/2; applications need not distinguish the two cases.
WebSocket Denial Response (extension)
By default an application rejects a handshake by sending websocket.close before websocket.accept, and the server replies with a bare 403 Forbidden. The denial response extension lets the application reject with a custom HTTP response instead -- a 401 with a WWW-Authenticate header, a 429 with Retry-After, a redirect, a JSON error body, and so on. It mirrors ASGI's websocket.http.response extension.
A server that supports it advertises extensions->{'websocket.http.response'} as an empty hashref on the websocket scope. To deny, the application sends two events in place of websocket.accept, only valid before the connection is accepted:
- -
-
websocket.http.response.start--{ status (Int), headers (ArrayRef[ArrayRef[Bytes]]) }, structured exactly likehttp.response.start. - -
-
websocket.http.response.body--{ body (Bytes, default ""), more (Int, default 0) }, structured exactly likehttp.response.body(only thebodyform applies;file/fhare not used for denial responses); setmore => 1to send the body in multiple events.
The server sends the resulting HTTP response and then closes the connection; the WebSocket is never established, so no websocket.disconnect is delivered. After websocket.accept these events are invalid and MUST be ignored.
# Reject the handshake with a custom 401 instead of a bare 403:
my $connect = await $receive->(); # websocket.connect
if (!authorized($scope)) {
await $send->({
type => 'websocket.http.response.start',
status => 401,
headers => [
[ 'content-type', 'application/json' ],
[ 'www-authenticate', 'Bearer' ],
],
});
await $send->({
type => 'websocket.http.response.body',
body => '{"error":"unauthorized"}',
});
return;
}
await $send->({ type => 'websocket.accept' });
Applications SHOULD check for the extension and fall back to websocket.close when it is absent:
if ($scope->{extensions}{'websocket.http.response'}) { ... deny with a response ... }
else { await $send->({ type => 'websocket.close' }) }
The PAGI::WebSocket helper in PAGI-Tools wraps this as $ws->deny(status => 401, headers => [...], body => ...), falling back to a plain close when the server does not advertise the extension.
Server-Sent Events (SSE)
SSE connections stream text/event-stream data to clients.
SSE Connection Detection
PAGI servers MUST detect SSE requests and assign a scope of type sse when all of the following are true:
- -
-
The
Acceptheader includes the media typetext/event-stream. - -
-
The request has not been upgraded to WebSocket.
Otherwise the connection uses a normal http scope.
Note on HTTP methods: SSE works with any HTTP method, not just GET. While the browser's native EventSource API only supports GET, libraries like Microsoft's fetch-event-source (used by htmx 4, datastar, and others) enable SSE over POST, PUT, and other methods via the Fetch API. PAGI servers MUST support SSE for all HTTP methods to enable these modern patterns.
Routing based on URL or application logic is not used to infer SSE.
SSE Connection Scope
SSE scopes reuse the HTTP scope structure. Servers MUST populate the same keys (http_version, method, scheme, path, headers, client, server, state, the pagi dict, and extensions) but set type => 'sse'. Header casing rules follow the HTTP section.
Request Body - receive event
For SSE requests with a body (POST, PUT, etc.), the application receives the body via sse.request events, similar to HTTP:
- -
-
type--"sse.request" - -
-
body(Bytes, default"") -- Request body chunk - -
-
more(Int, default0) --1if more body data is forthcoming, otherwise0
For GET requests (no body), a single sse.request event with empty body and more => 0 is returned.
Example (POST SSE with htmx/datastar):
my $event = await $receive->();
if ($event->{type} eq 'sse.request') {
my $body = $event->{body};
# Parse JSON body, extract query parameters, etc.
}
await $send->({ type => 'sse.start', status => 200 });
# ... send SSE events based on POST body ...
Start SSE - send event
sse.start replaces http.response.start for SSE connections and MUST be sent before any sse.send events.
- -
-
type--"sse.start" - -
-
status(Int, default200) - -
-
headers(ArrayRef[ArrayRef[Bytes]]) -- Must includecontent-type => 'text/event-stream'unless already supplied by middleware.
On sse.start the server supplies the headers a well-behaved event stream needs when the application did not: Content-Type: text/event-stream (if absent), Cache-Control: no-cache, and a Date header. Over HTTP/1.1 it additionally sends Connection: keep-alive and frames the stream with chunked Transfer-Encoding; HTTP/2 multiplexes and DATA-frames the stream, so those connection-specific headers do not apply. Applications need not set any of these.
Send SSE - send event
sse.send emits a single SSE dispatch. Fields marked "String" are Unicode strings per the core data-type rules and MUST be UTF-8 encoded by the server before transmission.
- -
-
type--"sse.send" - -
-
event(String, optional) - -
-
data(String) -- Required text payload - -
-
id(String, optional) - -
-
retry(Int, optional) -- Milliseconds for theretry:directive
Field validation. The event and id fields MUST NOT contain newline (\n or \r) characters: a newline there would forge an SSE frame boundary and let one dispatch inject another. A server MUST reject such a value -- the reference server raises, failing the send. This is the SSE-framing case of the same byte safety the server enforces on response headers (see "Response Start - send event"). retry MUST be a non-negative integer. data is the one field that legitimately contains newlines: the server splits it on newlines and emits one data: line per segment, preserving multi-line payloads.
To end the SSE stream the application simply returns after the final sse.send. The server will flush buffered events and close the HTTP connection.
SSE Comment - send event
sse.comment sends an SSE comment line. Comments start with a colon (:) and are used for keepalive pings or protocol-level messages. Comments do NOT trigger the client's onmessage handler in browsers, making them ideal for connection maintenance.
- -
-
type--"sse.comment" - -
-
comment(String) -- Comment text. If the text does not start with:, the server MUST prepend one.
Example:
# Keepalive ping (no browser callback triggered)
await $send->({
type => 'sse.comment',
comment => ':keepalive',
});
The server emits the comment followed by two newlines (:keepalive\n\n). This keeps the connection alive through proxies without triggering application-level event handlers on the client.
SSE Keepalive - send event
Enables automatic SSE keepalive comments. The server sends comment lines at the specified interval to prevent proxy/load balancer timeouts on idle connections.
- -
-
type--"sse.keepalive" - -
-
interval(Number) -- Seconds between keepalive comments.0disables keepalive. - -
-
comment(String, default'') -- Comment text to send. Empty string sends just:followed by newlines.
Behavior:
- -
-
Multiple
sse.keepaliveevents update settings (last wins) - -
-
Setting
interval => 0stops the keepalive timer - -
-
Comments do not trigger client's
onmessagehandler
Example:
# Enable keepalive with 30s interval
await $send->({
type => 'sse.keepalive',
interval => 30,
comment => 'ping',
});
SSE Disconnect - receive event
Sent to the application if the client disconnects or if the server shuts down the SSE stream after sse.start.
- -
-
type--"sse.disconnect" - -
-
reason(String) -- Standard reason string from "Standard Disconnect Reasons"
Common reasons:
client_closed-
Client closed the connection
write_error-
Failed to write to the socket (keepalive or event)
write_timeout-
Send timeout exceeded
server_shutdown-
Server shut down the stream
Transport Flow Control
The pagi.transport scope key provides a synchronous, read-only handle for inspecting outbound flow control -- how many bytes the server has queued for the client but not yet written to the network. It lets an application observe backpressure and make its own delivery decisions (conflate, coalesce, shed load, or disconnect a slow client) instead of only blocking until the buffer drains. It is the server-side analogue of the browser WebSocket API's bufferedAmount, generalized across the http, websocket, and sse scope types.
Servers SHOULD provide pagi.transport for http, websocket, and sse scopes. A server that cannot determine its outbound buffer state omits the key; applications MUST treat its absence as "flow-control introspection unavailable" (and the high-level helpers report a buffered amount of 0).
The long-term goal is universal coverage: every scope type that produces a stream of outbound events provides pagi.transport, across every transport a server supports, so an application need not know which transport or protocol version carries its events. A missing key therefore signals a capability limitation -- a server still being built out, or a transport whose outbound buffer the server cannot yet measure -- not a steady state for applications to design around. Write transport-agnostic code: treat the key's presence as the norm and tolerate its absence per the contract above.
Methods
buffered_amount() -- Returns the number of bytes queued for the client but not yet written to the network, as an integer; 0 when the send buffer is fully drained. This is a synchronous, non-blocking, non-destructive read -- it neither sends, receives, nor consumes queued messages.
my $pending = $scope->{'pagi.transport'}->buffered_amount;
If a server provides pagi.transport, it must implement buffered_amount.
high_water_mark() -- Returns the buffered-byte threshold at or above which the server applies backpressure: a $send that would exceed it blocks until the buffer drains. Applications use it to threshold relative to the ceiling rather than hard-coding a byte count. Returns undef if the server has no fixed high-water mark.
low_water_mark() -- Returns the buffered-byte threshold the buffer must fall back to before the server releases backpressure (the drain point). Returns undef if not applicable.
Servers SHOULD implement high_water_mark and low_water_mark where they use a watermark-based backpressure model.
Backpressure callbacks
The synchronous reads above suit a producer that checks before each send. A producer that cannot self-pace with a blocking $send -- an event-driven source that pushes a message per incoming event, say -- instead wants to be told when to pause and resume. For that, the handle provides two edge-triggered callbacks.
on_high_water($callback) -- Registers a callback invoked when the outbound buffer reaches or exceeds high_water_mark (backpressure engaged). If the buffer is already at or above the mark when the callback is registered, the callback is invoked immediately.
$transport->on_high_water(sub { $source->pause });
on_drain($callback) -- Registers a callback invoked when the outbound buffer falls back below low_water_mark after having reached the high mark (backpressure released). It is not invoked merely because the buffer is below the low mark at registration time -- only on an actual high-then-low transition.
$transport->on_drain(sub { $source->resume });
The two form a hysteresis cycle: on_high_water fires once when the buffer crosses up to the high mark, then on_drain fires once when it falls back below the low mark, then the cycle re-arms. The gap between the marks prevents flapping when the buffered amount hovers near a single threshold. Multiple callbacks may be registered for each and are invoked in registration order; the callbacks receive no arguments (read buffered_amount if the current depth is needed).
Servers that provide pagi.transport with a watermark-based backpressure model SHOULD implement on_high_water and on_drain.
Applicability
http-
Bytes queued for the current (streaming) response.
websocket-
Bytes queued for the WebSocket session.
sse-
Bytes queued for the SSE stream.
Example: conflating a live feed under backpressure
When a client falls behind on a high-frequency stream, sending the next update only while the backlog is small keeps the client current (sparse but fresh) rather than stale (dense but lagging):
my $transport = $scope->{'pagi.transport'};
while (1) {
my $update = await next_update();
# Skip this frame if the client is already behind; it will get the
# next, fresher one. Threshold relative to the server's ceiling.
if ($transport) {
my $ceiling = $transport->high_water_mark // 65536;
next if $transport->buffered_amount > $ceiling / 2;
}
await $send->({ type => 'websocket.send', text => $update });
}
The high-level helpers in PAGI::WebSocket, PAGI::SSE, and PAGI::Request expose these as $obj->buffered_amount, $obj->high_water_mark, and $obj->low_water_mark.
PAGI to PSGI Compatibility
PAGI translates keys explicitly to maintain compatibility with PSGI:
- -
-
REQUEST_METHOD->method - -
-
SCRIPT_NAME->root_path - -
-
PATH_INFO->pathminusroot_path - -
-
QUERY_STRING->query_string - -
-
CONTENT_TYPE-> extracted fromheaders - -
-
CONTENT_LENGTH-> extracted fromheaders - -
-
SERVER_NAME,SERVER_PORT->server - -
-
REMOTE_ADDR,REMOTE_PORT->client - -
-
SERVER_PROTOCOL->http_version - -
-
psgi.url_scheme->scheme - -
-
psgi.version->[1, 1](PAGI servers MUST advertise the PSGI version they emulate when bridging) - -
-
psgi.input-> constructed fromhttp.requestevents - -
-
psgi.errors-> handled by the server as appropriate - -
-
psgi.streaming,psgi.nonblocking,psgi.multithread,psgi.multiprocess-> derived from PAGI server capabilities and advertised via PSGI adapter docs
Response mappings:
- -
-
statusandheadersmap directly tohttp.response.start - -
-
Body content from PSGI maps directly to
http.response.bodymessages.
PAGI Encoding Differences
- -
-
path: Decoded UTF-8 string from percent-encoded input. The server first percent-decodesraw_path, then attempts UTF-8 decoding of the resulting bytes into Unicode characters. If the bytes are not valid UTF-8, the server should fall back to the original percent-decoded bytes rather than replacing invalid sequences or rejecting the request (Mojolicious-style fallback). Applications needing strict UTF-8 validation can checkraw_pathand decode themselves withEncode::FB_CROAK. - -
-
headers: Represented as bytes exactly as sent/received - -
-
query_string: Raw bytes from URL after?, percent-encoded - -
-
root_path: Unicode path string matchingSCRIPT_NAME
Version History
- -
-
0.3(Draft):pagi.transportflow control;websocket.http.responsedenial-response extension;on_completeplus reworked abnormal-only disconnect reasons;response_started(MUST) andresponse_complete(SHOULD) connection-state accessors for per-request response progress; documented server-supplied behaviors and WebSocket over HTTP/2; removedpagi.featuresand the per-sendtimeoutfield. - -
-
0.2(Draft): SSE POST method support (sse.request), keepalive events, disconnect reasons,pagi.connection, clarified scope fields. - -
-
0.1(Draft): Initial draft based on ASGI 2.5, supporting HTTP, WebSocket, and SSE.
Copyright
This document has been placed in the public domain.