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:
- Evaluates the module in a fresh context.
- Reads the
defaultexport off the module namespace. - Verifies it is a function.
- Calls it with
thisbound to the in-progress instance and one positional argument,context. - 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:
- ES2020+ syntax:
const/let, arrow andfunctionforms, destructuring, spread/rest, template literals, default parameters,async/awaitat the expression level, optional chaining, nullish coalescing, numeric separators. - Standard built-in globals:
Math,Date,JSON,String,Number,Array,Object,Map,Set,RegExp,Symbol, and the other ECMAScript built-ins. - Template literals and tagged templates.
Not supported:
importstatements other thanpagelove:schema.- Host I/O globals: no
fetch, noprocess, norequire, no filesystem or environment access. - Network and timers:
setTimeoutandsetIntervalare not available.
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:
- Imports other than
pagelove:schema. Nopagelove:hostmodule. No relative or HTTP imports. - Constraints, mutation handlers, HTTP queries, and
e:-attribute expression bindings in SSPI templates. All Sessel-only at present. - Cross-property
thisaccess ondefault. A default cannot read another property whose default is also being computed on the same creation. - Browser-side execution of these bindings. The source-level contract that makes browser-side execution possible is committed; the runtime that calls it client-side is a future spec.
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
- JavaScript DOM API — the DOM subset (
document,querySelector,createElement,classList, …) a JavaScript binding can use to traverse and build markup - Property — the slot in which
default,@read,@write,@validate, and@computedare declared - Methods — the schema-level declaration a method body's
implementationslot belongs to - Method Elements — invoking a method during page composition
- Resolvers —
@readand@writepipeline semantics shared with Sessel - Schema — inheritance, which determines pipeline chain order
- Sessel — the peer binding language; both share the same slots and error envelope
- Schema definitions in HTML — the client-side counterpart that runs JavaScript modules in the browser