Platform
← Patterns

PATTERNS FEATURE

Signed event runtime: interactive HTML

Components declare backend events with #[UiOn]; the server emits a signed manifest and one tiny runtime posts matching events to a single dispatch endpoint.

Live demo
Every keystroke is dispatched to POST /__ui/dispatch and the server patches its reply below.
#[UiOn] Signed event manifest POST /__ui/dispatch Tamper-proof dispatch Response patches

Signed event runtime — interactive HTML

This is the Interactive HTML pillar made concrete. Conventional SSR has no answer for "the page is interactive now", so teams escape into bespoke fetch glue and per-feature endpoints. Semitexa lets a component declare its backend event contract in the component, signs that contract into the page, and runs the handler server-side through one shared endpoint. The client never learns which method, class, or route handles the event — it cannot, by construction.

Declaring an event with #[UiOn]

A component method becomes the handler for a (part, event) pair by carrying #[UiOn]:

php
#[UiPart(name: 'input', uses: InputPrimitive::class, bind: 'value')]final class FieldComponent{    #[UiOn(part: 'input', event: 'change')]    public function onInputChanged(UiInteractionEvent $event): UiInteractionResult    {        return UiInteractionResult::patch(            patches: [                new UiResponsePatch(                    op: UiResponsePatch::OP_SET_TEXT,                    targetInstance: $event->instanceId,                    targetPart: null,                    targetName: 'server-ack',                    value: 'Server received: ' . (string) $event->value(),                ),            ],            debug: ['value' => $event->value(), 'instance' => $event->instanceId],        );    }}

part must reference a #[UiPart] on the same class; event is validated against /^[a-z][a-z0-9:_-]*$/. The framework checks all of this at metadata-extraction time, so a misconfiguration fails loudly at boot rather than silently at runtime.

The inert, signed manifest

On every component render the server emits a per-instance signed event manifest as inert JSON. Each entry is signed with SSR's SignedContext substrate (sc1.<base64url-claims>.<base64url-hmac>, HMAC-SHA256 over canonicalised JSON, TTL-bound, keyed by APP_SECRET). What lands in the DOM is data, never code:

html
<div data-ui-component="platform.field"     data-ui-component-instance-id="uci_4f8a…"     ui-component="field" sx-layout="stack" sx-gap="1">  …input + label + help/error…  <script type="application/json"          data-ui-event-manifest="uci_4f8a…"          data-ui-component="platform.field">    {      "v": 1,      "c": "platform.field",      "i": "uci_4f8a…",      "events": [        { "p": "input", "e": "change", "u": "value", "ctx": "sc1.<b64>.<hmac>" }      ]    }  </script></div>

What stays server-side is everything that could be abused: the handler method name (onInputChanged), the class FQCN, and any handler-resolution id. The signed ctx carries only opaque identity claims (c, i, p, e, u, iat, exp). The dispatcher resolves the real method from ctx via the registry — a client can never coerce a different method. The manifest is inert by construction: the script type is application/json, and no onclick/onchange/data-ui-handler/data-ui-event-url attribute is ever emitted.

The frontend runtime and the single endpoint

A tiny IIFE (event-runtime.js) loaded globally with defer scans the DOM for those manifest blocks, attaches one document-level capture-phase listener per distinct native event, and on a match builds a captured payload and dispatches a semitexa:ui-event:captured CustomEvent. On its own it sends nothing anywhere — no fetch, no WebSocket, no EventSource.

An opt-in transport bridge wires capture to exactly one endpoint:

js
const detach = window.SemitexaUi.transport.attach({ endpoint: '/__ui/dispatch' });

Every captured event posts the same minimal body — nothing else is ever included:

json
{  "ctx": "sc1.<base64url-claims>.<base64url-hmac>",  "dispatchId": "ui_evt_<32 hex>",  "payload": { "value": "taras@example.com" }}

There are no bespoke per-feature endpoints. Every interactive component on the page routes through POST /__ui/dispatch.

Tamper-proof by design

The dispatcher fails closed at every layer:

  • The signed ctx is the only source of (component, instance, part, event, updates) identity. Any mismatch between the signed claims and the registry fails the request (404 unknown_component / unknown_part / unknown_event, 403 updates_path_mismatch).
  • payload is treated as arbitrary user data only after the UiPayloadFieldGuard scrubs it. The guard rejects (400 forbidden_payload_field) any routing-flavoured key — handler, method, class, component, instance, part, event, updates, endpoint, route, patch, selector, html, script, and more — across camel/snake/kebab casings. You cannot smuggle a handler through the payload.
  • A dispatchId is single-use: the replay guard keys on sha256(ctx) + ':' + dispatchId and returns 409 duplicate_dispatch on an exact replay. The signed ctx itself stays reusable inside its TTL, so successive keystrokes don't force a re-sign per character — each captured event simply mints a fresh dispatchId with crypto.getRandomValues.
  • A pluggable UiInteractionAuthorizerInterface runs before the handler; a false return is 403 interaction_forbidden and the handler never runs.

Response patches: how the DOM updates

A handler returns a UiInteractionResult — a plain ack, or a small list of UiResponsePatch instructions. The allowed ops are exactly setText, setValue, setAttribute (the last gated to an aria-invalid / aria-describedby / data-state / ui-state allow-list). Every patch is validated against the signed claims' instance, so a handler can only patch its own component instance — never the document at large. The frontend applier uses textContent / element.value / setAttribute only; it never touches innerHTML, eval, or a caller-supplied selector.

Why it matters

This is interactivity without an escape hatch. There is no per-feature endpoint to secure, no client-held routing table to tamper with, and no second framework owning interaction. A component author writes a method, tags it #[UiOn], and the framework guarantees that the only thing a browser can do is ask the server — over one signed, replay-guarded, payload-scrubbed channel — to run exactly the handler the server already chose.

© Edsger W. Dijkstra:"Simplicity is prerequisite for reliability."

FieldComponent — #[UiOn] handler (adapted)Application entry point
#[UiOn(part: 'input', event: 'change')]public function onInputChanged(UiInteractionEvent $event): UiInteractionResult{    return UiInteractionResult::patch(        patches: [            new UiResponsePatch(                op: UiResponsePatch::OP_SET_TEXT,                targetInstance: $event->instanceId,                targetPart: null,                targetName: 'server-ack',                value: 'Server received: ' . (string) $event->value(),            ),        ],    );}

How it works

A #[UiOn(part, event)] method is the handler for a (part, event) pair. On render the server emits a per-instance signed event manifest as inert application/json — each entry signed with the SignedContext HMAC substrate. The method name and class FQCN never leave the server. A tiny capture-only runtime scans the manifests; an opt-in transport posts exactly { ctx, dispatchId, payload } to POST /__ui/dispatch. The dispatcher resolves the real method from the signed ctx, scrubs routing-flavoured payload keys, guards replays by (ctx, dispatchId), and returns safe DOM patches scoped to the firing instance.

Why it matters

Interactivity with no escape hatch: no per-feature endpoint to secure, no client-held routing table to tamper with, no second framework owning interaction. The only thing a browser can do is ask the server — over one signed, replay-guarded, payload-scrubbed channel — to run exactly the handler the server already chose. The live demo above shows the bare loop with no validation framing: type into the field and each keystroke is dispatched to POST /__ui/dispatch; the server runs the handler and patches back "Server received: …" — the line you see is produced by the server, proving the round-trip.