Reading and writing

← All sections · part of the machine-readable /all/ index.

Reading and writing

Reading and writing

Reference for the HTTP methods that read, replace, append, relocate, and remove document fragments — plus content negotiation, the request document, and Server-Sent Events.

Pages in this group

HTTP Methods

Other pages in this group

Parameterized routes moved to Composing pages — it's a page-composition concept (matching a stored template to many request URLs), not an HTTP method.

See also

GET

GET method

The HTTP GET method retrieves a document or a fragment of a document. When combined with a selector range unit, it returns the matched element instead of the full page.

When to reach for it

Use GET to read content. A request without a selector range returns the whole document. A request with a selector range returns the matched fragment as a 206 Partial Content response with a Content-Range header identifying the element.

All GET requests are subject to authorization.

Examples

Retrieve a single element

Given a document with a heading and a paragraph, retrieve the heading alone:

GET /get-selector-test.html
Range: selector=h1
HTTP/1.1 206
Content-Range: selector=h1

<h1>Introduction</h1>

Resource not found

If the document does not exist, the server returns 404 Not Found:

GET /get-nonexistent-page.html
HTTP/1.1 404

Selector matches nothing

If the document exists but the CSS selector matches no element, the server returns 416 Range Not Satisfiable:

GET /get-no-match-test.html
Range: selector=h1
HTTP/1.1 416

Directory requests

A directory is served through its index.html: requesting /blog/ returns the document stored at /blog/index.html.

If you request a directory without the trailing slash — /blog — and that directory has an index.html you are allowed to read, the server replies with 301 Moved Permanently and a Location of the trailing-slash form (/blog/), preserving any query string. Following the redirect serves the index. This keeps the page's address canonical so that relative links and assets within it resolve correctly, and means clients that strip trailing slashes still reach the index instead of a 404.

A slash-less path that has no readable index.html is returned as a normal 404 Not Found (no redirect), and a path whose final segment looks like a file — it contains a ., such as /style.css — is always treated as a file request, never redirected.

Caching

Every 200 OK carries an ETag (and, where applicable, Last-Modified), so clients and caches can revalidate cheaply with If-None-Match — an unchanged resource returns 304 Not Modified.

Static assets served verbatim from storage — CSS, JavaScript, images, and fonts — are additionally sent with Cache-Control: public, max-age=300. This lets browsers, CDNs, and any caching proxy in front of the platform store them for up to five minutes instead of re-fetching on every request. An edit to a static asset is therefore picked up by shared caches once that window lapses (and immediately on a cache that revalidates via the ETag). HTML pages and live event streams are not cached this way: composed pages use a much shorter shared-cache floor (or stay private when they depend on who is asking), and SSE streams are never cached.

Error cases

Condition Status
Document does not exist 404 Not Found
Selector matches no element 416 Range Not Satisfiable
Authorization denied 403 Forbidden

See also

Request Document

Request Document

The request document is a transient HTML document that represents the current HTTP request. It exists only for the duration of request processing and is addressable via resource bindings and includes during page composition.

Caching. Because the request document carries per-request identity (the auth scope), any page that includes or binds a fragment of it is served Cache-Control: private and is excluded from the shared cache. The request document is internal: it is not itself addressable over HTTP.

Reading a field directly. If you only need a request field (not a selector-addressable fragment), the same data is available as the request context variablerequest.auth.claims.email, request.path, request.method, request.query.* — inside expression bindings and Liquid templates, without a selector.

When to reach for it

Use the request document to access request metadata — path, method, query parameters, headers, and authentication claims — inside templates and bindings. The document does not persist after the request completes.

Shape

The request document uses the https://pagelove.org/Request itemtype. It contains nested scopes for query parameters, headers, and authorization claims.

<!doctype html>
<html lang="en">
    <head></head>
    <body itemscope itemtype="https://pagelove.org/Request">
        <meta itemprop="path" content="/index.html">
        <meta itemprop="method" content="GET">
        <meta itemprop="query" content="foo=bar">
        <meta itemprop="body" content="">
        <section itemprop="query" itemscope itemtype="https://pagelove.org/Request/HTTP/Query">
            <meta itemprop="foo" content="bar">
        </section>
        <section itemprop="headers" itemscope itemtype="https://pagelove.org/Request/HTTP/Headers">
            <meta itemprop="accept" content="text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=8">
            <meta itemprop="host" content="docs.pagelove.org">
            <meta itemprop="accept-language" content="en-GB,en;q=0.9">
        </section>
        <section itemprop="auth" itemscope itemtype="https://pagelove.org/Authorization">
            <section itemprop="claims" itemscope itemtype="https://pagelove.org/Claims">
                <meta itemprop="email" content="james@pagelove.team">
                <meta itemprop="name" content="James A. Duncan">
                <meta itemprop="sub" content="sub_X3jXrDQvxAJl4s7Y6BVZ6JvE_ipt">
                <meta itemprop="picture" content="https://pictures.hello.coop/r/cf47a516-b15b-42c5-bea5-d2694a00be78.jpeg">
            </section>
            <meta itemprop="username" content="sub_X3jXrDQvxAJl4s7Y6BVZ6JvE_ipt">
            <meta itemprop="role" content="james@pagelove.team">
            <meta itemprop="role" content="admins">
            <meta itemprop="role" content="staff">
            <meta itemprop="role" content="users">
        </section>
    </body>
</html>

Fields

Field Itemtype Description
path The request path
method The HTTP method
query https://pagelove.org/Request/HTTP/Query Parsed query parameters as individual properties
body The raw request body
headers https://pagelove.org/Request/HTTP/Headers Request headers as individual properties
auth https://pagelove.org/Authorization Authentication and authorization data, including OIDC claims and roles

Examples

Request object in a Liquid template

Store a page that renders request properties using p:template:

{% example "setup-reqobj-page", "body" %}

When the page is requested, the template evaluates with the live request data:

GET /sspi-reqobj-page.html
HTTP/1.1 200

<!DOCTYPE html>
<html>
<body>
  <section>
    <p>Method: GET</p>
    <p>Path: /sspi-reqobj-page.html</p>
  </section>
</body>
</html>

See also

PUT

PUT method

The HTTP PUT method replaces a document fragment or creates a new document. When combined with a selector range unit, it replaces the matched element with the request body.

When to reach for it

Use PUT to overwrite an existing element or to store a new document at a given path. The server returns 206 Partial Content with a Content-Range header identifying the replaced element.

All PUT requests are subject to authorization.

Examples

Replace a single element

Given a document with a heading and a paragraph, replace the heading:

PUT /put-replace-test.html
Range: selector=h1
Content-Type: text/html

<h1>New Header</h1>
HTTP/1.1 206

<h1>New Header</h1>

Write persists

After replacing a heading, the change is stored:

PUT /put-persist-test.html
Range: selector=h1
Content-Type: text/html

<h1>Updated Title</h1>
HTTP/1.1 206

<h1>Updated Title</h1>

Reading the document back confirms the update:

GET /put-persist-test.html
Range: selector=h1
HTTP/1.1 206

<h1>Updated Title</h1>

Concurrency

To protect a whole-document PUT against lost updates, send the ETag you last read in an If-Match header. The write only succeeds if the document is still at that version when the write commits — if another client's save lands first, even a moment before yours, your PUT is rejected with 412 Precondition Failed instead of silently overwriting their work. The 412 response carries the document's current ETag, so you can re-read and retry without an extra GET.

If-Match accepts the standard HTTP forms:

The mirror header If-None-Match blocks a write when a listed ETag (or *, meaning "if it exists at all") matches the current version — use If-None-Match: * for create-only semantics: the PUT succeeds only when the path is not yet taken.

Error cases

Condition Status
Document does not exist 404 Not Found
Selector matches no element 416 Range Not Satisfiable
Authorization denied 403 Forbidden
Target path is under the reserved /.pagelove/ namespace 403 Forbidden
Schema validation fails 422 Unprocessable Entity
Selector-scoped write on a collaborative document can't be reconciled 422 Unprocessable Entity
Body exceeds the request-body size cap 413 Content Too Large

Absence and denial are distinct. A selector that matches no element gets 416, and one you are not permitted to write gets 401/403 — the two are never confused. Where the edge cannot see your target it re-checks against the live document before answering, so a 416 means the element really was absent at that moment. Two cases still refuse rather than reporting 416: a target that exists but is covered by a rule denying it, which is a genuine authorization answer rather than an absence; and a target you would not be permitted to read, since "there is nothing there" is itself information about the page. If you can write to part of a page you cannot read, writes refuse identically whether the target exists or not.

Reserved namespace. The /.pagelove/ path prefix is reserved for the platform's internal documents and is not writable. Any write (PUT, POST, DELETE, MOVE) targeting a path under /.pagelove/ is refused with 403 Forbidden; choose a different path for your own documents.

Request body size limit

Every write request's body is subject to an operator-configured size cap (1 GiB unless changed; it can also be tuned per site, so the limit may differ between hosts). A request whose Content-Length exceeds the cap is answered 413 Content Too Large immediately, without the body being read; an upload without an accurate Content-Length (for example, chunked) is rejected with 413 as soon as it exceeds the cap. The 413 response body is HTML carrying https://pagelove.org/Error Microdata with status and message properties, and the connection is closed.

Send an accurate Content-Length when uploading large content so an over-limit request fails fast instead of transferring the whole body.

See also

POST

POST method

The HTTP POST method appends content to a document fragment or creates a new resource. When combined with a selector range unit, it appends the request body as a child of the matched element.

When to reach for it

Use POST to add content without replacing what already exists. The server returns 206 Partial Content with a Content-Range header identifying the updated element.

All POST requests are subject to authorization.

Examples

Append to a list

Given a document with a list, append a new item:

POST /post-append-test.html
Range: selector=ul
Content-Type: text/html

<li>New item</li>
HTTP/1.1 206

<li>New item</li>

Verify the append

Reading the list back confirms that existing items are preserved alongside the new one:

GET /post-verify-test.html
Range: selector=ul
HTTP/1.1 206

<ul>
    <li>Existing item</li>
  <li>Appended item</li>
</ul>

Placement

By default POST appends the request body as the last child of the element matched by Range:. To insert at a different position, supply placement=<value> as a Range: sub-field:

Placement Insertion site
append (default) Last child of the matched element
prepend First child of the matched element
before Previous sibling of the matched element
after Next sibling of the matched element

placement=before and placement=after against a parentless anchor (e.g. <html>) return 400 Bad Request — there's no sibling slot.

Prepend a list item

POST /post-prepend-test.html
Range: selector=ul; placement=prepend
Content-Type: text/html

<li>New first item</li>
HTTP/1.1 206

<li>New first item</li>

Insert before a specific sibling

POST /post-before-test.html
Range: selector=li#beta; placement=before
Content-Type: text/html

<li>Inserted before beta</li>
HTTP/1.1 206

<li>Inserted before beta</li>

Insert after a specific sibling

POST /post-after-test.html
Range: selector=li#alpha; placement=after
Content-Type: text/html

<li>Inserted after alpha</li>
HTTP/1.1 206

<li>Inserted after alpha</li>

Error cases

Condition Status
Document does not exist 404 Not Found
Selector matches no element 416 Range Not Satisfiable
Authorization denied 403 Forbidden
Schema validation fails 422 Unprocessable Entity

Absence and denial are distinct. A selector that matches no element gets 416, and one you are not permitted to write gets 401/403 — the two are never confused. Where the edge cannot see your target it re-checks against the live document before answering, so a 416 means the element really was absent at that moment — for an append that does not name a placement. A POST that specifies placement=before or placement=after currently refuses instead of reporting 416 when its anchor is absent.

Two further cases refuse rather than reporting 416: a target that exists but is covered by a rule denying it, which is a genuine authorization answer rather than an absence; and a target you would not be permitted to read, since "there is nothing there" is itself information about the page. If you can write to part of a page you cannot read, writes refuse identically whether the target exists or not.

Concurrency

A selector POST is additive: it appends to the matched element rather than replacing it. Two clients that append to the same element at the same time both land — neither append is lost, and both requests succeed. The server applies each append against the latest stored version of the document, so concurrent appends accumulate rather than one overwriting the other.

If you send a conditional append with If-Match: "<etag>" and the document changed since that ETag, the append is rejected with 412 Precondition Failed (re-read the document and retry against the current version) rather than applied against the new state.

This accumulation guarantee also holds when the matched element was projected into the page from another resource (via <p:stamp> or <p:include>): the append is routed to that element's origin resource and re-applied against the origin's latest version on conflict, so concurrent appends to a stamped or included element accumulate just as they do for a same-document append.

See also

MOVE

MOVE method

The HTTP MOVE method atomically relocates an element from one position to another. The source element is removed and re-inserted at a destination anchor with a chosen placementappend, prepend, before, or after relative to the anchor.

When to reach for it

Use MOVE to reorder children of a container or to shift an element between containers in a single request. The removal and insertion happen in one transaction; readers never observe a state where the element is missing or duplicated. Schema cardinality is checked at both the source ancestor (after removal) and the destination ancestor (after insertion), so a MOVE that would violate either side fails as a unit.

A successful MOVE returns 204 No Content.

Headers

Header Required Purpose
Range: selector=<source> Yes Selects the source element
Destination: <path> Yes The target document path. For an element move this must equal the request path; a whole-document move may name a different path
Destination-Range: selector=<dest>; placement=<position> Yes (for element moves) Selects the destination anchor and the placement directive

A request is a whole-document move only when both Range and Destination-Range are omitted; the whole document is then relocated to Destination. Supplying Range without Destination-Range (or vice versa) is an incomplete element move and is rejected with 422 Unprocessable Entity.

Placement

placement= accepts one of four values, case-insensitive:

Value Insertion site relative to the destination anchor
append As the last child of the anchor
prepend As the first child of the anchor
before As the previous sibling of the anchor
after As the next sibling of the anchor

Authorization

MOVE is always default-deny. Unlike GET, it is never covered by a host's default-GET mode, so a MOVE that matches no rule is refused even on a host that allows unmatched reads. Every MOVE needs at least one explicit AuthorizationRule.

The three checks

Because a MOVE both removes an element and inserts one, it is not authorized by a MOVE rule alone. An element MOVE is authorized by three separate checks, all of which must pass:

# Method checked Selector checked Granted by a rule for
1 MOVE none The document as a whole
2 DELETE the Range selector (the source element) Removing the source element
3 POST the Destination-Range selector (the destination anchor) Inserting at the destination

A rule granting MOVE therefore permits the operation, while the DELETE and POST rules permit the two element-level edits it performs. Granting MOVE alone is not sufficient — the request is denied at check 2 unless a DELETE rule also covers the source element.

All three checks are evaluated against the request path. An element MOVE cannot cross documents (see Error cases), so the request path is also the destination document.

Check 3 is authorized exactly as a POST to the destination anchor with the same placement — it keys on the element whose child list changes:

So a POST rule that authorizes inserting into a container also authorizes moving an element to append/prepend inside it, or before/after any of its children — no separate rule per placement is needed.

A MOVE rule must not carry a selector

An AuthorizationRule whose method is MOVE must leave selector empty. A MOVE rule that specifies a selector is discarded entirely — it does not merely lose its selector. An Allow rule written that way has no effect at all, and the MOVE is denied by default:

<!-- WRONG: discarded — this grants nothing, and MOVE is denied -->
<tr itemscope itemtype="https://pagelove.org/AuthorizationRule">
  <td itemprop="actor">editors</td>
  <td itemprop="resource">/board.html</td>
  <td itemprop="method">MOVE</td>
  <td itemprop="selector">.card</td>
  <td itemprop="action">Allow</td>
</tr>

Element granularity for a MOVE comes from the DELETE and POST rules in checks 2 and 3, which do take selectors. An empty or whitespace-only selector cell is fine.

Example: allowing editors to reorder cards

Three rules — one per check — let editors move any .card between lanes on a board:

<table itemscope itemtype="https://pagelove.org/AuthorizationRule">
  <tbody>
    <!-- Check 1: permit the MOVE operation on the document (no selector) -->
    <tr>
      <td itemprop="actor">editors</td>
      <td itemprop="resource">/board.html</td>
      <td itemprop="method">MOVE</td>
      <td itemprop="selector"></td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Check 2: permit removing a card from its current lane -->
    <tr>
      <td itemprop="actor">editors</td>
      <td itemprop="resource">/board.html</td>
      <td itemprop="method">DELETE</td>
      <td itemprop="selector">.card</td>
      <td itemprop="action">Allow</td>
    </tr>

    <!-- Check 3: permit inserting a card into a lane -->
    <tr>
      <td itemprop="actor">editors</td>
      <td itemprop="resource">/board.html</td>
      <td itemprop="method">POST</td>
      <td itemprop="selector">.lane</td>
      <td itemprop="action">Allow</td>
    </tr>
  </tbody>
</table>

The single POST rule on .lane covers every placement: append/prepend anchor a lane directly, and before/after anchor a .card whose parent is a .lane. So this one container-level rule authorizes both moving a card into a lane and reordering cards within one.

Narrowing check 2 or check 3 narrows which moves are possible. A DELETE rule scoped to #lane-todo > .card permits moving cards out of the todo lane only; a POST rule scoped to #lane-done permits inserting them into the done lane only.

Whole-document MOVE

A MOVE with no Destination-Range relocates a whole document. It has no source or destination selector, so checks 2 and 3 do not apply — but it is authorized by a MOVE rule on both the request path (the source) and the Destination path. Relocating a document is authorized against where it's landing, not only against where it started: a deny rule matching either path refuses the whole request, so a document can't be moved into a location protected by a deny rule just because the source location itself is allowed.

Denials

Condition Status
Any of the three checks denies, and the request is unauthenticated 401 Unauthorized
Any of the three checks denies, and the request is authenticated 403 Forbidden

Authorization for MOVE fails closed: if a selector in check 2 or check 3 matches no element, or the document backing the check cannot be read, the request is denied rather than allowed.

Examples

Move a card to a different lane

Given a board with cards in two lanes, move card 3 to the end of the "done" lane:

MOVE /move-append-test.html
Range: selector=#card-3
Destination: /move-append-test.html
Destination-Range: selector=#lane-done; placement=append
HTTP/1.1 204

Move a sibling before another

Reorder list items by inserting one before another:

MOVE /move-before-test.html
Range: selector=#item-c
Destination: /move-before-test.html
Destination-Range: selector=#item-a; placement=before
HTTP/1.1 204

Error cases

Condition Status
Source (Range) selector matches no element 416 Range Not Satisfiable
Destination (Destination-Range) selector matches no element 404 Not Found
Incomplete element move — only one of Range / Destination-Range supplied, or a missing placement 422 Unprocessable Entity
Whole-document move with no Destination header 422 Unprocessable Entity
Element move whose Destination names a different document 501 Not Implemented
Invalid Range / Destination-Range selector, or an illegal move (e.g. into the element's own descendant) 422 Unprocessable Entity
Authorization denied 401 Unauthorized / 403 Forbidden — see Authorization
Schema cardinality violated at source or destination 422 Unprocessable Entity

Cross-document element moves are not implemented: a MOVE carrying a Destination-Range whose Destination differs from the request path is rejected with 501 Not Implemented and a MoveCrossResource error, before anything is written. Only whole-document MOVE may cross paths; a whole-document move whose Destination names a path with no existing document creates it and returns 204.

A Destination-Range selector that matches no element returns 404 Not Found: the destination element MUST exist, and its absence is a client condition, not a server fault. This mirrors the source-selector case (a Range that matches no element returns 416) — both are reported as client errors rather than a 5xx.

See also

DELETE

DELETE method

The HTTP DELETE method removes content. With a selector range unit it removes one element and all its descendants; without a selector it removes the whole document.

When to reach for it

Use DELETE to remove a fragment from a document, or the whole document. The server returns 204 No Content — for a selector-scoped delete, with a Content-Range header identifying the removed element.

All DELETE requests are subject to authorization.

Examples

Remove an element

Remove the last paragraph from a document:

DELETE /delete-remove-test.html
Range: selector=p:last-child
HTTP/1.1 204

Verify the removal

Reading the document back confirms the element is gone:

GET /delete-verify-test.html
HTTP/1.1 200

<!DOCTYPE html>
<html><body>
  <h1>Title</h1>
  <p>Keep this paragraph.</p>
  
</body></html>

Delete a whole document

A DELETE with no selector removes the entire document:

DELETE /delete-whole-doc-test.html
HTTP 204

A later GET for that path returns 404 Not Found.

Concurrency

A whole-document DELETE can be made conditional with If-Match, exactly as for PUT: send the ETag you last read, and the delete only succeeds if the document is still at that version when the delete commits. If another client changed the document in the meantime, the DELETE is rejected with 412 Precondition Failed (with the current ETag in the response) instead of removing a version you never saw. The single-tag, list, and * forms of If-Match all work as they do for PUT.

Error cases

Condition Status
Document does not exist 404 Not Found
Selector matches no element 416 Range Not Satisfiable
Authorization denied 403 Forbidden
Schema validation fails 422 Unprocessable Entity
Selector-scoped delete on a collaborative document can't be reconciled 422 Unprocessable Entity
If-Match ETag no longer current (document changed concurrently) 412 Precondition Failed

Absence and denial are distinct. A selector that matches no element gets 416, and one you are not permitted to write gets 401/403 — the two are never confused. Where the edge cannot see your target it re-checks against the live document before answering, so a 416 means the element really was absent at that moment. Two cases still refuse rather than reporting 416: a target that exists but is covered by a rule denying it, which is a genuine authorization answer rather than an absence; and a target you would not be permitted to read, since "there is nothing there" is itself information about the page. If you can write to part of a page you cannot read, writes refuse identically whether the target exists or not.

See also

Content Negotiation

Content Negotiation

Content negotiation lets a client request an alternative representation of a resource via the Accept header, without changing the URL.

When to reach for it

Use content negotiation when a client needs structured data instead of rendered HTML. Set the Accept header on a GET request to receive a different media type.

Supported media types

Accept value Response
text/html (default) The HTML document, after server-side processing
application/ld+json The document's microdata serialized as JSON-LD

When no Accept header is present, or when it does not match a supported type, HTML is returned.

Content negotiation applies to both full-document and selector requests.

JSON-LD serialization

When a client requests application/ld+json, the server extracts HTML Microdata from the rendered page and returns JSON-LD.

Context inference

The @context and @type are inferred from the itemtype attribute. When itemtype is a full URL, the base becomes @context and the type name becomes @type.

Given this HTML:

<div itemscope itemtype="http://schema.org/Person">
  <span itemprop="name">Alice</span>
</div>

The JSON-LD response is:

{
  "@context": "http://schema.org/",
  "@type": "Person",
  "name": "Alice"
}

For non-URL itemtype values, the full string is used as @type with no @context.

Multiple items

When a page contains multiple top-level itemscope elements, they are wrapped in a @graph:

{
  "@context": "http://schema.org/",
  "@graph": [
    { "@type": "Person", "name": "Alice" },
    { "@type": "Person", "name": "Bob" }
  ]
}

When all items share the same vocabulary, @context is hoisted to the top level. When items use different vocabularies, each item carries its own @context inside the @graph.

A page with a single top-level item returns a flat JSON-LD object without @graph.

Example

Request JSON-LD by setting the Accept header:

GET /content-neg-jsonld-test.html
Accept: application/ld+json
HTTP/1.1 200
Content-Type: application/ld+json; charset=utf-8

{
  "@context": "http://schema.org/",
  "@type": "Person",
  "name": "Alice"
}

Vary header

All responses include Vary: Accept so that caches distinguish between HTML and JSON-LD representations of the same URL.

Non-HTML resources

Content negotiation applies only to HTML resources. Requests for non-HTML resources (images, stylesheets, scripts) ignore the Accept header and return the resource unchanged.

See also

Server-Sent Events

Server-Sent Events

Pagelove streams document mutations to connected clients over Server-Sent Events. When a document changes, every subscriber receives the mutation in real time without polling.

When to reach for it

Use SSE when a client needs to react to content changes as they happen — live dashboards, collaborative editing indicators, or cache invalidation. The wire format described here is the HTTP-level protocol. For a client-side JavaScript wrapper, see Server-Sent Events (JavaScript).

Subscribing

Open a stream by sending a GET request with Accept: text/event-stream:

% curl -si https://example.pagelove.org/pages/index.html -H 'Accept: text/event-stream'
HTTP/2 200 OK
content-type: text/event-stream
cache-control: no-cache

The connection remains open. Events arrive as they occur.

A subscribe request is subject to the same authorization rules as any other read — including on a host with no explicit rule for the resource: unlike an ordinary GET, a subscribe request is never granted by a host's default-GET mode, so a resource with no matching rule always denies it.

Mutation events

When a subscribed document is modified, the server sends a mutation event. The payload is an HTML fragment annotated with Microdata:

id: 1709942400000-0
event: mutation
data: <article itemscope itemtype="https://pagelove.org/Mutation">
data:   <span itemprop="method">PUT</span>
data:   <span itemprop="selector">main > h1</span>
data:   <span itemprop="etag">a1b2c3...</span>
data:   <span itemprop="path">/pages/index.html</span>
data:   <span itemprop="host">example.com</span>
data:   <div itemprop="body"><h1>Hello</h1></div>
data: </article>

Mutation event fields

Property Description
method The HTTP method that caused the mutation (PUT, POST, or DELETE)
selector CSS selector identifying the mutated element
etag Element-level ETag of the mutated content
path Document path
host Virtual host
body The mutated HTML fragment (empty for DELETE)
placement POST and MOVE only. Where the content was inserted relative to the element selector matched: append, prepend, before, or after. Absent for other methods.

The id field is a server-assigned event identifier used for reconnection.

Applying a POST event

A POST does not always append. The writer may have asked for a different insertion site with placement= on Range:, and the event reports the position actually used — so insert body relative to the element selector matched rather than assuming a trailing append:

Every POST event carries a placement; a POST that omitted placement= reports an explicit append. If placement is missing or holds a value you don't recognise, refetch the document instead of guessing a position.

Reset events

When the server cannot guarantee stream continuity, it sends a reset event:

event: reset
data: <article itemscope itemtype="https://pagelove.org/StreamReset">
data:   <span itemprop="reason">events-expired</span>
data: </article>
Reason Meaning
events-expired The last-known event has aged out of the server's retention window
session-expired The subscriber's session has passed its expiry time
session-invalidated The subscriber's session is gone, corrupt, or otherwise unusable

A reset indicates that the client should re-fetch the full document to re-synchronize.

Reconnection

SSE clients reconnect automatically when the connection drops. On reconnection the browser sends the last received event ID via Last-Event-ID:

GET /pages/index.html HTTP/1.1
Accept: text/event-stream
Last-Event-ID: 1709942400000-0

The server replays any events that occurred after that ID. Events are retained for 10 minutes. If the ID has expired, the server sends a reset event with reason events-expired.

If your client stops reading

Each subscription has a bounded send buffer. A client that stops consuming its stream — a paused tab, a stalled reader, a connection that is open but no longer draining — fills that buffer, and the server then closes the stream rather than waiting for it.

This is not an error condition to guard against, and it is not data loss: your client reconnects as above, sends Last-Event-ID, and receives everything it missed within the retention window. From the client's point of view it is an ordinary reconnect.

The reason the server closes rather than waits is that subscribers share one delivery path. A stream held open by a client that never reads would otherwise delay delivery for every other subscriber on that server, so a client which cannot keep up is disconnected and invited to catch up through replay.

Practically, this only affects clients that stop reading for long enough to fall many events behind. Consume events as they arrive — do not block inside an event handler on a slow operation — and you will never see it.

Keepalives

The server sends SSE comment lines every 20 seconds to prevent proxies from closing idle connections:

: ping

Conformant SSE clients ignore these comments.

Echo suppression

A mutation is never delivered back to the connection that originated it — a client that writes to a document it also subscribes to does not receive its own write echoed to it (this covers both the mutation event and any paired crdt-delta).

By default this is keyed on session id. Since a browser's per-origin session cookie is shared across every tab, all same-origin tabs are treated as one unit: a write from tab A is suppressed for every tab sharing that session, not just tab A itself — so tab B would not see tab A's write live either.

To distinguish tabs, use the connection token the server assigns to every stream. The first event on each stream is pagelove-connection, and its data is an opaque token for that connection:

<script>
  const source = new EventSource("/notes.html"); // plain URL — nothing to mint

  let conn = null;
  source.addEventListener("pagelove-connection", (evt) => {
    conn = evt.data; // arrives before any mutation event
  });
</script>

Send that token back as a Pagelove-Connection header on every mutating request (PUT/POST/DELETE/PATCH) from that tab.

When both the subscription and the write carry the token, suppression narrows to an exact match: the originating tab is still suppressed, but every other same-origin tab keeps receiving the mutation live. Omitting the header — including a write sent before the pagelove-connection event has arrived — falls back to the session-id behavior above, so existing clients are unaffected.

The pre-2026 legacy channels (a self-minted ?conn=<token> query parameter and the X-Pagelove-Connection write header) have been removed: a ?conn= parameter is ignored — the server always assigns the token — and the legacy header is not read, so a writer still sending it falls back to the session-id behavior above.

Composed resources

When a resource included via includes is mutated, subscribers to the parent document also receive the event. A page that includes a shared header or footer receives live updates when those fragments change.

Live example

The following demonstrates a complete SSE round-trip: subscribe to a document, mutate it, and receive the mutation event.

PUT /sse-roundtrip-test.html HTTP/2
Content-Type: text/html

<!DOCTYPE html>
<html><body><h1>Original</h1><p>Content here.</p></body></html>
SUBSCRIBE /sse-roundtrip-test.html
Accept: text/event-stream
PUT /sse-roundtrip-test.html HTTP/2
Range: selector=h1
Content-Type: text/html

<h1>Updated</h1>
HTTP/2 206 Partial Content
SSE mutation
data-contains: itemprop="method"
data-contains: PUT
data-contains: Updated

See also

Uploading files

Uploading files

Any file that is not HTML or XML — images, CSS, JavaScript, JSON, PDFs, fonts, archives — is stored as an opaque blob and served back byte-for-byte. Blobs are not parsed, composed, or addressable by selector.

Uploading

PUT the file bytes to a path, with the file's media type as the Content-Type:

PUT /data.json HTTP/2
Host: 127.0.0.1
Content-Type: application/json

{"name":"widget","price":29.99}

Fetching the path returns the stored bytes unchanged, with the stored content type:

GET /data.json HTTP/2
Host: 127.0.0.1
HTTP/2 200
content-type: application/json

{"name":"widget","price":29.99}

POST to a directory creates a new blob under a generated name, the same way it creates HTML resources — see Resource Creation.

Content type

The stored content type decides how a document is served and whether it is treated as HTML, XML, or an opaque blob. It is resolved in this order:

# Rule
1 A .html / .htm path is always text/html, even if the request sends a different Content-Type.
2 Otherwise, the explicit Content-Type request header, if present.
3 Otherwise, inferred from the file extension — .csstext/css, .pngimage/png, and so on.
4 Otherwise, application/octet-stream.

Because the explicit header wins over the extension (rule 2 before rule 3), send the correct Content-Type for the file — or omit the header entirely and let the extension decide. A wrong or generic header (for example, a client that defaults every body to application/x-www-form-urlencoded) is stored verbatim and served back as-is.

Serving

A blob GET returns the stored bytes with the stored Content-Type and a content-hash strong ETag, so conditional requests revalidate cheaply. Static-asset types — CSS, JavaScript, images, and fonts — are additionally served with Cache-Control: public, max-age, so caches and CDNs between tiers can store them.

Blobs are opaque:

Storage

Blob bytes are held in an external object store, not the primary document database. An operator configures one or more backends — filesystem, s3, or azure; when several are configured, reads try each in list order and writes and deletes fan out to all of them. This is an operator concern: authoring is the same PUT / GET regardless of the backend.

See also