NAME
PAGI::Spec::Www - PAGI message formats for HTTP, WebSocket, and SSE
HTTP, WebSocket & SSE PAGI Message Format
Version: 0.6 (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.6:pagi.connectionis provided on everyhttp,websocket, andssescope, with every accessor required, plusabort($detail),disconnect_detail, theapp_abortreason, a per-scope definition of started, clean end, and abnormal end, and the rule that the object's reason and a scope's disconnect event carry the same token. Refusing a WebSocket handshake or an SSE stream is an ordinary HTTP response sent with the HTTP response events beforewebsocket.acceptorsse.start; thewebsocket.http.response.*andsse.http.response.*events and thewebsocket.http.responseextension are removed;websocket.closebefore accept fails.sse.disconnectis delivered beforesse.startas well as after. See PAGI::Upgrading.sse.closeand the WebSocket closing handshake are the terminal events of their scopes: returning aftersse.startwithoutsse.close, or from an accepted WebSocket without sendingwebsocket.closeor receivingwebsocket.disconnect, is an incomplete response, and a WebSocket so abandoned is closed with code1011. The connection object additionally reports the WebSocket peer's Close code and reason throughclose_codeandclose_reason, adds a single terminal observer --on_endandend_future-- that fires for a clean and an abnormal end alike, and adds theclose_timeoutreason for a closing handshake the peer never completes. All terminal callbacks (on_disconnect,on_complete,on_end) are delivered on the event loop, never synchronously inside an application's$sendor$receive. - -
-
0.5: Constrained what a response body may contain and what an intermediary may do to one. A response's body is either a sequence of inlinebodyevents or a singlefile/fhevent, never both; an application MUST NOT sendfile/fhafter inline bytes and the server MUST fail that send. An intermediary MUST NOT invalidate a commitment the response head made -- atrailers => 1declaration, or a206/Content-Rangebody that is a range rather than the representation -- and MUST examine the head before transforming a body or attaching metadata derived from one. An intermediary re-emitting a buffered event MUST preserve the event's other keys, includingmore, whose default of0makes an omission an assertion of completeness. Also states which of the two incomplete-response signals answers which question: absence of the terminal event governs what may be forwarded or synthesized,disconnect_reasongoverns only whether to raise or log. - -
-
0.4: Defined settlement of in-flight I/O at disconnect: a send Future still pending when the connection (or the scope's stream) ends settles by resolving successfully, every pending receive resolves with the disconnect event, and both settlements are ordered after the connection-state transition so that await-then-check is race-free.on_disconnectcallbacks anddisconnect_futureresolution are never delivered synchronously within an application's call into$sendor$receive. The HTTP/2RST_STREAMmapping now names the standard disconnect transition instead of "cancel any outstanding Futures". - -
-
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, HEAD response semantics (server-side body suppression), incomplete-response handling (forced closure), and WebSocket over HTTP/2 (RFC 8441). Tightened SSE detection to an exact media-range match withq > 0(wildcards andq=0never signal SSE). 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.
$scope->{pagi}{spec_version} reports which version of this specification governs an HTTP, WebSocket, or SSE connection. It is optional; if omitted, applications MUST assume '0.1'. Omitting it declares support for version 0.1 only, so the server MUST NOT use behaviour introduced by a later version on that connection. The core PAGI version does not affect this default.
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
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() consumes whatever the scope's queue holds next -- request body on an http scope, websocket.connect or a message on a websocket scope, sse.request on an sse scope.
The Problem
In PAGI's pull-based receive model, the only way to learn that a client has disconnected by using $receive alone is to consume the next event:
my $message = await $receive->();
if ($message->{type} eq 'http.disconnect') {
# Client is gone - but if it wasn't a disconnect, that data is lost!
}
On an http scope the event consumed may be request body. On a websocket scope it may be websocket.connect or a message; on an sse scope it may be sse.request. In every case a component that does not own the receive queue -- a response object, a middleware, a framework helper -- cannot check for a disconnect without stealing an event from the code that does. And after a normally completed refusal (see "Meaning per scope") no disconnect event is ever delivered, so a receive-based watcher has no terminal condition.
The connection state object solves this with synchronous, non-destructive checks and callbacks, on every http, websocket, and sse scope.
Connection Object Interface
Servers MUST provide $scope->{'pagi.connection'} on every http, websocket, and sse scope: one object per scope instance (per request, per WebSocket session including its handshake, per SSE stream), never shared across keep-alive requests or HTTP/2 streams. Every accessor and method in this section is REQUIRED; a server that omits any of them is not conforming. Frameworks may rely on all of them unconditionally on a scope whose $scope->{pagi}{spec_version} is 0.6 or later.
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.
Exactly one of on_disconnect and on_complete fires for a given scope.
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
disconnect_detail() - Returns a free-text diagnostic string for the abnormal end, or undef. Its format is unspecified. It is diagnostic only: never placed on the wire, never included in a client-visible response, never a branching key. Servers MUST NOT require applications to parse it; anything an application needs to branch on is a token in disconnect_reason. It MUST NOT contain request bodies, headers, credentials, or a peer's Close-frame reason text (which is delivered in websocket.disconnect). Servers SHOULD supply it when they know more than the token says: the violated rule for protocol_error; the OS or TLS error for write_error, read_error, and write_timeout; the elapsed interval for keepalive_timeout and idle_timeout; the limit for body_too_large and queue_overflow; the RST_STREAM error code for an HTTP/2 client_closed; the exception message for server_error; the application's own string for app_abort.
my $detail = $conn->disconnect_detail; # e.g. "no pong within 10s", or undef
close_code() / close_reason() - On a websocket scope, return the peer's Close-frame code (an integer) and reason text (a string), or undef before a Close has been observed. They report what the peer sent, never the code the server itself sent: on a server-initiated close (for example the 1011 for an abandoned socket, or a 1002 for a protocol error) they report the peer's own Close if the peer answers with one, and once the scope ends with no Close from the peer they are 1006, never the server's code. Per RFC 6455 section 7.1.5, close_code is 1005 when the handshake completed but the peer's Close carried no code, and 1006 (with close_reason undef) when the transport ended with no Close frame at all; undef is the value only while the scope is still open. On http and sse scopes both return undef. Like every terminal fact they are populated before on_complete/on_end fire. These values agree with the code and reason in the websocket.disconnect receive event on a peer-initiated Close; on a server-initiated close they differ -- the accessor reports 1006 because the peer sent nothing, while the event carries the code the server sent. The accessors let a component that does not own the receive queue read the peer's Close without consuming it.
my $code = $conn->close_code; # peer's code: e.g. 1000, 1005, 1006, or undef
my $reason = $conn->close_reason; # peer's reason text, or undef
on_disconnect($callback) - Registers a callback invoked once when the scope ends abnormally. The callback receives ($reason, $detail): $reason is the token disconnect_reason will return and $detail is the value of disconnect_detail, possibly undef. Callbacks that ignore the second argument are unaffected. Callbacks run in registration order; one callback's exception MUST NOT prevent the others from running. A callback registered after an abnormal end is invoked immediately; one registered after a clean end never runs. The server MUST release its references to registered callbacks once the terminal outcome has been delivered.
$conn->on_disconnect(sub ($reason, $detail) { ... });
Callback invocation context. Servers MUST NOT deliver abnormal-disconnect notifications -- invoking on_disconnect callbacks, or resolving disconnect_future -- synchronously within an application's call into $send or $receive. A server that detects a disconnect while such a call is on the stack updates the synchronous facts immediately (is_connected, disconnect_reason) and defers only the delivery to the event loop. Deferred notifications MUST still be delivered even if the connection is torn down before the deferred delivery runs -- deferral changes when a notification arrives, never whether. The registration-time rule above is unchanged: registering a callback after the transition has already happened invokes it immediately, within the registering call; that re-entrancy is under the application's control.
on_complete and on_end are delivered under the same rule: no terminal callback -- on_disconnect, on_complete, or on_end -- is invoked synchronously within an application's call into $send or $receive, and each is still delivered on the event loop even if the transport is torn down first. Emitting the terminal event -- http.response.body with more => 0, a file/fh body, the final trailers, sse.close, or the completed WebSocket closing handshake -- sets the synchronous fact response_complete() to true at once, but does not synchronously invoke on_complete or on_end; the server MAY deliver them after the handler has returned. An application MUST NOT sequence code on the assumption that a completion callback has already run when its terminal send resolves: it reads response_complete() for the synchronous fact, and uses the callback only for work indifferent to whether it runs before or after the handler unwinds. pagi.transport callbacks carry no such deferral guarantee.
This governs callback delivery only. Separately, a receive that the application's own terminal send resolves (a receive pending when it sends sse.close or a refusal's terminal event) MAY resume the awaiting code inside that send, after the object is marked and before the send's own Future resolves; that receive-resumption is a distinct mechanism and is unchanged. The resumed code observes the synchronous facts already settled (response_complete, is_connected), while the terminal callbacks are delivered afterward on the event loop.
Rationale: a callback that can run inside an application's own half-finished call into $send would force every framework to build re-entrancy defenses around every send. Deferring delivery to the event loop is cheap for servers and eliminates the hazard class, while the synchronous facts stay immediate for polling code.
response_started() - Returns true once this request's response has started -- that is, once http.response.start has been emitted for this scope (see "Meaning per scope" for the websocket and sse equivalents) -- 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.
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
});
A callback registered after a clean end is invoked immediately; one registered after an abnormal end never runs. The server MUST release its references to registered callbacks once the terminal outcome has been delivered.
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 (see "Meaning per scope" for the websocket and sse equivalents) -- false while a response is still streaming or has not started. It is a boolean: true only after a clean end, false while the scope is active and after an abnormal end. The synchronous counterpart to on_complete, and read-only to the application.
my $done = $conn->response_complete; # Boolean, synchronous
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).
my $future = $conn->disconnect_future; # Future, resolves on abnormal disconnect
my $reason = await $future;
Each returned Future MUST be cancellation-isolated: cancelling it -- directly, or implicitly as the losing component of a combinator such as Future->wait_any -- MUST NOT affect the server's disconnect processing, the delivery of other notifications, or a Future returned by another call to this method. Successive calls MAY return distinct Futures; obtain a fresh one for each race, since a Future that lost one race cannot win a later one. This makes explicit what the definition above already promises: every returned Future "resolves, with the reason, on abnormal disconnect" -- a Future one consumer can cancel out from under the others cannot keep that promise, and step 3 of "State Transition Order" cannot be performed on it.
on_end($callback) - Registers a callback invoked once when the scope reaches its terminal outcome, clean or abnormal. Exactly one terminal outcome occurs for a scope, and on_end observes whichever it was, so a consumer that must act on either ending -- subscription cleanup, a framework's close hook -- registers it instead of both on_complete and on_disconnect. The callback receives ($reason, $detail): $reason is the disconnect_reason token on an abnormal end and undef on a clean end; the full terminal record (including a WebSocket's close_code/close_reason) is read from the object, which is immutable once terminal. Registration, ordering, exception isolation, and reference release match on_disconnect and on_complete.
$conn->on_end(sub ($reason, $detail) {
release_subscription(); # ran on whichever ending occurred
});
end_future() - Returns a Future that resolves when the scope ends, for either outcome, with the reason token on an abnormal end and undef on a clean end. Termination is the event being observed, not a failure of the observer: the Future resolves, it never fails. It is the all-outcome sibling of disconnect_future (which resolves only on an abnormal end), for a background producer or framework to observe a scope's end while any active receive loop keeps using receive(). Each returned Future is cancellation-isolated on the same terms as disconnect_future.
my $reason = await $conn->end_future; # token, or undef on a clean end
abort($detail) - Requests that the server end this scope's transport now: close the connection on HTTP/1.1, reset only this stream (RST_STREAM, e.g. CANCEL) on HTTP/2. abort returns immediately with no meaningful value; it is not awaitable and MUST NOT wait for an in-flight write to drain, since that write may be blocked by the peer. The server MUST then follow "State Transition Order" exactly as for a transport-detected abnormal end: mark the scope abnormal with the standard token app_abort and record $detail, if given, as disconnect_detail; resolve disconnect_future; invoke on_disconnect callbacks; then settle pending receives with the scope's disconnect event and pending sends successfully; and treat later sends as post-close no-ops. on_complete does not fire. The server MUST NOT log an incomplete-response or no-response error for an aborted scope. abort after either terminal outcome is a no-op that preserves that outcome. It is valid on every scope, before or after websocket.accept or sse.start. Before accept or start it ends the handshake with no HTTP response: the client observes a failed connection, not a status, and a reconnecting client may retry. An application that wants the client to know why refuses instead (see "Refusing the handshake" and "Refusing the stream").
$conn->abort("quota exceeded after $sent bytes");
An exception raised by a registered on_disconnect, on_complete, or pagi.transport callback MUST NOT prevent the remaining callbacks from running and MUST NOT disturb the server's own processing; servers SHOULD log such exceptions.
What "connection" means here
The object is named for the connection, but it is scoped to a single scope: each HTTP request, WebSocket session, or SSE stream gets its own pagi.connection, and the response_started / response_complete flags describe that scope's response, never the transport as a whole. This matters the moment a transport carries more than one scope:
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 on every http, websocket, and sse scope; see "Meaning per scope" for what a start and a clean end are on each.
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 or stream idle too long -- before a request arrived, or mid-stream on a quiet WebSocket or SSE session
keepalive_timeout-
Keep-alive connection idled out between requests, or a WebSocket keepalive ping received no pong within its timeout (see
websocket.keepalive) close_timeout-
An accepted WebSocket's closing handshake did not complete within the server's bound: a Close frame was sent and the peer's Close never arrived, so the server closed the transport rather than wait indefinitely (RFC 6455 section 7.1.1). The bound and its default are the server's to choose and document.
close_incomplete-
An accepted WebSocket whose closing handshake completed -- or whose peer sent its Close -- but whose transport (its stream, on HTTP/2) did not finish closing within the server's bound, so the server force-closed rather than wait indefinitely. Distinct from
close_timeout, where a Close was sent and the peer's Close never arrived: here the peer did answer, but the transport-level close did not complete. Because the peer sent a valid Close,close_codeandclose_reasonreport the peer's values (per "Connection Object Interface" -- the peer's code, or1005when its Close carried none), not1006; the outcome is carried indisconnect_reasonalone, keeping protocol data separate from the lifetime reason. The bound and its default are the server's to choose and document. 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
app_abort-
The application ended the connection deliberately by calling
aborton the connection state object.disconnect_detailcarries the application's string, if any.
Servers MAY define additional reasons prefixed with x- (e.g., x-rate-limited).
Server Requirements
MUST provide
pagi.connectionon everyhttp,websocket, andssescopeMUST implement
is_connected(),disconnect_reason(),disconnect_detail(),close_code(),close_reason(),on_disconnect(),on_complete(),on_end(),disconnect_future(),end_future(),response_started(),response_complete(), andabort()methodsMUST fire
on_disconnectcallbacks only on abnormal disconnect, andon_completecallbacks only on successful completion -- never both for the same request; MUST fireon_end(and resolveend_future) exactly once, for whichever of the two outcomes occurred; MUST deliver all three callback families on the event loop, never synchronously within an application's$sendor$receive, with every terminal fact (close_code/close_reasonincluded) populated before any of them is invokedMUST 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 reasonInvoke
on_disconnectcallbacks in registration order, each with($reason, $detail)Deliver the scope's disconnect event: append
http.disconnect,websocket.disconnect, orsse.disconnectas the scope type requires to the receive queue, and if a receive Future is already pending, resolve it with that event. (A completed refusal delivers none; see "Meaning per scope".)Settle any send Future still pending for this scope, per the Send Completion Contract in PAGI::Spec (it resolves successfully)
Steps 5 and 6 MAY be performed in either order, but both MUST happen after step 4.
An application coroutine resumed by step 5 or step 6 therefore always observes the transition already complete: is_connected() returns false, disconnect_reason() returns the reason, and any registered on_disconnect callbacks have been invoked or scheduled. Awaiting $send or $receive and then consulting the connection state is race-free by construction.
Alongside this order, end_future() resolves at step 3 and on_end callbacks are invoked at step 4, both carrying the same reason token as disconnect_future and on_disconnect. on_end fires exactly once for the scope, here on the abnormal path and per the clean-end order below on the clean path.
This order applies to every scope type, and to an end initiated by abort exactly as to one detected on the transport. Delivery of every terminal callback -- on_disconnect, on_complete, and on_end -- and the resolution of disconnect_future and end_future is never synchronous inside an application's own call into $send or $receive; a callback registered after the terminal transition is the one exception, and it runs immediately under the registering caller's control.
When a request completes successfully, servers MUST update state in this order:
Set
is_connected()to return false andresponse_complete()to return true.Populate the scope's terminal facts before any observer is notified -- for a
websocketscope,close_code()andclose_reason()from the peer's Close, if any -- so the first callback sees a complete, final record.Leave
disconnect_reason()asundef.on_disconnectcallbacks anddisconnect_future()MUST NOT fire on this path.Invoke
on_completecallbacks in registration order, invokeon_endcallbacks with(undef, undef), and resolveend_future()withundef.
Steps 1 and 2 update the synchronous facts immediately at the terminal event; step 4's callback delivery follows the deferral rule above and MAY run after the handler returns.
Exceptions after the terminal event. Delivery defines completion: an exception raised by the application after its response reached the terminal event (the final body, a file/fh body, or the final trailers) does not un-complete the request. The server MUST still treat the request as completed — on_complete fires, disconnect_reason() stays undef — and SHOULD log the exception at error level as an operational event. The server MAY close the connection afterwards rather than reuse it; the delivered response is unaffected either way. (This matches ASGI-ecosystem practice, where a post-completion exception is logged and the already-granted keep-alive stands.)
Meaning per scope
An abnormal end is one rule for every scope: any condition named in "Standard Disconnect Reasons" fires on_disconnect with that token and sets disconnect_reason. A clean end and an abnormal end are mutually exclusive; the first to occur wins; the terminal state never reopens. After either, is_connected returns false. response_complete is a boolean: true only after a clean end, false while the scope is active and after an abnormal end. disconnect_reason is undef after a clean end.
http-
response_started: the server has accepted a validhttp.response.start. Clean end: the server has finished its output of the terminal body, thefile/fhbody, or the final trailers without an abnormal end. websocket-
response_started: the server has accepted a validwebsocket.accept, or a validhttp.response.startrefusing the handshake (see "Refusing the handshake"). Clean end: the server has finished its output of a refusal, or the accepted socket completed a closing handshake initiated by either side and its transport (its stream, on HTTP/2) closed without an abnormal end. The application's terminal act on an accepted socket is sendingwebsocket.closeor receivingwebsocket.disconnect; returning with neither is an incomplete response (see "Application Left a Response Incomplete"). sse-
response_started: the server has accepted a validsse.start, or a validhttp.response.startrefusing the stream (see "Refusing the stream"). Clean end: the server has finished its output of a refusal, or of a started stream that ended withsse.close.sse.closeis the application's terminal act on a started stream; returning without it is an incomplete response (see "Application Left a Response Incomplete").
response_started reports acceptance of the start event, not that headers reached the wire; it is also set for server-generated responses. A clean end means the server finished its output processing, not that the client received anything. A resolved terminal send is not by itself proof of a clean end: the Send Completion Contract in PAGI::Spec also covers an event discarded after the connection ended.
Receiving a peer Close frame advances the closing handshake; it does not alone establish a clean end. Sending websocket.close likewise initiates the handshake without completing it: a completed handshake requires the peer's Close (or the server's Close sent in response to the peer's) and the stream/transport then closing. A completed handshake is clean regardless of the peer's close code; that code and its reason text are protocol data delivered in websocket.disconnect (RFC 6455 section 7.1.4) and, without consuming the receive queue, in close_code/close_reason. A server-detected protocol violation, timeout, or transport loss before a clean end is abnormal with the applicable standard token. A server MUST bound the wait for a peer that never sends its Close after the server's, and when that bound elapses end the scope abnormally with close_timeout rather than wait indefinitely. close_timeout names the deadline case specifically: a transport or stream loss during that wait is a distinct outcome, ending the scope abnormally with the applicable transport-loss token, and close_timeout is never inferred from missing Close metadata alone. Both report close_code 1006 (RFC 6455 section 7.1.5) only when no valid peer Close was observed; a peer Close that did arrive keeps its own code and reason. Because a completed handshake requires the peer's Close, on_complete does not fire on the application's own websocket.close until the handshake completes: a cooperative peer completes it promptly, a silent peer yields close_timeout.
Agreement with disconnect events. Where a scope delivers a disconnect receive event for a server-reported abnormal end (http.disconnect carries no reason; websocket.disconnect and sse.disconnect carry one), the event's reason and the object's disconnect_reason MUST be the same token. This does not apply to the free-text reason from a peer's Close frame, which is not a server lifetime reason and is never overwritten. A normally completed refusal is a clean end and delivers no disconnect event of its own; a receive after it reports the end, as the next paragraph says.
Receiving after the scope's end. A $receive call made after the scope has ended reports the end; it never invents data and never invents a failure. After an abnormal end, pending and later receives resolve with the scope's disconnect event (see "Cancellation and Disconnects" in PAGI::Spec). After a clean end the application itself produced with nothing left to deliver -- a completed refusal of a WebSocket handshake or an SSE stream, or sse.close -- they resolve with the scope's end as well: sse.disconnect with no reason on an sse scope, and http.disconnect after a WebSocket refusal, which was an HTTP exchange on that scope. (An accepted WebSocket's own websocket.close starts a closing handshake, and websocket.disconnect arrives when it completes.) A server MAY bound how many receives it answers this way on one scope, as a guard against a receive loop that never checks for the end, failing the receive once its documented bound is exceeded; the bound and its default are the server's to choose and document. Frameworks observe on_complete.
Observing the end of a scope. The callbacks are how anything that is not the scope's own receive loop learns how the scope ended: audit, metrics, subscription cleanup, a framework's close hook. They fire on every terminal outcome, including a completed refusal and an abort. A receive loop on an accepted WebSocket needs no extra handling, because websocket.disconnect arrives on every ending; an affirmative is_connected check in the loop condition is a readability preference this specification neither requires nor discourages. A consumer that must act on either outcome registers both callback families; disconnect_future resolves only on an abnormal end. A clean end leaves no watcher or timer behind.
No response and incomplete. Returning from a websocket scope without websocket.accept or a refusal, or from an sse scope without sse.start or a refusal, is governed by "Application Produced No Response", including its client-gone carve-out. Returning after a valid start without the scope's terminal event -- the terminal body or trailers after http.response.start on any scope, refusals included; sse.close after sse.start; websocket.close sent or websocket.disconnect received after websocket.accept -- is an incomplete response under "Application Left a Response Incomplete". Every scope has exactly one way to end cleanly from the application's side, and it is an explicit event.
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 $event_future = wait_for_event();
# Race: wait for event OR disconnect
await Future->wait_any($conn->disconnect_future, $event_future);
return unless $conn->is_connected;
my $event = await $event_future;
await send_response($send, $event);
}
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: an abnormal disconnect of that stream's scope; the server performs the standard disconnect transition for it (see "State Transition Order").
- -
-
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.5') - -
-
$scope->{pagi}{spec_version}(String) -- The version of this HTTP sub-specification (e.g.'0.6'). Optional; if omitted, assume'0.1'. - -
-
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". - -
-
pagi.transport(Object, optional) -- Outbound flow-control introspection handle (buffered_amount, watermarks, drain callbacks), when the server provides one. See "Transport Flow Control". - -
-
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 Future returned by $send 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 - at most one may be provided per event. If none is provided, the event is treated as if body => '' had been given (per the body default above); an event with no payload keys and more => 0 is therefore a valid way to end a response. 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.
Payload kinds do not mix within a response. The exclusivity above holds across the response as well as within an event: a response's body is either a sequence of inline body events or a single file/fh event, never both. An application MUST NOT send a file or fh event once inline body bytes have been delivered on that response, and the server MUST fail such a $send->() Future. Because a file/fh event is implicitly complete, the only sequence this rules out is one or more more => 1 chunks followed by a file/fh event.
A file/fh send that fails delivers no bytes and leaves the response sequence state untouched (see Error Handling below), so an application may still recover by sending an inline body instead.
The restriction exists for intermediaries. A middleware that transforms body bytes must commit to an encoding before it can know whether a delegated payload will follow, and it cannot apply that encoding to bytes the server streams on the application's behalf; permitting the mix would oblige every such middleware either to read the delegated file itself, defeating the delegation, or to emit a response whose declared encoding does not describe its bytes. ASGI's Path Send extension draws the same line ("cannot be mixed with http.response.body"); PSGI and Rack make the combination unrepresentable. An application that wants to prepend generated bytes to a file's contents sends the whole body inline, or arranges for the file to contain them.
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, or a read or seek on it fails, the$send->()Future MUST fail - -
-
Delivery failure is fail-don't-mutate. When a
file/fhsend fails, the server MUST leave the response sequence state as it was before the event -- the same rule as header byte safety -- so the application may recover, for example by sending an alternative body. Once body bytes from the failed event have already reached the wire, recovery is no longer possible and "Application Left a Response Incomplete" governs the outcome. - -
-
The failed Future is the application's notification of the failure; an application that does not await its send Futures forfeits per-send failure notification, and delivery problems then surface only through the connection-state callbacks and disconnect events.
- -
-
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.
Framing requirement (HTTP/1.1). Over HTTP/1.1, trailers exist only in chunked transfer coding (RFC 9112): when the application supplied Content-Length — so the server framed the response without chunking — a trailer section cannot be transmitted. The server MUST reject a http.response.trailers event on such a response by failing the $send Future, and MUST NOT silently discard it or treat the response as complete with trailers unsent — the same fail-don't-mutate rule the spec applies to header byte safety. HTTP/2 carries trailers as trailing HEADERS frames and has no such framing restriction. (On a HEAD response the trailers event is accepted and discarded per "HEAD Requests", which takes precedence.)
Keys:
- -
-
type--"http.response.trailers" - -
-
headers(ArrayRef[ArrayRef[Bytes]], default[]) -- Trailer headers encoded the same way as response headers (lower-case names, byte values)
HEAD Requests
A response to a HEAD request carries no content on the wire, on every HTTP version (RFC 9110 S9.3.2). The server's send path is the single point that guards the wire, so suppression is the server's job: the application handles a HEAD request exactly as it would the equivalent GET -- same http.response.start, same headers, same body events -- and the server MUST NOT transmit the body bytes. Suppression is transparent to the application: each $send Future resolves as usual, the terminal body event (more => 0) completes the request, and on_complete / response_complete behave exactly as for any other method.
Server requirements:
- -
-
The server MUST NOT transmit response body content for a
HEADrequest, regardless of HTTP version. - -
-
An application-supplied
Content-Lengthheader MUST be passed through untouched: per RFC 9110 it advertises the length theGETresponse would have had. The server MUST NOT applyTransfer-Encoding: chunkedframing to aHEADresponse; when noContent-Lengthwas supplied, the response simply ends after the header section, with no body-framing headers at all. - -
-
For
fileandfhbody events the server skips streaming entirely. It is NOT required to open orstatthe file to synthesize aContent-Length; an application that wants the length advertised sets the header itself. - -
-
A
http.response.trailersevent on aHEADresponse is accepted and discarded.
Application guidance:
- -
-
HEADrequests are delivered to the application like any other method -- the server does not answer them itself and does not rewrite them toGET. An application whose routing only matchesGETwill return its not-found response toHEADclients. - -
-
An application MAY short-circuit on
$scope->{method} eq 'HEAD'and skip generating an expensive body: sendhttp.response.startwith the same headers theGETwould carry, then a single empty terminal body event. This is purely an optimization -- an unmodifiedGETcode path is equally correct, the server discards the bytes. - -
-
Set
Content-Lengthexplicitly when the size matters toHEADclients. A response that relies on the server's chunked framing gives aGETclient the size implicitly through the body, but aHEADclient receives no length information at all. - -
-
Middleware that measures response size by observing body events will over-report for
HEAD: the bytes it counts are discarded by the server. Wire-accurate sizes come from the server's own logging.
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.
Application Left a Response Incomplete
A response is incomplete when the application's Future becomes ready -- resolves or fails with an exception -- after a valid start was accepted but before the scope reached its terminal event. The start and the terminal event are per scope:
- -
-
httpscope, and a refusal on any scope: afterhttp.response.start, the terminal event is the finalhttp.response.bodywithmore => 0, afile/fhbody event, or -- whenhttp.response.startpromised trailers withtrailers => 1-- thehttp.response.trailersevent. - -
-
ssescope: aftersse.start, the terminal event issse.close. - -
-
websocketscope: afterwebsocket.accept, the terminal event iswebsocket.closesent by the application orwebsocket.disconnectreceived by it. A closing handshake the peer initiated is completed by the server, so receiving the disconnect event is the application's whole obligation in that case.
What the terminal event asserts. The terminal event is the application's claim that the response is complete in the sense of RFC 9110 Section 6.1 -- that all the octets its framing indicates are available. The server renders that claim on the wire as the zero-length chunk of the chunked coding (RFC 9112 Section 7.1) or as END_STREAM (RFC 9113 Section 8.1). Everything below follows from that: the event is a statement about the representation, not a formality that ends a code path.
The same holds on the other scopes. An SSE stream ends on the wire with the same chunked terminator or END_STREAM, so a stream the application abandons has the same unfinished framing as an HTTP body. A WebSocket session ends with the closing handshake of RFC 6455 section 7.1, and a socket the application walks away from has no Close frame to tell the peer why it ended.
The server MUST treat this as an abnormal end, never a clean one. The governing principle: a truncated response must be observable as truncated by the client, and a connection with unfinished framing must never carry another response. Concretely, the server:
- -
-
MUST NOT synthesize the terminal framing the application failed to send. Writing the chunked terminator or signaling
END_STREAMon the application's behalf would present a truncated response to the client as complete -- a lie that downstream caches would then store (RFC 9111 Section 3.3 permits storing an incomplete response only when it is recorded as incomplete, and forbids answering a request with one). - -
-
MUST terminate the transport so the client observes truncation. Over HTTP/1.1, for an
httpscope, a refusal, or anssestream, this means closing the connection without the chunk terminator (or, for aContent-Length-framed response, before the declared length was delivered); the connection MUST NOT be kept alive or serve a pipelined request. Over HTTP/2 the server resets the stream (RST_STREAM, e.g.INTERNAL_ERROR); other streams on the connection are unaffected. On an accepted WebSocket the server sends a Close frame with code1011("internal error"), waits no longer than its ordinary close timeout for the peer's Close, and then closes the transport (on HTTP/2, an orderly stream close per RFC 8441 section 5). The1011frame is the WebSocket analog ofRST_STREAMINTERNAL_ERROR: an error signal, not the completion the application failed to assert. - -
-
MUST report the abnormal end on the
pagi.connectionobject: fireon_disconnectwith reasonserver_error.on_completeMUST NOT fire -- the response was not fully delivered (see "Connection State"). The scope's disconnect event carries the same token; on an accepted WebSocket it iswebsocket.disconnectwith code1011and reasonserver_error. - -
-
SHOULD log the event at error level.
As with "Application Produced No Response", if the client had already disconnected before the application's Future resolved, this is not an application error: the request already ended abnormally with its own disconnect reason, and the server MUST NOT log an incomplete-response error.
The application side of the same rule. An application that stops early because its client disconnected MUST NOT send the terminal event to "tidy up": it did not produce the whole representation, so it may not assert that it did. Its $send would be a successful no-op in that state (see "Disconnected Client - sending after disconnect"), but the assertion would still be false to every intermediary in the $send chain, and an application that aborts for some other reason -- a failed backend, say -- may not yet be inside the post-disconnect no-op guarantee at all, in which case a live client receives a terminal marker over a truncated body. The absence of the terminal event is the signal that the response stopped short; that is what makes it distinguishable from one that completed.
Every producer is bound, not only the server. The prohibition on synthesizing terminal framing applies to anything that emits response events -- middleware that buffers a body and re-emits it, a framework component that adapts one response into another, or any other intermediary wrapping $send -- not only to the server writing bytes. An intermediary that observes an incomplete event stream MUST NOT complete it on the application's behalf, and MUST NOT compute or attach a representation validator (an ETag or digest), a Content-Length, or any other completeness-dependent metadata from the bytes it happened to observe. Intermediaries that need to distinguish an abnormal end from an application fault read the request's disconnect_reason (see "Connection State"); a defined reason means the request ended abnormally, while undef distinguishes a clean completion, which is_connected alone does not.
Which signal answers which question. The two signals above are not alternatives, and an intermediary needs both. The absence of the terminal event answers whether the response completed; disconnect_reason answers why the request ended. An intermediary decides what to forward -- and whether it may attach completeness-dependent metadata -- from the event stream alone: it may synthesize a terminal event, an ETag, or a Content-Length only if it observed a terminal event itself. It consults disconnect_reason only to decide whether to raise or log, never to decide whether to forward what the application already produced.
Using the disconnect signal to make the forwarding decision is a fault in both directions. A buffering intermediary that discards its buffered http.response.start because the client disconnected destroys a response the application really did start: every outer observer then sees a request that never started a response, which is a different state with different consequences -- servers report it differently, and a client that read no bytes at all may retry an idempotent request under RFC 9110 Section 9.2.2. Conversely, an intermediary that keys on the disconnect signal alone still fabricates whenever an application stops early while its client is still connected, which the disconnect signal cannot see. Forward the events you withheld; withhold only the metadata you did not earn.
An intermediary must not invalidate what the head promised. A response head can commit the application to things a body-transforming intermediary will break unless it looks first. Before transforming a body, or attaching metadata derived from one, an intermediary MUST examine the response head for such commitments and decline to act where it cannot honour them. Two that exist today, and the list is not closed:
- -
-
trailers => 1commits the server to chunked framing over HTTP/1.1, because that is the only HTTP/1.1 framing that can carry a trailer section (see "Response Trailers -sendevent"). An intermediary that adds aContent-Lengthto such a response makes the server reject the application's ownhttp.response.trailersevent — the application did nothing wrong, and its framing choice was overruled downstream. - -
-
A
206withContent-Rangemeans the body is a range, not the representation. An intermediary that computes anETagor digest from it labels the resource with a validator that identifies only the fragment, so a cache may later serve the fragment as the whole. The same applies to anyContent-Encodingthe intermediary would add: it describes the transfer of a range whose bounds were computed against the unencoded representation.
An intermediary that re-emits an event it buffered MUST also preserve the event's other keys rather than reconstructing it from the fields it happens to care about. Rebuilding an http.response.start from status and headers alone silently drops trailers => 1, which is the first failure above reached by a different route.
Re-emitting a buffered body event. An intermediary that buffers body events and later re-emits them MUST preserve each event's more value. Because more defaults to 0 (see "Response Body - send event"), omitting it on a re-emitted non-terminal event asserts a completeness the application never claimed -- the same prohibition as above, reached by omission rather than intent.
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. - -
-
Connection-level and framing headers belong to the server. An application MAY include
Connection,Transfer-Encoding,Keep-Alive,Upgrade,Proxy-Connection, orTEamong its response headers, but the server controls connection disposition and message framing and must not let an application-supplied value contradict them: it strips or ignores application-supplied values in this family (the full set over HTTP/2, per RFC 9113;Transfer-EncodingandConnectionover HTTP/1.1), supplies its own, and SHOULD log when it strips. PAGI defines no exceptions of its own to this rule -- where the HTTP RFCs themselves carve one out, the server implements the RFC rather than the blanket strip. The two the RFCs define today: RFC 9113 permitstewhen its value is thetrailerstoken (a server forwarding it normalizes to the canonical token, since the RFC forbids surrounding whitespace in field values), and RFC 9110 obliges anyUpgradesender to pair it withConnection: upgrade, so over HTTP/1.1 a server must supply that token itself when an application response carriesUpgrade(e.g. a426 Upgrade Required) -- the application never sendsConnection: upgradedirectly. Applications express content encoding throughContent-Encoding, and connection lifetime through completing or not completing responses, never through these headers. (This continues the WSGI and ASGI rule that hop-by-hop headers are the gateway's to manage.)
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. - -
-
503 Service Unavailable-- the server is at an admission-control limit (for example, a configured maximum connection count) and declines the connection before invoking any application; aRetry-Afterheader SHOULD accompany it (RFC 9110 Section 15.6.4).
The exact reason phrases and body text are server-defined; applications MUST NOT depend on them.
Sends Are Sequential
An application must not issue a new $send on a connection before the previous send's Future has resolved; the effect of overlapping sends is unspecified. Applications with concurrent producers serialize them (for example through a queue) before sending. (This matches the one-outstanding- write rule of comparable streaming interfaces; a normally structured application that awaits each send satisfies it automatically.)
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.
A send already in flight when the disconnect is detected is not an error either: it settles by resolving successfully, per the Send Completion Contract in PAGI::Spec. The application resumes from its await, observes the disconnect through pagi.connection (already updated -- see "State Transition Order"), and stops or cleans up as it chooses.
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:
WebSocket
WebSocket servers handle fragmentation and PING/PONG messages. Servers MUST wait for a reply to websocket.connect before completing the handshake.
WebSocket Connection Scope
- -
-
type(String) --"websocket" - -
-
$scope->{pagi}{version}(String) -- The PAGI core spec version (e.g.'0.5') - -
-
$scope->{pagi}{spec_version}(String) -- The version of this WebSocket sub-specification (e.g.'0.6'). Optional; if omitted, assume'0.1'. - -
-
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) - -
-
pagi.connection(Object) -- Per-session connection-state object for disconnect detection and response progress (response_started/response_complete). See "Connection State". - -
-
pagi.transport(Object, optional) -- Outbound flow-control introspection handle, when the server provides one. See "Transport Flow Control". - -
-
max_frame_size(Int, optional) -- The server's inbound WebSocket frame size ceiling in bytes, when the server enforces one. Frames from the peer exceeding it terminate the connection, so an application that directs its client's framing (for example, a chunked-upload protocol advertising a chunk size at handshake) SHOULD size against this value rather than hard-coding a guess. - -
-
max_receive_queue(Int, optional) -- The server's inbound event-queue ceiling for this connection, when the server enforces one. Overflow closes the connection with1008/queue_overflow, so an application doing expensive per-message work can use this value to apply its own flow control (acknowledgement throttling, pause messages) before the server sheds the session. - -
-
extensions(HashRef, default{}) -- Optional server capabilities advertised for this connection (e.g.tls). 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)
The alternative to accepting is to refuse the handshake with an ordinary HTTP response; see "Refusing the handshake".
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 scope ends -- by a closing handshake from either side, a transport drop, a server-detected error, or an abort -- before or after websocket.accept. A normally completed refusal is the one ending that delivers none of its own; a receive after it resolves with http.disconnect (see "Meaning per scope").
- -
-
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.
This event is delivered whenever the websocket scope ends abnormally, before as well as after websocket.accept: a client that drops during the handshake, or an abort before accept, produces it with code 1006 and the standard reason token for the condition. A normally completed refusal (see "Refusing the handshake") delivers none of its own. An abort on an accepted socket sends no Close frame -- the server ends the transport -- and delivers this event with code 1006 and reason app_abort.
Once this event has been delivered the scope is over, and a further receive() resolves with the same websocket.disconnect again. The event reports the scope's terminal state rather than delivering a message, so it is never consumed by one reader and lost to another.
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, before or after accept, or an
abort.codeis1006("abnormal closure") andreasonis the token for the condition:client_closed,keepalive_timeout,write_timeout,write_error,server_shutdown, orapp_abort.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.Application left the session incomplete -- the application returned from an accepted socket without sending
websocket.closeor receiving this event.codeis1011("internal error"), the code the server sent, andreasonisserver_error(see "Application Left a Response Incomplete").
The pairings above are normative wherever the server names the ending: a server-reported abnormal end carries the standard token as reason and the code shown (see "Meaning per scope", Agreement with disconnect events); an incomplete session carries 1011 and server_error; an abort carries 1006 and app_abort. When the peer sent the Close frame, code and reason are the peer's own and are never replaced by a token. Applications SHOULD branch on reason where it is a token and treat peer-supplied text as data.
Disconnected Client - sending after disconnect
How a $send behaves after closure depends on who closed. After the transport closed -- the peer disconnected, a timeout fired, the server is shutting down -- a $send is a no-op: it neither delivers data nor raises, because the application may race the disconnect through no fault of its own; applications detect a closed WebSocket through the websocket.disconnect event delivered to $receive. After the application itself sent websocket.close, further sends are a programming error and the server MUST fail the $send Future -- the application cannot race itself.
A send already in flight when the transport closes settles per the Send Completion Contract in PAGI::Spec: it resolves successfully; the application learns of the closure from the websocket.disconnect event, not from the send.
Close - send event
websocket.close is valid only after websocket.accept. Sent before accept it is an out-of-sequence event and the server MUST fail the $send Future without mutating state; refusing a handshake is done with an HTTP response (see "Refusing the handshake").
After websocket.accept, websocket.close is the application's terminal event: send it, or receive websocket.disconnect, before returning. Returning from an accepted socket with neither is an incomplete response (see "Application Left a Response Incomplete"): the server closes with code 1011, not 1000.
After acceptance, an otherwise valid first application websocket.close racing peer-initiated closure MUST complete successfully, including when closure is already in progress.
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.
Refusing the handshake
Until the application sends websocket.accept, a websocket scope is an HTTP exchange (RFC 6455 section 4.2.2), and the application MAY refuse the handshake by answering it with an ordinary HTTP response using the HTTP response events: http.response.start, http.response.body, and http.response.trailers where the start declared them, with exactly the semantics those events have on an http scope (see "HTTP"). This is the one place where a scope carries events from another protocol's namespace, and it exists because the wire really is HTTP until the 101. On the wire the refusal's status, headers, and body are identical to the same response on an http scope, apart from the connection-lifecycle headers the server owns (see below). Every server offering websocket scopes MUST support it; there is nothing to advertise or detect.
await $send->({ type => 'http.response.start', status => 401,
headers => [['www-authenticate', 'Bearer realm="ws"'],
['content-type', 'application/problem+json']] });
await $send->({ type => 'http.response.body', body => '{"title":"sign in"}' });
Body semantics are inherited, not restated. A first body event with more => 0 is a complete body the server may frame with Content-Length; more => 1 streams under the transport's ordinary body, flow-control, and framing rules; file, fh, and trailers are allowed because they are allowed on HTTP responses. Nothing is buffered specially. Prefer a complete body for a refusal: once the first body event is accepted the status and headers are committed; a refusal abandoned after its start without the terminal body is an incomplete response under "Application Left a Response Incomplete", with that section's client-gone carve-out; and, as with any streamed body, a client that disconnects mid-stream may have received part of it.
websocket.accept and the refusal's http.response.start are the only handshake choices and are mutually exclusive.
A refusal's status MUST be 300 or above. A 1xx or 2xx status is not a refusal: 101 is the HTTP/1.1 acceptance, and on HTTP/2 any 2xx answer to the CONNECT request opens the tunnel (RFC 8441 section 5), so an http.response.start carrying such a status on a websocket scope is invalid and the server MUST fail the $send Future without transmitting anything or mutating state, on either transport.
After a refusal has started, only HTTP response events are valid until its terminal event; after websocket.accept, HTTP response events MUST fail the $send Future without transmitting anything, the same fail-don't-mutate rule as every other out-of-sequence send. A normally completed refusal is a clean end (see "Meaning per scope"): the WebSocket was never established, so no websocket.disconnect is delivered. On HTTP/1.1 the server closes the connection after the refusal and the response carries Connection: close, so a pooling client does not reuse the socket; on HTTP/2 the refusal ends the stream and the connection is unaffected.
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 request carries the SSE client signal: parsing the
Acceptheader value (values from repeatedAcceptheaders are combined) as a comma-separated list of media ranges per RFC 9110 S12.5.1, the exact rangetext/event-streamappears with an effective quality value greater than zero. A range withq=0is an explicit refusal and does not signal SSE. Media-type parameters other thanqare ignored for this test, as is case (media types are case-insensitive). - -
-
Wildcard ranges never satisfy the test: neither
*/*nortext/*signals SSE. Browsers send*/*on ordinary navigation; a rule that counted wildcards would classify every plain page load as an SSE request. - -
-
The request has not been upgraded to WebSocket.
Otherwise the connection uses a normal http scope.
This is a boolean client-signal check, not content negotiation. Real SSE clients are unambiguous -- WHATWG requires EventSource to send exactly Accept: text/event-stream, and fetch-event-source does the same -- so the server only asks "did the client ask for an event stream?", never "which representation does the client prefer?". Preference ranking among acceptable types (a client that accepts both text/event-stream and text/html, say) is the application's business, and fuller RFC 9110 content negotiation MAY be layered on as middleware. When the boolean signal classifies a request as sse that the application would rather answer as plain HTTP, an ordinary HTTP response (see "Refusing the stream") is the correction path.
Middleware note: because http and sse are sibling scope types for what is on the wire an ordinary HTTP request, middleware that filters on $scope->{type} eq 'http' silently skips SSE requests to the same URL. Middleware guarding HTTP endpoints (authentication, rate limiting, logging) SHOULD match both types.
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, pagi.connection, pagi.transport where provided, and extensions) but set type => 'sse'. pagi.connection is the per-stream connection-state object for disconnect detection and response progress (response_started / response_complete); see "Connection State". 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.
Refusing the stream
An sse request is an ordinary HTTP request until the application sends sse.start. Instead of starting an event stream, the application MAY refuse by answering with an ordinary HTTP response using the HTTP response events http.response.start, http.response.body, and http.response.trailers, with exactly the semantics those events have on an http scope (see "HTTP"): a 404 for an unknown endpoint, a 401/403 for an unauthenticated request, a 204 to tell an EventSource client to stop reconnecting, a redirect. This carries events from another protocol's namespace for the same reason as "Refusing the handshake", and on the wire the refusal's status, headers, and body are identical to the same response on an http scope, apart from the connection-lifecycle headers the server owns (see below). Every server offering sse scopes MUST support it; there is nothing to advertise or detect.
Unlike a WebSocket refusal, an SSE refusal may carry any status, 200 included. An sse scope is distinguished from a stream by the event that answers it, http.response.start rather than sse.start, not by the status, and an ordinary 200 page is the documented answer for a request the application does not treat as a stream (see "Known issue" in PAGI::Upgrading).
Body semantics, including more => 1 streaming, file, fh, and trailers, are inherited from "HTTP" and not restated here; the note under "Refusing the handshake" about committed status, abandoned refusals, and partial delivery applies equally. sse.start and the refusal's http.response.start are mutually exclusive: after sse.start, HTTP response events MUST fail; after a refusal has started, the stream events (sse.send, sse.comment, sse.keepalive, sse.close) MUST fail. A normally completed refusal is a clean end (see "Meaning per scope"): no event stream was started and no sse.disconnect is delivered of the server's own accord; a receive after the refusal resolves with sse.disconnect carrying no reason (see "Meaning per scope"). As with a WebSocket refusal, on HTTP/1.1 the server closes the connection after the refusal and the response carries Connection: close; on HTTP/2 the refusal ends the stream and the connection is unaffected.
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 sends sse.close (see "Close SSE - send event"); the server then flushes buffered events, writes the end-of-stream marker, and closes the stream. Returning after sse.start without sse.close is an incomplete response (see "Application Left a Response Incomplete").
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',
});
Close SSE - send event
sse.close ends the SSE stream. It is the SSE analog of http.response.body with more => 0, and like that event it is the stream's terminal event: the server flushes any buffered events, writes the end-of-stream marker, and closes the stream. It takes effect immediately on receipt, before the application returns, which lets a helper deep in the call stack end the stream without unwinding to the top; a subsequent return is a no-op. An application that returns after sse.start without having sent sse.close has left the response incomplete (see "Application Left a Response Incomplete"): the server MUST NOT write the end-of-stream marker on its behalf, terminates the stream so the client observes truncation, reports server_error on the connection object, and logs the event.
- -
-
type--"sse.close" - -
-
reason(String, optional) -- A server-side reason for logging, metrics, or tracing (for example"job_complete"). The SSE wire protocol has no close frame, soreasonis never transmitted to the client; it is server-side metadata only, surfaced through the same channels as thesse.disconnectreason. To convey anything to the client -- including "stop reconnecting" -- send an ordinarysse.sendevent before closing and use an HTTP204(orretry:) on the client's next connection.
After sse.close, any further sse.send, sse.comment, or sse.keepalive on the same connection MUST raise -- the stream is closed. A $receive call, pending or later, resolves with sse.disconnect carrying no reason: the scope ended cleanly (see "Meaning per scope"). sse.close is idempotent: a second sse.close is ignored. sse.close does not by itself stop the client reconnecting; that is governed by the response to the client's next connection, not by the close of the current stream.
Unlike websocket.close, sse.close carries no code or client-visible reason: WebSocket has an RFC 6455 close frame to carry them, while SSE has no such frame on the wire.
Why return is not a close. An EventSource client reconnects after any end of stream, clean or truncated, so the difference between sse.close and a bare return is invisible in a browser. It is visible where it matters: in the server's log, on the connection object, and to every intermediary that wraps $send. A stream that ends because a helper returned early -- an exception it swallowed, a last that skipped the close -- is reported as what it is, an incomplete response, rather than as a completed one.
SSE Disconnect - receive event
Sent to the application when the client disconnects at any point after the sse scope was dispatched, whether or not sse.start has been sent, and when the server shuts down a started event stream. A normally completed refusal (see "Refusing the stream") delivers no sse.disconnect of its own; a receive after it resolves with one carrying no reason (see "Meaning per scope").
Once this event has been delivered the scope is over, and a further receive() resolves with the same sse.disconnect again. The event reports the scope's terminal state rather than delivering a message, so it is never consumed by one reader and lost to another.
- -
-
type--"sse.disconnect" - -
-
reason(String) -- Standard reason string from "Standard Disconnect Reasons". Absent when the event reports a clean end the application produced (a receive aftersse.closeor after a completed refusal; see "Meaning per scope").
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 presence of pagi.transport affects introspection, not the fundamental send contract. Applications self-pace by awaiting every $send Future whether or not this object is present; the object additionally lets them observe queue depth and make an earlier policy decision to conflate, coalesce, shed load, or disconnect.
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: the Future returned by a $send that would exceed it remains pending until the buffer drains enough for the server to continue processing the event. Awaiting that Future suspends the application coroutine, not the Perl thread. Applications use the 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.6(Draft): Universal connection state (pagi.connectionon every scope,abort,disconnect_detail,app_abort); refusals as ordinary HTTP responses;websocket.closebefore accept fails;sse.disconnecttiming;sse.closeand the WebSocket closing handshake required as terminal events (return without them is an incomplete response; WebSocket closed with1011). Areceive()afterwebsocket.disconnectorsse.disconnectresolves with that event again; areceive()after a completed refusal or aftersse.closeresolves with the scope's end (sse.disconnectwith no reason, orhttp.disconnect); a server MAY bound such re-deliveries. - -
-
0.5(Draft): Response body payload kinds do not mix -- inline events or onefile/fhevent, never both, enforced by the server failing the send. Intermediaries must not invalidate what the response head promised (trailers => 1, a206/Content-Rangerange) and must preserve a re-emitted event's other keys. States which incomplete-response signal governs forwarding and which governs diagnostics. - -
-
0.4(Draft): Settlement of in-flight I/O at disconnect -- pending sends resolve, pending receives resolve with the disconnect event, both ordered after the connection-state transition; loop-deferred delivery of disconnect notifications;RST_STREAMmapped to the standard disconnect transition. - -
-
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, HEAD response semantics, incomplete-response handling, and WebSocket over HTTP/2; tightened SSE detection to an exact media-range match withq > 0; 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.