feat: add native module realtime transport
CI / verify (pull_request) Successful in 37s
CI / infrastructure-smoke (pull_request) Successful in 13s
CI / migration-smoke (pull_request) Successful in 1m7s

This commit is contained in:
ekklesia
2026-08-23 18:47:18 -05:00
parent fcc531a39c
commit fc6c8045c4
53 changed files with 3959 additions and 108 deletions
+44 -11
View File
@@ -70,7 +70,7 @@ Every module-facing contract in this platform is written so that **full Ekklesia
thing that can satisfy it**. The intended second implementation is a reusable single-module
standalone host — conceptually `@ekklesia/standalone-host` — which loads exactly one module and
supplies the same host contracts Ekklesia does: configuration, secrets, object storage, HTTP, module
lifecycle, and later realtime and display/UI hosting.
lifecycle, and realtime, with display/kiosk hosting still later.
**No such package exists yet, and this pass does not create one.** It is recorded here because it is
what several contract decisions are *for*, and because a change that quietly makes it unimplementable
@@ -84,6 +84,12 @@ the scoped facade, binding resolution, delivery URLs and their bounds, lifecycle
host-neutral, and a standalone host implements one interface to get all of it, including direct
browser delivery ([ADR 0031](decisions/0031-direct-object-delivery.md)).
Realtime is the second worked example. A module declares local state channels and publishes opaque
JSON through scoped contracts; its browser UI subscribes through another scoped contract. Neither
side names SSE, EventSource, a platform URL, an origin, a cookie, or a module namespace. Ekklesia's
host adapter uses permission-gated SSE, while a standalone host may choose another concrete
transport without changing the module. See [ADR 0032](decisions/0032-native-module-realtime-transport.md).
### Storage servers are shared infrastructure, never per-module
- A module hosted under Ekklesia uses **Ekklesia's** storage service, through its own bindings.
@@ -280,17 +286,17 @@ by its own esbuild script, that discovers installed module UIs and mounts them.
The missing piece was discovery. `GET /api/v1/module-ui` — the collection root of the same prefix the
artifact route already owns — returns, per publishable module, its id and version, the server-produced
artifact URL, the server-produced root of the module's own API, and the subset of that module's
declared permissions the caller actually holds. Nothing else: no filesystem paths, and no display or
navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's presentation
configuration. The module set is the publication registry, never a rescan, so the inventory and the
artifact route cannot disagree. It requires `core.modules.ui.read` (baseline `authenticated`) because
it is precisely the enumeration ADR 0025 withheld from the anonymous artifact route.
artifact URL, the server-produced roots of the module's own HTTP and realtime APIs, and the subset of
that module's declared permissions the caller actually holds. Nothing else: no filesystem paths, and
no display or navigation metadata, since ADR 0021 keeps Dashboard grouping an administrator's
presentation configuration. The module set is the publication registry, never a rescan, so the
inventory and artifact route cannot disagree. It requires `core.modules.ui.read` (baseline
`authenticated`) because it is precisely the enumeration ADR 0025 withheld from the anonymous
artifact route. ADR 0032 later extended ADR 0026's original descriptor with the realtime root.
Both URLs being server-produced is the load-bearing part. `moduleUiArtifactUrl` and the newly exported
`moduleApiBaseUrl` are each the single construction of their URL shape, so the browser is a consumer
of both — it never rebuilds `/api/v1/modules/<id>`, the prefix ADR 0022 forbids a module UI from
spelling, and neither prefix is pinned by having a second author.
All three URLs being server-produced is load-bearing. `moduleUiArtifactUrl`, `moduleApiBaseUrl`, and
`moduleRealtimeBaseUrl` are each the single construction of their URL shape, so the browser consumes
them rather than rebuilding a platform module prefix, and none is pinned by having a second author.
The shell builds a `ModuleUiHostContext` entirely from server-derived data, resolves a module's
relative API calls onto its own API root (refusing anything that escapes it), and hands the artifact
@@ -438,3 +444,30 @@ configures nothing. A reverse proxy in front of storage must pass the original `
unchanged; it may terminate TLS, because the scheme is not signed. Streaming through a module's own
route remains available for responses that must be computed or authorized per byte, and signed
**write** URLs are deliberately not offered.
Phase 7A pass 5 adds native module realtime
([ADR 0032](decisions/0032-native-module-realtime-transport.md)). Registration declares a bounded,
module-local channel and one permission owned by that module; live runtime and HTTP contexts receive
only that module's publisher. Every channel must publish an authoritative initial state before
startup completes. Core serializes opaque JSON once, assigns a process-local per-channel sequence,
and atomically installs each subscriber with the latest snapshot, eliminating a GET-then-subscribe
race. It retains only the latest channel state and one coalescing pending update per client, so a
slow browser never blocks module work or creates an unbounded queue. There is no history, replay,
broker namespace, or durable state.
Ekklesia's concrete host transport is one-way Server-Sent Events because the demonstrated
Announcements consumer sends complete state server-to-browser while browser commands already fit
authenticated module HTTP. The channel permission is evaluated through the existing cookie
principal and authorization system before allocation; the host closes streams after five minutes so
reconnect re-evaluates that authority. Native EventSource owns recoverable reconnects, each of which
starts with a fresh snapshot. The wire sends 15-second comment keepalives, disables common proxy
buffering, carries no query token or replay id, and never exposes transport objects through the
module UI SDK.
Shutdown closes long-lived subscriptions before finite HTTP drain, while leaving publishers usable
for requests already in flight. Reverse-order stop then deactivates each module's publisher
immediately before that module's own stop hook, so dependencies remain operational while dependents
clean up and captured publishers cannot outlive their module. Direct object-delivery URLs remain
opaque payload strings and are never re-signed per publication or subscriber. MQTT remains an
external-control concern for a future Companion boundary, not an internal module bus. Display/kiosk
hosting and its public/device principal are explicitly not part of this pass.