JavaScript in schemas

JavaScript in schemas lets you embed ES module source in a property's default, @read, @write, @validate, or @computed, and in a Method's implementation. The server evaluates the module on every write or read in an isolated context, sharing the request budget with Sessel. The same source is also browser-runnable, so a binding may also execute client-side under the future browser-side schema runtime.

When to reach for it

Reach for a JavaScript binding when the logic is more naturally expressed as a small ES module than a Sessel expression — for example, when reusing a familiar JavaScript idiom for string normalisation, when the same source needs to run unchanged on the server and (later) in the browser, or when the binding needs to construct schema instances via the pagelove:schema import.

JavaScript authors the default slot, the three pipeline slots (@read, @write, @validate), the schema-level @validate slot, computed properties (@computed), and method bodies (implementation). Other expression slots accept Sessel only at present: constraints, mutation handlers, HTTP queries, and e:-attribute expression bindings in SSPI templates. Group constraints and shape constraints are not expressions in either language — they are declarative Microdata and CSS selector forms.

Shape

A JavaScript binding is a typed Microdata item with itemtype="https://pagelove.org/JavaScript/Module" placed in a property's default, @read, @write, or @validate slot. Its source child carries the ES module text, conventionally inside a <script type="module"> so the same string is also browser-runnable.

<div itemprop="default" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default () => String(Math.floor(100 + Math.random() * 500));
  </script>
</div>

The dispatcher selects the language by the itemtype URL of the typed item, never by <script type>. The <script> tag is a browser-execution affordance, not a server-data signal.

The export default contract

The module must have a default export, and that export must be a function — arrow or function form. The server:

  1. Evaluates the module in a fresh context.
  2. Reads the default export off the module namespace.
  3. Verifies it is a function.
  4. Calls it with this bound to the in-progress instance and one positional argument, context.
  5. Converts the return value back to a dombase value.

ES modules always run in strict mode, so arrow functions do not bind their own this. A binding that needs to read this must use a function form:

export default function() { return this.firstname; }

A module with no default export, or a default export that is not a function, fails with the shape variant.

Slot semantics

Slot First argument / this Return value When it fires
default this is the in-progress instance; first positional argument is context The default value to inject when the property is absent At write time, before validation
@read First argument is the property value flowing through the chain The transformed value, passed to the next stage After fetch from storage, before the response is sent
@write First argument is the property value flowing through the chain The transformed value, passed to the next stage Before validation, before storage
@validate (property-level) First argument is the property's current value after defaults Truthy to accept; falsy or thrown to reject At persistence (PUT), after defaults, before the required-property check
@validate (schema-level) this is the serialized instance (an element HTML string); the positional context argument is null for this slot Truthy to accept; falsy or thrown to reject At persistence (PUT), after cardinality, type, property-level @validate, and group constraints have all passed

For property-level @read, @write, and @validate, the first argument is the pipeline value, not the owning instance. For default, schema-level @validate, and @computed, this is the instance — so these must use a function form to read this (an arrow function in a strict-mode ES module does not bind its own this). Method bodies also bind this to the owning instance (the Class constructor for static methods) — see Method bodies below.

Computed properties (@computed)

An @computed slot makes a property's value always derived from a binding, with no value stored for it. The binding runs on every read; assigning to the property is rejected, since there is nothing to assign to.

<div itemprop="@computed" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default function() { return this.firstName + " " + this.lastName; }
  </script>
</div>

this is bound to the owning instance (a function form is required to read it). If a property declares both @computed and @read, @computed wins and @read never runs for it. A legacy, Sessel-only shorthand — an @read slot whose typed item is the bare https://pagelove.org/Sessel type — is also still recognized as a computed property for backward compatibility; new schemas should prefer @computed. See Property for the full behavior, including the legacy form.

Method bodies

A Method's implementation slot accepts a JavaScript module the same way a property binding does:

<div itemprop="property" itemscope itemtype="https://pagelove.org/Method">
  <meta itemprop="name" content="greet">
  <div itemprop="parameter" itemscope itemtype="https://pagelove.org/Parameter">
    <meta itemprop="name" content="greeting">
  </div>
  <div itemprop="implementation" itemscope itemtype="https://pagelove.org/JavaScript/Module">
    <script itemprop="source" type="module">
      export default function(greeting) { return greeting + ", " + this.name + "!"; }
    </script>
  </div>
</div>

this is bound to the calling object — the schema instance for an instance method, or the Class constructor for a static method. Parameters are forwarded positionally, in the method's declared parameter order (Sessel implementations instead read each parameter by name). A method body may be async; the engine drives its returned Promise to settlement the same way an async default export settles.

Method bodies also dispatch when a schema method is invoked through page composition — a namespaced method element (<ns:method>) or an ns:method="…" attribute. There, this is the host element the method was dispatched on (read-only), arguments bind by name from the dispatch element's attributes (rather than positionally), an ambient read-only document is available, and the return value is spliced into the page. See Method Elements for the composition-side contract, and Methods for the schema-level declaration.

A method body dispatches in a third context too: when the method is called from another server-side expression — a trigger's action, a processor, a mutation handler, or a nested method call made by another method (whether that caller is written in Sessel or JavaScript). Here the call behaves like a direct instance.method(...) call: arguments are forwarded positionally in declared order, this is the receiver, and the return value is the method's value. A read-only document global is available only when the receiver is an element; a typed schema instance receives no document global and operates on this.

In this third context the method also has full access to the shared Context: it can read ambient entries such as Context.request, and any Context.foo = … assignment it makes is written back to the pass's Context — the same Context a Sessel method mutates in the same place — so a processor or trigger that reads Context afterwards observes the change. (Reading per-user request identity marks the response as user-specific, just as it does in composition.)

Whichever way a method is dispatched — through page composition (<ns:method> splicing its return into the page) or from another server-side expression — when it returns a typed schema instance it builds, that instance is treated exactly as new SchemaName { … }: its schema's @default values are filled in for any properties left unset, and it is validated against the schema (missing a required property with no default, or a wrong type, raises an error). So a method can construct and return an instance and rely on the same defaults and validation Sessel construction gives — you don't have to set defaulted properties yourself.

this and context for default

this — the in-progress instance

this is a read-only plain JS object view of the element being created. It exposes the schema-declared property keys that are already set — fields supplied by the client on the write, or fields set earlier in construction. Mutations to this inside a default expression are not preserved.

A default on one property may not read the value of another property whose default is also being computed in the same creation. Reading such a field returns undefined. Cross-property default ordering is not part of the public schema contract.

context — request and host information

context is a plain JS object passed as the first positional argument. The first cut carries a minimal set of well-known keys:

Key Type Meaning
document_html String The serialised source document being written, when one is available.

Authors must read only the keys they need and not enumerate or rely on the exact set. Future revisions may add keys.

The document global

Separately from context, a default expression also has access to a document global — a live view of the in-progress element that supports the same DOM methods as the JavaScript DOM API, including mutations (document.querySelector(...), appending or removing elements, and so on). Any changes made through document are kept: they're applied to the element being created.

Other binding kinds that also receive a document global (for example @write bindings) get a read-only version — calling a mutating method throws.

Chaining order

When a property has pipeline bindings at multiple levels of a schema inheritance chain, stages are chained:

Hook Order Combination rule
@read Ancestor-first (root → leaf) The output of one stage is the input to the next.
@write Child-first (leaf → root) The output of one stage is the input to the next.
@validate Most-derived only The child's validator completely overrides the parent's; no chaining.

Pipeline chains can mix Sessel and JavaScript freely across the inheritance hierarchy. A parent's Sessel stage feeds a child's JavaScript stage, and vice versa, in the order above.

Importing schemas

A schema import uses the schema URL as the module specifier, with a fixed type import attribute of "https://pagelove.org/Schema":

import Note from "https://moodboard.pagelove.org/Note" with { type: "https://pagelove.org/Schema" };

The schema URL is an identifier, not a fetchable URL. The module loader resolves the import at evaluation time by looking up the schema in the host's schema cache. An unknown schema fails with the unknown-schema variant.

Each import yields a JavaScript class with the schema's short name (the last path segment of the itemtype URL). The class is synthesised at evaluation time and supports construction, property access, instanceof, and method dispatch.

All other import specifiers (relative paths, bare specifiers, HTTP URLs) are rejected with the import-not-allowed variant. There is no pagelove:host module.

Working with imported classes

Operation Behaviour
new Foo({...}) Constructor accepts a plain object whose keys are schema-declared property names. Undeclared properties are stored on the instance but do not trigger pipelines.
instance.prop Declared properties are readable and writable through ordinary getter/setter pairs backed by a Symbol-keyed store.
instance instanceof Bar Walks the prototype chain. If Foo extends Bar, new Foo() instanceof Bar is true.
instance.method() Schema-declared methods appear on the class prototype. Method bodies execute their declared implementation — Sessel or JavaScript — via the cross-language call path.

Schemas inheriting from https://pagelove.org/Map produce classes that extend Map, with keys, values, entries, and merge exposed. Undeclared property reads fall through to the Map backing store.

Supported language features

Supported:

Not supported:

Math.random() and Date.now() are permitted and return legitimately different values on the server and the browser. Bindings commit logic over inputs, not clock or RNG agreement; the server's value is what gets stored.

Resource limits

Every evaluation runs under a per-request budget. In production the budget is read from the host's https://pagelove.org/TransactionBudget Microdata, shared with Sessel. A request touching one Sessel binding and one JavaScript binding sees a single shared budget, not two competing ones.

Limit Source in production Default in unbudgeted contexts Exhaustion error
Memory Remaining memory budget on the request 16 MB per context out-of-memory
Stack Per-thread default 256 KB per context threw (typically InternalError: stack overflow)
Time Remaining time budget on the request Per-thread default timeout
Ops Charged in batches against the request budget on every periodic interrupt Not charged for short, straight-line code Surfaces via the request budget

Errors

Every failure is rendered to the client as an HTML Microdata document of type https://pagelove.org/BindingFailure, nested inside the https://pagelove.org/SchemaViolation envelope.

Variant Trigger
parse The module source fails to parse as an ES module.
shape The module parsed but has no default export, or the default export is not a function.
threw The default function ran and threw. The message and (when available) stack are carried in the failure item.
timeout The evaluation exceeded the time budget.
out-of-memory The evaluation exceeded the memory budget.
marshal An input value (a field of this, or a key of context) cannot be converted to a JavaScript value.
return-type The return value cannot be converted back into a dombase value. Functions, symbols, cyclic objects, and objects nested deeper than 64 levels all fall here.
import-not-allowed The module source contains an import specifier other than pagelove:schema.
unknown-schema A pagelove:schema import referenced a schema itemtype URL not found in the host's schema cache.
unknown-language The slot carried a typed item whose itemtype URL matches no registered language.

The rendered failure carries at minimum:

<div itemscope itemtype="https://pagelove.org/BindingFailure">
  <meta itemprop="language" content="https://pagelove.org/JavaScript/Module">
  <meta itemprop="variant" content="threw">
  <p itemprop="message">TypeError: Cannot read properties of undefined (reading 'foo')</p>
  <pre itemprop="stack">    at default (eval:2:17)</pre>
</div>

The stack property is present for threw when the engine provided one and absent otherwise. Sessel bindings produce structurally identical BindingFailure items with a different language value.

Tracing

Every JavaScript evaluation produces an OpenTelemetry-compatible span named dombase_js.evaluate:

Attribute Source
schema.itemtype The schema this binding belongs to, or "(none)".
binding.kind "default", "read", "write", "validate", "computed", "schema-@validate", or "method". (Other binding contexts — triggers, processors, WebDAV auth — use their own kind values; not covered here.)
binding.source_hash Short hex-encoded fingerprint of the source.
binding.source_len Length of the source string in bytes.
outcome "ok" on success, or the failure variant name.
budget.memory_delta_bytes Bytes allocated during evaluation.
budget.time_nanos Wall-clock nanoseconds for the evaluation.

A child span dombase_js.compile wraps the compile step on the first use of a given source on a thread. Cross-language Sessel calls from within JavaScript nest as children of the dombase_js.evaluate span.

Marshalling

Values flow between dombase and JavaScript at every binding boundary:

Direction Behaviour
dombase → JS (inputs to this, context, pipeline argument) Each value is converted to its JavaScript equivalent. Values that cannot be converted fail the binding with marshal.
JS → dombase (return value) The value is converted back to a dombase value. Functions, symbols, cyclic objects, and objects nested deeper than 64 levels fail with return-type.
Date round-trips A dombase temporal value passed into this/context, or a JS Date returned from a binding, currently unmarshalls as a plain Map of the Date object's own properties rather than as a dombase temporal value. Bindings that need a temporal default should return an ISO-8601 string.
this traversal this is a placeholder object seeded only with the explicitly set schema-declared fields the marshaller currently understands. Rich traversal of the in-progress element's DOM subtree is not yet wired through.

First-cut exclusions

The following are out of scope for the current cut. Each is either a deferred feature or an interim limitation that will be lifted without breaking existing bindings:

Examples

Random coordinate default

<div itemprop="default" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default () => String(Math.floor(100 + Math.random() * 500));
  </script>
</div>

Timestamp default reading context

<div itemprop="default" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default function(context) {
      return `created at ${new Date().toISOString()}`;
    }
  </script>
</div>

Normalise on write

<div itemprop="@write" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default (val) => typeof val === 'string' ? val.trim().toLowerCase() : val;
  </script>
</div>

Format on read

<div itemprop="@read" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default (val) => typeof val === 'string' ? val.toUpperCase() : val;
  </script>
</div>

Validate at persistence

<div itemprop="@validate" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default (val) => typeof val === 'string' && val.includes('@');
  </script>
</div>

Default that constructs a schema instance

<div itemprop="default" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    import Note from "https://moodboard.pagelove.org/Note" with { type: "https://pagelove.org/Schema" };
    export default () => new Note({ title: "untitled", x: 0, y: 0 });
  </script>
</div>

Computed property combining two fields

<div itemprop="@computed" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default function() { return this.firstName + " " + this.lastName; }
  </script>
</div>

Method body using a parameter and this

<div itemprop="implementation" itemscope itemtype="https://pagelove.org/JavaScript/Module">
  <script itemprop="source" type="module">
    export default function(greeting) { return greeting + ", " + this.name + "!"; }
  </script>
</div>

See also