NAME

PAGI::Spec::Www - PAGI Specification Documentation

NOTICE

This documentation is auto-generated from the PAGI specification markdown files. For the authoritative source, see:

https://github.com/jjn1056/PAGI/tree/main/docs/specs

HTTP, WebSocket & SSE PAGI Message Format

Version: 0.1 (Draft)

The HTTP, WebSocket & SSE PAGI sub-specification defines how HTTP/1.1, HTTP/2, WebSocket, and Server-Side Events (SSE) connections are transported within PAGI.

It is designed to be a superset of the PSGI specification and specifies how to translate between PAGI and PSGI for compatible requests.

Spec Versions

-

0.1: Initial draft, based on ASGI 2.5, including Server-Side Events support.

Common Data Types

Headers Format

Headers are represented as ArrayRef[ArrayRef[Bytes]] - an array of 2-element tuples where each tuple contains [name, value]:

headers => [
    ['content-type', 'text/html; charset=utf-8'],
    ['x-request-id', '12345'],
    ['set-cookie', 'session=abc'],
    ['set-cookie', 'tracking=xyz'],  # duplicate names allowed
]
B<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:

  1. Clearer iteration - Each header is a discrete unit: ```perl # PAGI - straightforward for my $header (@$headers) { my ($name, $value) = @$header; }

    # PSGI - requires index math
    for (my $i = 0; $i < @$headers; $i += 2) {
    my ($name, $value) = @{$headers}[$i, $i+1];
    }
    ```
  2. Explicit duplicates - Duplicate header names (common for Set-Cookie) are visually obvious

  3. Easier manipulation - Filtering, mapping, and transforming headers works naturally with array operations: ```perl # Remove all cookies my @filtered = grep { $_->[0] ne 'set-cookie' } @$headers;

    # Find a header
    my ($ct) = grep { $_->[0] eq 'content-type' } @$headers;
    ```
  4. ASGI compatibility - Matches the Python ASGI specification that PAGI is modeled on

Rules: - Header names MUST be lowercase byte strings - Header values MUST be byte strings (opaque, not decoded) - Each inner arrayref MUST contain exactly 2 elements: [name, value] - Duplicate header names are permitted (required for Set-Cookie, etc.)

HTTP::Headers Compatibility:

PSGI's flat array format is more compatible with HTTP::Headers->flatten(). If you need to interoperate:

# PAGI tuples → flat array (for HTTP::Headers)
my @flat = map { @$_ } @$pagi_headers;
my $hh = HTTP::Headers->new(@flat);

# Flat array → PAGI tuples
my @flat = $http_headers->flatten;
my @pagi = map { [$flat[$_*2], $flat[$_*2+1]] } 0 .. ($#flat/2);

Scope Extension Keys

The scope hashref may contain additional keys beyond those defined in the HTTP, WebSocket, and SSE sections below. This allows middleware and applications to pass data through the request lifecycle.

Reserved prefixes:

-

pagi.* - Reserved for PAGI spec extensions (e.g., pagi.router, pagi.session)

-

Keys without a dot prefix (e.g., type, method, path) - Reserved for core spec use

Custom keys:

Applications and third-party middleware SHOULD use a unique key or prefix to avoid collisions. Two common patterns:

# Pattern 1: Single hashref (recommended for grouped data)
$scope->{myauth} = {
    user  => $user_object,
    roles => ['admin', 'editor'],
};

# Pattern 2: Dotted keys (for flat/independent values)
$scope->{'myapp.request_id'} = $uuid;
$scope->{'myapp.started_at'} = time();
Either approach works - choose based on whether your data is naturally grouped or independent.

Allowed values:

Scope values may be any Perl data type:

-

Scalars, arrayrefs, hashrefs

-

Blessed objects

-

Code references

Note: Objects in scope are NOT serializable. Do not assume scope can be passed between processes, persisted to storage, or serialized to JSON. Scope exists only for the lifetime of a single request within a single process.

Example:

# Authentication middleware - hashref pattern
$scope->{myauth} = {
    user  => $user_object,
    roles => ['admin', 'editor'],
    authenticated_at => time(),
};

# Router middleware (PAGI built-in)
$scope->{'pagi.router'} = {
    params => { id => '42' },
    route  => '/users/:id',
};

# Application accessing middleware data
my $user = $scope->{myauth}{user};
my $id = $scope->{'pagi.router'}{params}{id};
B<Middleware guidelines:>
-

Document the keys your middleware adds to scope

-

Use consistent naming within your namespace

-

Don't modify keys outside your namespace

-

Check for key existence before assuming middleware ran

HTTP

PAGI covers HTTP/1.0, HTTP/1.1, and HTTP/2. Protocol servers assign separate scopes for requests within the same HTTP/2 connection and multiplex responses appropriately.

HTTP/2 Stream Mapping

PAGI servers must translate HTTP/2 frames into PAGI HTTP events per stream. Applications only see structured events, not raw frames:

-

HEADERS: start a new PAGI http scope and emit an initial http.request event with headers and more => 1 if DATA will follow, or more => 0 if END_STREAM was signaled immediately.

-

DATA: emit subsequent http.request events with body => <chunk> and more => 1 or 0 depending on END_STREAM.

-

END_STREAM: if no DATA frames, send an http.request with body => '' and more => 0 to signal end of request.

-

RST_STREAM: trigger a http.disconnect event and cancel any outstanding Futures for that scope.

-

WINDOW_UPDATE / PRIORITY: ignored by default (advanced flow control is optional).

-

PUSH_PROMISE: not supported; servers must reject push promises.

Only HTTP/2 over TLS (h2) is required for the initial implementation; cleartext HTTP/2 (h2c) is optional.

The HTTP version is available in the scope. Pseudo headers (like :authority) from HTTP/2 and HTTP/3 must be removed; if :authority is present, its value must be used to populate or override the host header.

Multiple Set-Cookie headers must be preserved individually, and Cookie headers should be combined or split according to the version-specific rules (as per RFC 7230, RFC 6265, and RFC 9113).

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 cookie header appears in the PAGI headers list

Example:

If the client sends:

Cookie: a=1 Cookie: b=2; c=3
The PAGI scope must include:


headers => [
  [ 'cookie', 'a=1; b=2; c=3' ]
]
The server does not parse the cookie string into key-value pairs — parsing is left to middleware or application code. The server only guarantees RFC-compliant normalization.

HTTP Connection Scope

Each HTTP request has a single-request connection scope. Scope keys:

-

type (String) -- "http"

-

pagi["version"] (String) -- PAGI version

-

pagi["spec_version"] (String) -- PAGI HTTP spec version (default "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 to SCRIPT_NAME in 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) -- State namespace from lifespan

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, default 0) -- 1 if more body data is forthcoming, otherwise 0

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, default 0) -- 1 if trailers will be sent after body via http.response.trailers, otherwise 0

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, default 0) -- Byte offset to start reading from (for range requests)

-

length (Int, optional) -- Number of bytes to send (omit to read until EOF)

-

more (Int, default 0) -- Indicates more body content to follow (1 if true, otherwise 0). Ignored for file and fh responses which are implicitly complete.

The body, file, and fh keys are mutually exclusive - exactly one MUST be provided per event. Applications MUST provide body as encoded bytes. For text content, this typically means UTF-8 encoding before sending. The Content-Length header (if present) MUST reflect byte length, not character length.

Note: When using file or fh, the response is implicitly complete after the file/handle contents are sent. The more key is ignored for these response types - there is no need to specify more => 0.

When file or fh is provided, servers MUST stream the file contents efficiently:

-

Servers SHOULD use sendfile() or similar zero-copy mechanisms when available

-

Servers MAY fall back to chunked read/write if sendfile() is unavailable or inappropriate

-

The offset and length keys enable range request support (e.g., HTTP 206 Partial Content)

-

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 file cannot be opened (not found, permission denied), the $send->() Future MUST fail with an appropriate exception

-

If fh is invalid or closed, the $send->() Future MUST fail immediately

-

Applications SHOULD validate file existence before sending http.response.start to avoid incomplete responses

Validation:

-

offset MUST be a non-negative integer

-

length MUST be a non-negative integer if provided

-

If offset exceeds file size, servers SHOULD send zero bytes

Examples:

# Full file streaming
await $send->({
    type => 'http.response.body',
    file => '/var/www/static/large-video.mp4',
});

# Range request (bytes 1000-1999)
await $send->({
    type => 'http.response.body',
    file => '/var/www/static/document.pdf',
    offset => 1000,
    length => 1000,
});

# Streaming from already-open filehandle
open my $fh, '<:raw', '/tmp/generated-report.csv' or die $!;
await $send->({
    type => 'http.response.body',
    fh => $fh,
});
close $fh;  # Application MUST close after send Future completes

Response Trailers - send event

Only valid when http.response.start was sent with trailers => 1. After trailers are transmitted the server MUST consider the response body complete.

Keys:

-

type -- "http.response.trailers"

-

headers (ArrayRef[ArrayRef[Bytes]], default []) -- Trailer headers encoded the same way as response headers (lower-case names, byte values)

Disconnected Client - send exception

If the client disconnects or cancels the connection, servers MUST send an explicit disconnect event to the application.

Any subsequent $send invocation must fail its returned Future (or throw) with a Perl exception class that indicates the disconnect (e.g., PAGI::Error::Disconnected). Servers MUST NOT expose Python exceptions such as OSError.

Applications MUST gracefully handle disconnect events by: - Immediately halting unnecessary processing upon disconnect - Optionally sending minimal final acknowledgment messages - Executing asynchronous cleanup of resources as necessary.

Disconnect - receive event

Sent to the application if receive is called after a response has been sent or after the HTTP connection has been closed.

Keys:

-

type -- "http.disconnect"

WebSocket

WebSocket servers handle fragmentation and PING/PONG messages. Servers MUST wait for a reply to websocket.connect before completing the handshake. If websocket.close is sent instead of websocket.accept, the server MUST reject the connection with HTTP 403.

WebSocket Connection Scope

-

type (String) -- "websocket"

-

pagi["version"] (String) -- PAGI version

-

pagi["spec_version"] (String) -- PAGI HTTP spec version (default "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)

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

-

type -- "websocket.connect"

Accept - send event

-

type -- "websocket.accept"

-

subprotocol (String, optional)

-

headers (ArrayRef[ArrayRef[Bytes]], optional)

Receive - receive event

-

type -- "websocket.receive"

-

bytes (Bytes, optional)

-

text (String, optional)

Exactly one must be non-null.

The server must UTF-8 decode incoming text frames into Unicode characters for text, and UTF-8 encode outgoing text values to wire format. Binary frames pass through as raw bytes without encoding transformation.

If a text frame contains invalid UTF-8, the server must fail the WebSocket connection with close code 1007 (Invalid frame payload data) per RFC 6455.

Send - send event

-

type -- "websocket.send"

-

bytes (Bytes, optional)

-

text (String, optional)

Exactly one must be non-null.

Disconnect - receive event

-

type -- "websocket.disconnect"

-

code (Int, default 1005)

-

reason (String, default empty)

Disconnected Client - send exception

Raises server-specific subclass of OSError.

Close - send event

-

type -- "websocket.close"

-

code (Int, default 1000)

-

reason (String, default empty)

Server-Side Events (SSE)

SSE connections stream text/event-stream data to clients.

SSE Connection Detection

PAGI servers MUST detect SSE requests and assign a scope of type sse when all of the following are true: - The HTTP method is GET. - The Accept header includes the media type text/event-stream. - The request has not been upgraded to WebSocket. Otherwise the connection uses a normal http scope. Routing based on URL or application logic is not used to infer SSE.

SSE Connection Scope

SSE scopes reuse the HTTP scope structure. Servers MUST populate the same keys (http_version, method, scheme, path, headers, client, server, state, etc.) but set type => 'sse'. Header casing rules follow the HTTP section.

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, default 200)

-

headers (ArrayRef[ArrayRef[Bytes]]) -- Must include content-type => 'text/event-stream' unless already supplied by middleware.

Send SSE - send event

sse.send emits a single SSE dispatch. Fields marked "String" are Unicode strings per the core data-type rules and MUST be UTF-8 encoded by the server before transmission.

-

type -- "sse.send"

-

event (String, optional)

-

data (String) -- Required text payload

-

id (String, optional)

-

retry (Int, optional) -- Milliseconds for the retry: directive

To end the SSE stream the application simply returns after the final sse.send. The server will flush buffered events and close the HTTP connection.

SSE Comment - send event

sse.comment sends an SSE comment line. Comments start with a colon (:) and are used for keepalive pings or protocol-level messages. Comments do NOT trigger the client's onmessage handler in browsers, making them ideal for connection maintenance.

-

type -- "sse.comment"

-

comment (String) -- Comment text. If the text does not start with :, the server MUST prepend one.

Example:

# Keepalive ping (no browser callback triggered)
await $send->({
    type    => 'sse.comment',
    comment => ':keepalive',
});
The server emits the comment followed by two newlines (C<:keepalive\n\n>). This keeps the connection alive through proxies without triggering application-level event handlers on the client.

SSE Disconnect - receive event

Sent to the application if the client disconnects or if the server shuts down the SSE stream after sse.start.

-

type -- "sse.disconnect"

PAGI to PSGI Compatibility

PAGI translates keys explicitly to maintain compatibility with PSGI:

-

REQUEST_METHOD → method

-

SCRIPT_NAME → root_path

-

PATH_INFO → path minus root_path

-

QUERY_STRING → query_string

-

CONTENT_TYPE → extracted from headers

-

CONTENT_LENGTH → extracted from headers

-

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 from http.request events

-

psgi.errors → handled by the server as appropriate (often tied to the loop's logging sink)

-

psgi.streaming, psgi.nonblocking, psgi.multithread, psgi.multiprocess → derived from PAGI server capabilities and advertised via PSGI adapter docs

Response mappings:

-

status and headers map directly to http.response.start

-

Body content from PSGI maps directly to http.response.body messages.

PAGI Encoding Differences

-

path: Decoded UTF-8 string from percent-encoded input. The server first percent-decodes raw_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 check raw_path and decode themselves with Encode::FB_CROAK.

-

headers: Represented as bytes exactly as sent/received

-

query_string: Raw bytes from URL after ?, percent-encoded

-

root_path: Unicode path string matching SCRIPT_NAME

Version History

-

0.1 (Draft): Initial draft based on ASGI 2.5, supporting HTTP, WebSocket, and SSE.

This document has been placed in the public domain.