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.
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]:
#[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:
<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:
const detach = window.SemitexaUi.transport.attach({ endpoint: '/__ui/dispatch' });Every captured event posts the same minimal body — nothing else is ever included:
{ "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
ctxis 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). payloadis treated as arbitrary user data only after theUiPayloadFieldGuardscrubs 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
dispatchIdis single-use: the replay guard keys onsha256(ctx) + ':' + dispatchIdand returns409 duplicate_dispatchon an exact replay. The signedctxitself stays reusable inside its TTL, so successive keystrokes don't force a re-sign per character — each captured event simply mints a freshdispatchIdwithcrypto.getRandomValues. - A pluggable
UiInteractionAuthorizerInterfaceruns before the handler; afalsereturn is403 interaction_forbiddenand 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.
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.