NAME
PAGI::Spec::Www - PAGI Specification Documentation
NOTICE
This documentation is auto-generated from the PAGI specification markdown files. For the authoritative source, see:
https://github.com/jjn1056/PAGI/tree/main/docs/specs
HTTP, WebSocket & SSE PAGI Message Format
Version: 0.2 (Draft)
The HTTP, WebSocket & SSE PAGI sub-specification defines how HTTP/1.1, HTTP/2, WebSocket, and Server-Side 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.2: Added keepalive events (websocket.keepalive,sse.keepalive), send timeouts, disconnect reasons, SSE support for all HTTP methods (not just GET) withsse.requestreceive event for request body. Addedpagi.connectionscope key for non-destructive disconnect detection.0.1: Initial draft, based on ASGI 2.5, including Server-Side 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:
perl # 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:
perl # Remove all cookies my @filtered = grep { $_->[0] ne 'set-cookie' } @$headers;
# Find a header my ($ct) = grep { $I<< -> >>I<0>I<>I<] 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:
Scalars, arrayrefs, hashrefs
Blessed objects
Code references
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.ENDSTREAM: if no DATA frames, send an
http.requestwithbody => ''andmore => 0to signal end of request.RSTSTREAM: trigger a
http.disconnectevent and cancel any outstanding Futures for that scope.WINDOWUPDATE / PRIORITY: ignored by default (advanced flow control is optional).
PUSHPROMISE: 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 and HTTP/3 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"pagi["version"](String) -- PAGI versionpagi["spec_version"](String) -- PAGI HTTP spec version (default"0.2")http_version(String) --"1.0","1.1", or"2"method(String) -- Uppercase HTTP methodscheme(String, default"http") -- URL scheme ("http"or"https")path(String) -- Decoded HTTP pathraw_path(Bytes, optional) -- Original HTTP path bytesquery_string(Bytes) -- Percent-encoded query stringroot_path(String, default"") -- Application mount path, equivalent toSCRIPT_NAMEin PSGIheaders(ArrayRefArrayRef[Bytes]]) -- Original HTTP headers. Header names must be lower-cased byte strings and header values must be opaque byte strings.client(ArrayRefString, Int], optional) --[host, port]of clientserver(ArrayRefString, Optional[Int]], optional) --[host, port]or[path, undef]for Unix socketsstate(HashRef, optional) -- State namespace from lifespanpagi.connection(Object) -- Connection state object for disconnect detection. See Connection State below.
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 chunkmore(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 codeheaders(ArrayRefArrayRef[Bytes]], default[]) -- Response headerstrailers(Int, default0) --1if trailers will be sent after body viahttp.response.trailers, otherwise0
Response Body - send event
Keys:
type--"http.response.body"body(Bytes, default"") -- Response body chunkfile(String) -- Absolute path to file for server to open and streamfh(Filehandle) -- Already-open filehandle for server to streamoffset(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 itWhen 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 exceptionIf
fhis invalid or closed, the$send->()Future MUST fail immediatelyApplications SHOULD validate file existence before sending
http.response.startto avoid incomplete responses
Validation:
offsetMUST be a non-negative integerlengthMUST be a non-negative integer if providedIf
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(ArrayRefArrayRef[Bytes]], default[]) -- Trailer headers encoded the same way as response headers (lower-case names, byte values)
Disconnected Client - send exception
If the client disconnects or cancels the connection, servers MUST send an explicit disconnect event to the application.
Any subsequent $send invocation must fail its returned Future (or throw) with a Perl exception class that indicates the disconnect (e.g., PAGI::Error::Disconnected). Servers MUST NOT expose Python exceptions such as OSError.
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:
type--"http.disconnect"
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
Servers MUST provide a connection state object with these methods:
is_connected() - Returns true if the connection is still open.
my $connected = $conn->is_connected; # Boolean, synchronous
disconnect_reason() - Returns the disconnect reason string, or undef if still connected.
my $reason = $conn->disconnect_reason; # String or undef
on_disconnect($callback) - Registers a callback to be invoked when disconnect occurs.
$conn->on_disconnect(sub {
my ($reason) = @_;
cleanup_resources();
});
May be called multiple times to register multiple callbacks
Callbacks are invoked in registration order
If registered after disconnect already occurred, callback is invoked immediately
disconnect_future() - Returns a Future that resolves when the connection closes.
my $future = $conn->disconnect_future; # Future or undef
my $reason = await $future;
Servers SHOULD provide this method. Returns undef if not supported.
Standard Disconnect Reasons
Servers MUST use these standard reason strings:
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
disconnect_future()method returning a FutureMUST 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 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
Applicability
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
async sub handler {
my ($scope, $receive, $send) = @_;
my $conn = $scope->{'pagi.connection'};
my $temp_file = create_temp_file();
# Register cleanup - runs automatically if client disconnects
$conn->on_disconnect(sub {
my ($reason) = @_;
$temp_file->unlink;
log_info("Client disconnected: $reason");
});
my $result = await process_data($temp_file);
$temp_file->unlink; # Normal cleanup
await send_response($send, $result);
}
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"pagi["version"](String) -- PAGI versionpagi["spec_version"](String) -- PAGI HTTP spec version (default"0.2")http_version(String, default"1.1") -- HTTP version used for handshakescheme(String, default"ws") -- URL scheme ("ws"or"wss")path(String) -- Decoded path stringraw_path(Bytes, optional) -- Original path bytes from requestquery_string(Bytes) -- Percent-encoded query stringroot_path(String, default"") -- Mount point for applicationheaders(ArrayRefArrayRef[Bytes]]) -- Original headersclient(ArrayRefString, Int], optional)server(ArrayRefString, Optional[Int]], optional)subprotocols(ArrayRefString], default[])state(HashRef, optional) #### Handshake Headers and Subprotocols Theheadersarrayref 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) Thesubprotocolskey is an arrayref of strings parsed from theSec-WebSocket-Protocolheader by splitting on commas and trimming whitespace. If the header is absent,subprotocolsMUST be an empty arrayref.
WebSocket Events
Connect - receive event
type--"websocket.connect"
Accept - send event
type--"websocket.accept"subprotocol(String, optional)headers(ArrayRefArrayRef[Bytes]], optional)
Receive - receive event
type--"websocket.receive"bytes(Bytes, optional)text(String, optional)
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
type--"websocket.send"bytes(Bytes, optional)text(String, optional)timeout(Number, optional) -- Send timeout in seconds. If the write does not complete within this time, the Future fails and the connection is closed.
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.keepalive events update settings (last wins) - Omitting timeout enables keepalive without dead connection detection (useful for high-latency connections) - Setting interval => 0 stops the keepalive timer
# Enable keepalive with 30s ping interval, 20s pong timeout
await $send->({
type => 'websocket.keepalive',
interval => 30,
timeout => 20,
});
Example:
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, default1005) -- WebSocket close code per RFC 6455reason(String, default empty) -- Human-readable disconnect reason
Common close codes and reasons:
Disconnected Client - send exception
Raises server-specific subclass of OSError.
Close - send event
type--"websocket.close"code(Int, default1000)reason(String, default empty)
Server-Side 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, etc.) 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 chunkmore(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(ArrayRefArrayRef[Bytes]]) -- Must includecontent-type => 'text/event-stream'unless already supplied by middleware.
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 payloadid(String, optional)retry(Int, optional) -- Milliseconds for theretry:directive
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.keepalive events update settings (last wins) - Setting interval => 0 stops the keepalive timer - Comments do not trigger client's onmessage handler
# Enable keepalive with 30s interval
await $send->({
type => 'sse.keepalive',
interval => 30,
comment => 'ping',
});
Example:
SSE Send Timeout
The sse.send event supports an optional timeout field:
timeout(Number, optional) -- Send timeout in seconds. If the write does not complete within this time, the Future fails and the connection is closed.
await $send->({
type => 'sse.send',
data => 'hello',
timeout => 5,
});
Example:
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, default empty) -- Human-readable disconnect reason
Common reasons:
PAGI to PSGI Compatibility
PAGI translates keys explicitly to maintain compatibility with PSGI:
REQUEST_METHOD->methodSCRIPT_NAME->root_pathPATH_INFO->pathminusroot_pathQUERY_STRING->query_stringCONTENT_TYPE-> extracted fromheadersCONTENT_LENGTH-> extracted fromheadersSERVER_NAME,SERVER_PORT->serverREMOTE_ADDR,REMOTE_PORT->clientSERVER_PROTOCOL->http_versionpsgi.url_scheme->schemepsgi.version->[1, 1](PAGI servers MUST advertise the PSGI version they emulate when bridging)psgi.input-> constructed fromhttp.requesteventspsgi.errors-> handled by the server as appropriatepsgi.streaming,psgi.nonblocking,psgi.multithread,psgi.multiprocess-> derived from PAGI server capabilities and advertised via PSGI adapter docs
Response mappings:
statusandheadersmap directly tohttp.response.startBody 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
B<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 check C<raw_path> and decode themselves with C<Encode::FB_CROAK>. - C<headers>: Represented as bytes exactly as sent/received - C<query_string>: Raw bytes from URL after C<?>, percent-encoded - C<root_path>: Unicode path string matching C<SCRIPT_NAME>
Version History
0.2(Draft): SSE POST method support, keepalive events, disconnect reasons, clarified scope fields (method required for HTTP/SSE, not for WebSocket)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.