HTTP, WebSocket & SSE PAGI Message Format

Version: 0.2 (Draft)

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

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

Spec Versions

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:

  1. 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];
    }
    
  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:

    # 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:

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:

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:

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:

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.

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:

Request - receive event

Note: Chunked transfer encoding must be de-chunked by the server. Each http.request represents a de-chunked body fragment.

Keys:

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:

Response Body - send event

Keys:

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:

Error Handling:

Validation:

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:

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:

Disconnect - receive event

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

Keys:

Connection State

The pagi.connection scope key provides a mechanism for applications to detect client disconnection without consuming messages from the receive queue. This addresses a fundamental limitation where checking for disconnect via receive() may inadvertently consume request body data.

The Problem

In PAGI's pull-based receive model, the only way to know if a client has disconnected is to consume the next message:

my $message = await $receive->();
if ($message->{type} eq 'http.disconnect') {
    # Client is gone - but if it wasn't a disconnect, that data is lost!
}

The connection state object solves this with synchronous, non-destructive checks.

Connection Object Interface

Servers MUST provide a connection state object with these methods:

is_connected() - Returns true if the connection is still open.

my $connected = $conn->is_connected;  # Boolean, synchronous

disconnect_reason() - Returns the disconnect reason string, or undef if still connected.

my $reason = $conn->disconnect_reason;  # String or undef

on_disconnect($callback) - Registers a callback to be invoked when disconnect occurs.

$conn->on_disconnect(sub {
    my ($reason) = @_;
    cleanup_resources();
});

disconnect_future() - Returns a Future that resolves when the connection closes.

my $future = $conn->disconnect_future;  # Future or undef
my $reason = await $future;

Servers SHOULD provide this method. Returns undef if not supported.

Standard Disconnect Reasons

Servers MUST use these standard reason strings:

| Reason | Description | |--------|-------------| | client_closed | Client initiated clean close (TCP FIN) | | client_timeout | Client stopped responding (read timeout) | | idle_timeout | Connection idle too long between requests | | write_timeout | Response write timed out | | write_error | Socket write failed (EPIPE, ECONNRESET) | | read_error | Socket read failed | | protocol_error | HTTP parse error, invalid request | | server_shutdown | Server shutting down gracefully | | body_too_large | Request body exceeded limit |

Servers MAY define additional reasons prefixed with x- (e.g., x-rate-limited).

Server Requirements

  1. MUST provide pagi.connection in scope for http type requests
  2. MUST implement is_connected(), disconnect_reason(), and on_disconnect() methods
  3. SHOULD implement disconnect_future() method returning a Future
  4. MUST update connection state as soon as disconnect is detected
  5. MUST use standard reason strings where applicable
  6. MUST NOT transition is_connected() back to true once false (one-way transition)

State Transition Order

When disconnect is detected, servers MUST update state in this order:

  1. Set is_connected() to return false
  2. Set disconnect_reason() to return the reason string
  3. Resolve disconnect_future() with the reason (if provided)
  4. Invoke on_disconnect callbacks in registration order
  5. Send http.disconnect message to receive queue

Applicability

| Scope Type | Connection State | |------------|------------------| | http | MUST provide pagi.connection | | websocket | NOT APPLICABLE (use websocket.disconnect events) | | sse | NOT APPLICABLE (use sse.disconnect events) |

WebSocket and SSE have dedicated disconnect events and typically use handler objects (like PAGI::WebSocket, PAGI::SSE) that already manage connection state.

Example: Basic Connection Check

async sub handler {
    my ($scope, $receive, $send) = @_;
    my $conn = $scope->{'pagi.connection'};

    # Check before expensive work
    return unless $conn->is_connected;

    my $result = await expensive_operation();

    # Check again before responding
    return unless $conn->is_connected;

    await $send->({ type => 'http.response.start', status => 200, headers => [] });
    await $send->({ type => 'http.response.body', body => $result, more => 0 });
}

Example: Cleanup on Disconnect

async sub handler {
    my ($scope, $receive, $send) = @_;
    my $conn = $scope->{'pagi.connection'};

    my $temp_file = create_temp_file();

    # Register cleanup - runs automatically if client disconnects
    $conn->on_disconnect(sub {
        my ($reason) = @_;
        $temp_file->unlink;
        log_info("Client disconnected: $reason");
    });

    my $result = await process_data($temp_file);
    $temp_file->unlink;  # Normal cleanup

    await send_response($send, $result);
}

Example: Racing Against Disconnect

async sub long_poll_handler {
    my ($scope, $receive, $send) = @_;
    my $conn = $scope->{'pagi.connection'};

    my $disconnect_future = $conn->disconnect_future;
    my $event_future = wait_for_event();

    if ($disconnect_future) {
        # Race: wait for event OR disconnect
        await Future->wait_any($disconnect_future, $event_future);

        return unless $conn->is_connected;
    }

    my $event = $event_future->get;
    await send_response($send, $event);
}

WebSocket

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

WebSocket Connection Scope

Handshake Headers and Subprotocols

The headers arrayref must include all WebSocket handshake headers as raw byte strings, lower-cased, for example:

WebSocket Events

Connect - receive event

Accept - send event

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.

Behavior:

Example:

# Enable keepalive with 30s ping interval, 20s pong timeout
await $send->({
    type     => 'websocket.keepalive',
    interval => 30,
    timeout  => 20,
});

Disconnect - receive event

Sent when the WebSocket connection is closed, either by the client, server, or due to error conditions.

Common close codes and reasons:

| Code | Reason | Meaning | |------|--------|---------| | 1000 | (from client) | Normal closure | | 1001 | 'going away' | Client/server going away | | 1006 | 'keepalive timeout' | No pong received within timeout | | 1006 | 'send timeout' | Send operation timed out | | 1006 | 'write error' | Write to socket failed | | 1007 | 'invalid UTF-8' | Text frame contained invalid UTF-8 |

Disconnected Client - send exception

Raises server-specific subclass of OSError.

Close - send event

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:

Otherwise the connection uses a normal http scope.

Note on HTTP methods: SSE works with any HTTP method, not just GET. While the browser's native EventSource API only supports GET, libraries like Microsoft's fetch-event-source (used by htmx 4, datastar, and others) enable SSE over POST, PUT, and other methods via the Fetch API. PAGI servers MUST support SSE for all HTTP methods to enable these modern patterns.

Routing based on URL or application logic is not used to infer SSE.

SSE Connection Scope

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

Request Body - receive event

For SSE requests with a body (POST, PUT, etc.), the application receives the body via sse.request events, similar to HTTP:

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.

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.

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.

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.

Behavior:

Example:

# Enable keepalive with 30s interval
await $send->({
    type     => 'sse.keepalive',
    interval => 30,
    comment  => 'ping',
});

SSE Send Timeout

The sse.send event supports an optional timeout field:

Example:

await $send->({
    type    => 'sse.send',
    data    => 'hello',
    timeout => 5,
});

SSE Disconnect - receive event

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

Common reasons:

| Reason | Meaning | |--------|---------| | 'client disconnect' | Client closed connection normally | | 'write error' | Failed to write (keepalive or send) | | 'send timeout' | Send timeout exceeded |

PAGI to PSGI Compatibility

PAGI translates keys explicitly to maintain compatibility with PSGI:

Response mappings:

PAGI Encoding Differences

Version History

This document has been placed in the public domain.