Reacting to changes

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

Reacting to changes

Reacting to changes

Reference for Pagelove's automation primitives — triggers that fire before a request is processed, processors that fire after, and outbound HTTP requests that can be dispatched from either.

Pages in this group

See also

Processors

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:

  1. Request arrives
  2. Trigger phase — matching triggers execute
  3. Core processing (GET, PUT, POST, DELETE, etc.)
  4. Processor phase — matching processors execute in order
  5. Async dispatch — queued outbound HTTP requests fire
  6. 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

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:

  1. Request arrives
  2. Trigger phase — matching triggers execute in order
  3. Core processing (GET, PUT, POST, DELETE, etc.)
  4. Processor phase — matching processors execute
  5. Async dispatch — queued outbound HTTP requests fire
  6. 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:

<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).

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

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

See also