Reading and writing
← All sections · part of the machine-readable /all/ index.
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
- GET method — retrieve a whole document or a single fragment by CSS selector
- PUT method — replace a fragment or create a document
- POST method — append content to a fragment or create a new resource
- MOVE method — atomically relocate a fragment within or across documents
- DELETE method — remove a fragment and its descendants from a document
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
- Protocol — OPTIONS discovery and WebDAV authoring
- Modeling data — schemas, constraints, and resolvers that validate writes
- Composing pages — includes, templating, and resource bindings
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
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 variable — request.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 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:
- a single ETag —
If-Match: "abc123...";
- a comma-separated list —
If-Match: "abc...", "def..." — which succeeds
if any listed version is current;
- the wildcard
If-Match: *, which succeeds as long as the document exists
at all (any version), and fails with 412 if it doesn't.
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 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 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 placement — append, 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.
| 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:
append / prepend key on the anchor itself (the container gaining a child).
before / after key on the anchor's parent (the container whose child list changes as the moved element becomes a sibling).
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 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 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.
| 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"
}
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
-
GET method — retrieve documents and fragments
-
Schema — the types whose microdata drives JSON-LD output
-
Templating — server-side rendering that runs before negotiation
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:
append — last child of the matched element
prepend — first child of the matched element
before / after — preceding / following sibling of the matched element
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
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 — .css → text/css, .png → image/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:
- No composition. Namespaced directives, expression bindings, and templates are not evaluated inside a blob.
- No selector addressing. A selector-range read or write is not applicable to a non-HTML/XML document and fails with
422 Unprocessable Content; selector operations require an HTML or XML document.
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
- PUT method — the request that stores a document or blob
- GET method — retrieving whole documents and fragments
- XML documents — the other non-HTML content family, which is parsed and composed
- Resource Creation — creating resources with
POST to a directory