Processors
A processor fires after the server processes a request, before the response is sent to the client. Processors can inspect and modify the response — status code, headers, and body — or leave it unchanged.
Processors are stored as HTML Microdata items and discovered automatically via selector queries, the same way triggers are.
Quick example
This processor intercepts 404 responses and replaces the body with a custom error page:
<div itemscope itemtype="https://pagelove.org/Processor">
<meta itemprop="resource" content="*">
<meta itemprop="method" content="GET">
<meta itemprop="status" content="404">
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.response.body = "<h1>Page not found</h1><p>Sorry, that page doesn't exist.</p>"
</script>
</div>
</div>
Request lifecycle
Processors sit near the end of the request lifecycle:
- Request arrives
- Trigger phase — matching triggers execute
- Core processing (GET, PUT, POST, DELETE, etc.)
- Processor phase — matching processors execute in order
- Async dispatch — queued outbound HTTP requests fire
- Response sent
Properties
All filter properties are optional. Omitting a filter means "match all."
resource
One or more glob patterns. The processor fires only when the request path matches at least one pattern. Same syntax as trigger resource.
<meta itemprop="resource" content="/blog/*">
method
HTTP methods to match. Same rules as trigger method.
<meta itemprop="method" content="GET">
selector
A CSS selector with semantic matching. Same behaviour as trigger selector.
status
HTTP status codes or class prefixes. The processor fires only when the response status matches one of the listed values.
<!-- Exact match -->
<meta itemprop="status" content="404">
<!-- Class prefix — matches any status 400-499 -->
<meta itemprop="status" content="4xx">
<!-- Multiple patterns (OR'd) — use separate elements -->
<meta itemprop="status" content="404">
<meta itemprop="status" content="410">
<!-- Match all errors -->
<meta itemprop="status" content="4xx">
<meta itemprop="status" content="5xx">
An empty or omitted status filter matches any status code.
when
A Sessel or JavaScript expression that gates execution. Same behaviour as trigger when, but with access to the response for status-conditional gating.
<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.response.status == 404
</script>
</div>
The JavaScript equivalent reads ctx.response instead of Context.response:
<div itemprop="when" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) {
return ctx.response.status === 404;
}
</script>
</div>
action
One or more actions, same types as trigger actions: Sessel, JavaScript, and outbound HTTP request.
<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) {
throw {
schema_url: "https://pagelove.org/HTTPResponse",
status: 200,
body: "<p>Custom 404 page</p>"
};
}
</script>
</div>
A JavaScript processor action cannot mutate the response directly (see Response-mutation asymmetry) — it must throw an HTTPResponse to replace it, the same shape used by trigger chain termination.
Context
Sessel expressions in processors have access to both Context.request and Context.response:
| Property |
Type |
Description |
Context.request.method |
String |
HTTP method |
Context.request.path |
String |
Request path |
Context.request.headers |
Map |
Request headers |
Context.request.query |
Map |
Query string parameters |
Context.response.status |
Number |
HTTP status code |
Context.response.body |
String |
Response body (fully buffered) |
Context.response.headers |
Map |
Response headers |
JavaScript when/action modules receive the equivalent context as their first positional argument (conventionally named ctx):
| Property |
Type |
Description |
ctx.request |
Object |
Same shape as the trigger JavaScript context |
ctx.response.status |
Number |
HTTP status code |
ctx.response.body |
String |
Response body (fully buffered) |
ctx.response.headers |
Object |
Response headers |
Reading and writing the response
Both reading and writing use Context.response:
Context.response.status // 404
Context.response.body // "<p>Not Found</p>"
Context.response.status = 200
Context.response.body = "<p>Found it after all</p>"
Pass-through
If no Sessel action modifies any Context.response property, the original response is forwarded unchanged. Processors are pass-through by default — they only affect the response when an action explicitly writes to Context.response.status, Context.response.body, or Context.response.headers.
Response-mutation asymmetry
This direct-mutation style is Sessel-only. JavaScript processor actions run the same evaluation path used for when gates, which does not detect writes to ctx.response — assigning to it has no effect. A JavaScript processor action that needs to change the response must throw an HTTPResponse (see action above) rather than mutate ctx.response in place.
Writing from processors
Like triggers, processor Sessel actions can write to the database using Pagelove.PUT() and Pagelove.DELETE(). This, too, is Sessel-only — see Writing from triggers for the JavaScript alternative (an outbound HTTP request).
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Pagelove url("https://pagelove.org/1.0");
@schema AuditLog url("https://example.com/AuditLog");
let log = new AuditLog { status: Context.response.status };
Pagelove.PUT(log, "/audit/latest.html")
</script>
</div>
Side-effect writes from processors follow the same semantics as trigger writes — see Writing from triggers for details.
Chain termination
A Sessel or JavaScript action can terminate the processor chain by throwing an HTTPResponse, the same mechanism as in triggers. The thrown response replaces whatever core produced.
Execution order
Same rules as triggers — lexicographic by document path, document order within each document.
Error handling
Same rules as triggers — malformed Microdata is skipped, Sessel or JavaScript runtime errors surface as error responses.
See also
Triggers
A trigger fires before the server processes a request. Triggers can prepare state, write to transient elements, queue outbound HTTP requests, or terminate the request chain by throwing an HTTP response.
Triggers are stored as HTML Microdata items in the database and discovered automatically via selector queries. Any document on the host can contain trigger definitions.
Quick example
This trigger fires on every PUT or POST to paths under /blog/ and sends a webhook notification:
<div itemscope itemtype="https://pagelove.org/Trigger">
<meta itemprop="resource" content="/blog/*">
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<meta itemprop="url" content="https://hooks.example.com/notify">
<meta itemprop="method" content="POST">
</div>
</div>
Request lifecycle
Triggers sit at the beginning of the request lifecycle:
- Request arrives
- Trigger phase — matching triggers execute in order
- Core processing (GET, PUT, POST, DELETE, etc.)
- Processor phase — matching processors execute
- Async dispatch — queued outbound HTTP requests fire
- Response sent
If a trigger throws an HTTP response, steps 3--4 are skipped. The thrown response is returned directly.
Properties
All filter properties are optional. Omitting a filter means "match all."
resource
One or more glob patterns. The trigger fires only when the request path matches at least one pattern.
<meta itemprop="resource" content="/blog/*">
<meta itemprop="resource" content="/news/*">
<meta itemprop="resource" content="/announcements/*">
Multiple resource values are OR'd — any match is sufficient.
method
HTTP methods to match. The trigger fires only when the request method matches one of the listed values. Matching is case-insensitive. GET implies HEAD.
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<meta itemprop="method" content="DELETE">
selector
A CSS selector. The trigger fires only when the element targeted by the request appears in the set of elements selected by this selector.
Both the trigger's selector and the request's selector are resolved against the actual document (semantic matching). If the request does not carry a selector (e.g. a plain GET /page), the selector filter is not evaluated and the trigger matches as if the filter were omitted.
<meta itemprop="selector" content="article">
when
A Sessel or JavaScript expression that gates execution. If the expression evaluates to a falsy value (null, false, 0, empty string), the trigger is skipped. Omitting when means the trigger fires whenever its declarative filters match.
<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.headers["Authorization"] != null
</script>
</div>
The same gate written in JavaScript uses the https://pagelove.org/JavaScript/Module typed item instead — dispatch is by itemtype, not by the <script> tag:
<div itemprop="when" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) {
return ctx.request.method === "PUT";
}
</script>
</div>
The module's default export is called with the request context as its first positional argument — see Context below. this is not bound to anything meaningful for a when gate.
action
Actions to execute when the trigger fires. Actions execute in document order. Three action types are supported. A trigger needs at least one action or at least one otherwise (see below) — a trigger with neither is skipped:
- Sessel — a synchronous expression with access to
Context.request
- JavaScript — a synchronous
export default function(ctx) module with access to the same request context (see Context)
- Outbound HTTP request — an asynchronous outbound call, queued for dispatch after the response is sent
<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) {
if (ctx.request.path.startsWith("/admin/")) {
throw {
schema_url: "https://pagelove.org/HTTPResponse",
status: 403,
message: "Admin area is restricted"
};
}
return true;
}
</script>
</div>
A JavaScript action's return value is discarded — only side effects (an outbound request queued elsewhere in the chain, or a thrown HTTPResponse) have any effect. See Chain termination for the throw shape, and Writing from triggers for a capability JavaScript actions do not have.
<!-- Sessel action -->
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.headers["Authorization"] != null
</script>
</div>
<!-- Outbound HTTP request action -->
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<meta itemprop="url" content="https://hooks.example.com/notify">
<meta itemprop="method" content="POST">
</div>
otherwise
One or more actions to execute when when evaluates to a falsy value, instead of action. Same action types, same document-order execution, same Context. otherwise is only meaningful alongside a when gate — if when is omitted, otherwise is never evaluated, since there is no falsy case for it to handle.
<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.headers["Authorization"] != null
</script>
</div>
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">1</script>
</div>
<div itemprop="otherwise" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema HTTPResponse url("https://pagelove.org/HTTPResponse");
throw new HTTPResponse { status: 401, body: "Authorization required" }
</script>
</div>
This is a shorthand for the common "require X, otherwise reject" shape — it saves inverting the when condition and repeating it, and keeps the accepted and rejected paths next to each other in the markup.
Context
Sessel expressions in triggers have access to Context.request:
| Property |
Type |
Description |
Context.request.method |
String |
HTTP method (GET, PUT, POST, etc.) |
Context.request.path |
String |
Request path |
Context.request.headers |
Map |
Request headers |
Context.request.query |
Map |
Query string parameters |
Context.request.body |
String |
Request body (raw string) |
Context.request.rawBody |
String |
Request body as raw UTF-8 bytes |
Context.response is not available during trigger execution — the response does not exist yet.
JavaScript when/action modules receive the equivalent request context as their first positional argument (conventionally named ctx):
| Property |
Type |
Description |
ctx.request.method |
String |
HTTP method (GET, PUT, POST, etc.) |
ctx.request.path |
String |
Request path |
ctx.request.headers |
Object |
Request headers |
ctx.request.host |
String |
The host name |
ctx.request.query |
Object |
Query string parameters |
ctx.request.body |
String |
Request body, when available |
ctx.request.auth |
Object |
Authentication claims, when available |
Chain termination
A Sessel action can terminate the trigger chain by throwing an HTTPResponse. The server does not process the request — the thrown response is returned directly to the client.
<div itemscope itemtype="https://pagelove.org/Trigger">
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<meta itemprop="method" content="DELETE">
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
@schema HTTPResponse url("https://pagelove.org/HTTPResponse");
if (Context.request.headers["Authorization"] == null) {
throw new HTTPResponse {
status: 403,
message: "<p>Forbidden</p>"
}
}
</script>
</div>
</div>
This is the mechanism for request guards, authentication checks, and access control at the trigger level.
A JavaScript action terminates the chain the same way, by throwing a plain object with schema_url (or itemtype) set to https://pagelove.org/HTTPResponse:
<div itemprop="action" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) {
if (ctx.request.headers["Authorization"] == null) {
throw {
schema_url: "https://pagelove.org/HTTPResponse",
status: 403,
message: "Forbidden"
};
}
}
</script>
</div>
Supported properties on the thrown object: status (defaults to 500), message (fallback body when body is absent), body, and headers (an object of additional response headers).
An HTTPResponse can include custom response headers using the header property with Pair values:
@schema HTTPResponse url("https://pagelove.org/HTTPResponse");
@schema Pair url("https://pagelove.org/Pair");
throw new HTTPResponse {
status: 303,
header: new Pair { key: "Location", value: "/other-page.html" }
}
Multiple headers can be set by repeating the header property (it has 0..n cardinality):
throw new HTTPResponse {
status: 200,
body: "Hello
",
header: new Pair { key: "X-Custom", value: "one" },
header: new Pair { key: "Cache-Control", value: "no-cache" }
}
Writing from triggers
Trigger Sessel actions can write to the database using the Pagelove.PUT() and Pagelove.DELETE() methods. Import the Pagelove schema first:
This is a Sessel-only capability. JavaScript actions do not have access to Pagelove.PUT()/Pagelove.DELETE() — they run the same evaluation path used for when gates, without a write provider. A JavaScript action that needs to write should queue an outbound HTTP request back to the host instead.
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Pagelove url("https://pagelove.org/1.0");
@schema Thing url("https://schema.org/Thing");
let item = new Thing { name: "side-effect-doc" };
Pagelove.PUT(item, "/side-effects/record.html")
</script>
</div>
Side-effect writes
Pagelove.PUT(item, "/path.html") writes a document to the specified path. The write goes through the normal request pipeline, including authorization checks and schema validation.
Body transformation
Pagelove.PUT(item, Context.request.path) writes to the same path as the incoming request. This replaces the request body in-flight — the server processes the transformed content instead of the original. No throw is needed; core processing runs normally on the modified body.
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Pagelove url("https://pagelove.org/1.0");
@schema Article url("https://schema.org/Article");
let transformed = new Article {
name: "transformed-by-trigger"
};
Pagelove.PUT(transformed, Context.request.path)
</script>
</div>
Chaining
Each trigger in the chain sees the body as left by the previous trigger's Pagelove.PUT(). The last writer wins.
Error handling for writes
Side-effect writes committed via Pagelove.PUT(item, "/path") are not rolled back if a later trigger throws or errors. Body transformations via Pagelove.PUT(item, Context.request.path) are transient — they are discarded if the chain terminates with a throw or error.
Static vs dynamic properties
Any property on an outbound HTTP request action can be either a static literal or a dynamic Sessel or JavaScript expression:
<!-- Static URL -->
<meta itemprop="url" content="https://hooks.example.com/notify">
<!-- Dynamic URL (Sessel) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
"https://hooks.example.com/" + Context.request.path
</script>
</div>
<!-- Dynamic URL (JavaScript) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) { return "https://hooks.example.com/" + ctx.request.path; }
</script>
</div>
Declarative by default, imperative when needed.
Execution order
Within a single document, triggers execute in document order (top to bottom). When triggers are spread across multiple documents, documents are ordered lexicographically by path. Within each document, triggers execute in document order.
This produces a deterministic total ordering: triggers in /app/auth fire before triggers in /app/logging, and within each document, top to bottom.
Chain termination stops all subsequent triggers, including those from later documents.
Authentication example
A common pattern: a trigger fires on every request to check for a JWT and write identity claims to a transient element:
<div itemscope itemtype="https://pagelove.org/Trigger">
<meta itemprop="method" content="GET">
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<meta itemprop="method" content="DELETE">
<div itemprop="when" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.headers["Authorization"] != null
</script>
</div>
<div itemprop="action" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
// Decode JWT claims and write to transient identity element
</script>
</div>
</div>
Authentication becomes another trigger — no separate login handler required.
Error handling
Malformed Microdata: If a trigger cannot be parsed from its Microdata (missing required fields, invalid structure), it is skipped. It does not break the request.
Sessel or JavaScript runtime error: If a when/action expression throws an unhandled error (one that isn't a recognized HTTPResponse shape), the request fails with an HTML Microdata error body describing the failure. Runtime errors are surfaced, not swallowed, regardless of which language raised them.
See also
Outbound HTTP requests
An HttpRequest is an asynchronous outbound HTTP call, available as an action type in both triggers and processors. These actions are queued during trigger or processor execution and dispatched after the response is sent to the client. They are fire-and-forget — they do not block the response.
Quick example
Send a webhook notification when content under /blog/ is created or updated:
<div itemscope itemtype="https://pagelove.org/Trigger">
<meta itemprop="resource" content="/blog/*">
<meta itemprop="method" content="PUT">
<meta itemprop="method" content="POST">
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<meta itemprop="url" content="https://hooks.example.com/notify">
<meta itemprop="method" content="POST">
<meta itemprop="body" content="Blog content updated">
</div>
</div>
Properties
url (required)
Target URL for the outbound request.
<meta itemprop="url" content="https://hooks.example.com/notify">
method
HTTP method. Defaults to POST. Supported methods: GET, POST, PUT, DELETE, PATCH.
<meta itemprop="method" content="POST">
content-type
Content-Type header sent with the request. Defaults to text/html.
<meta itemprop="content-type" content="application/json">
body
Request body content.
<meta itemprop="body" content="<p>Content was updated</p>">
An additional request header, written as a Pair of key and value. Repeat
the property for as many headers as the request needs — this is how an outbound
request carries an API token, a tenant identifier, or anything else the service
you are calling expects.
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<meta itemprop="url" content="https://api.postmarkapp.com/email">
<meta itemprop="content-type" content="application/json">
<div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
<meta itemprop="key" content="X-Postmark-Server-Token">
<meta itemprop="value" content="1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d">
</div>
<div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
<meta itemprop="key" content="Accept">
<meta itemprop="value" content="application/json">
</div>
</div>
A header value can be dynamic, like any other property:
<div itemprop="header" itemscope itemtype="https://pagelove.org/Pair">
<meta itemprop="key" content="X-Request-Path">
<div itemprop="value" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.path
</script>
</div>
</div>
A header may also be written as a single line of text, split on its first colon:
<meta itemprop="header" content="X-Api-Version: 2">
Repeats are kept. The same field name may appear more than once, and
headers are sent in the order you write them.
Content-Type has one home. If a header sets Content-Type, it replaces
the content-type property rather than being sent alongside it, so the field
never appears twice.
Malformed headers are dropped. A name that is not a valid HTTP field name,
or a value containing control characters such as carriage return or line feed,
is discarded rather than sent — including when a dynamic value produces one.
The rest of the request is unaffected.
A header value written here lives in the document. An API token in a
header is stored in the document that declares it, and is readable by
anyone your authorization rules allow to read
that document. Keep documents that carry credentials off any path that grants
public read access.
Static vs dynamic properties
Any property can be either a static literal or a dynamic Sessel or JavaScript expression. This makes it possible to build request URLs and bodies from request context:
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<!-- Static method -->
<meta itemprop="method" content="POST">
<!-- Dynamic URL built from request path (Sessel) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
"https://hooks.example.com/" + Context.request.path
</script>
</div>
<!-- Dynamic body built from request data (Sessel) -->
<div itemprop="body" itemscope itemtype="https://pagelove.org/Sessel">
<script itemprop="source" type="text/sessel">
@schema Context url("https://pagelove.org/Context");
Context.request.path
</script>
</div>
</div>
The same properties accept a https://pagelove.org/JavaScript/Module typed item instead. The language is selected per property by its itemtype, so a single HttpRequest action can mix Sessel and JavaScript across different properties:
<div itemprop="action" itemscope itemtype="https://pagelove.org/HttpRequest">
<!-- Dynamic URL built from request path (JavaScript) -->
<div itemprop="url" itemscope itemtype="https://pagelove.org/JavaScript/Module">
<script type="module" itemprop="source">
export default function(ctx) { return "/api/" + ctx.request.path; }
</script>
</div>
<meta itemprop="method" content="GET">
</div>
Retry
On failure, outbound requests retry with exponential backoff:
| Attempt |
Delay before attempt |
| 1 |
immediate |
| 2 |
2 seconds |
| 3 |
4 seconds |
| 4 |
8 seconds |
| 5 |
16 seconds |
Maximum 5 attempts. The backoff delay would be capped at 30 seconds, though the schedule above never reaches that cap. After all attempts are exhausted, the request is abandoned.
Failures do not affect the client response (already sent).
Error handling
- Transport errors (connection refused, timeout): retried according to the retry schedule.
- Non-2xx responses: retried according to the retry schedule.
- After all retries exhausted: the request is abandoned. No effect on the client.
See also