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
+95 -11
View File
@@ -248,7 +248,7 @@ independent.
### What the host gives you
`mount` receives exactly four members, and nothing else is available:
`mount` receives exactly five members, and nothing else is available:
| Member | What it is |
| --- | --- |
@@ -256,6 +256,7 @@ independent.
| `api` | `request(path, init)` against **your own** module API — see below |
| `principal` | Who is viewing: `ModuleUiPrincipal`, either `{ kind: "anonymous" }` or `{ kind: "authenticated", userId }` |
| `permissions` | `has(permissionId)` — a presentation hint, never a security check |
| `realtime` | `subscribe(channel, handlers)` against **your own** module's live state — see [Realtime state](#realtime-state) |
Navigation, host theme and design tokens, module configuration, organization context, and
diagnostics reporting are **not** in the contract yet. They need designs that do not exist, and a
@@ -579,10 +580,12 @@ export default defineModuleRuntime({
ID and version are intentional build-artifact identity markers and must match the manifest. Core
rejects missing, duplicate, or undeclared capability registrations. A module may resolve only its
own capabilities and the capability dependencies declared in its manifest. The `register` context
is deliberately limited to `moduleId`, capability access, and the HTTP route registrar (below) —
never `services`. `start` and `stop` share one context shape and both additionally receive
`services` — the same frozen `ModuleRuntimeServices` object for the lifetime of one running API,
currently exposing only `database` — see [Database access](#database-access).
contains `moduleId`, capability access, the frozen configuration snapshot, and the HTTP and realtime
declaration registrars — never live `services` or storage. `start` and `stop` share one context shape:
capability access, configuration, `services`, storage, and a module-scoped realtime publisher. The
same scoped objects are also supplied to the module's HTTP handlers; see
[Database access](#database-access), [Object storage](#object-storage), and
[Realtime state](#realtime-state).
Entrypoint imports must have no top-level side effects. Modules are trusted backend plugins, and ESM
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
@@ -734,12 +737,12 @@ method and path (or two paths that are identical once every parameter is treated
placeholder, such as `/events/:eventId` and `/events/:id` under `GET`) fails as a duplicate route.
The handler receives the standard `Request` plus a context of exactly `moduleId`, the caller's
already-authorized `EkklesiaPrincipal`, the immutable decoded path parameters, and `services` — the
same frozen `ModuleRuntimeServices` object the module's own `start`/`stop` hooks receive, currently
exposing only `database` (see [Database access](#database-access)) — never an authorization store,
grant/platform-owner state, storage, jobs, configuration, secrets, a framework-specific request
context, or a raw Better Auth session. The full query string remains available through
`Request.url`; there is no separate query or body abstraction.
already-authorized `EkklesiaPrincipal`, immutable decoded path parameters, `services`, the module's
frozen configuration snapshot, scoped storage, and scoped realtime publisher. It never receives an
authorization store, grant/platform-owner state, jobs, a framework-specific request context, or a
raw Better Auth session. Secret configuration exists only when the module explicitly declared that
key and the deployment supplied it; Core never serializes it. The full query string remains
available through `Request.url`; there is no separate query or body abstraction.
Core defines, validates, and collects routes into `StartedModuleRuntime`'s immutable route
snapshot — it never invokes a handler itself. `apps/api` mounts that exact snapshot into production
@@ -756,6 +759,87 @@ deliberately conservative, since even a public-baseline handler still receives a
principal and may personalize its response. See [API](../development/api.md#module-http-routes) for
the full production mounting, enforcement, and error-response contract.
## Realtime state
Realtime is a small state-delivery contract, not a broker. Declare each module-local channel during
`register`, using one permission owned by the same module:
```ts
export default defineModuleRuntime({
id: "announcements",
version: "0.1.0",
register({ realtime }) {
realtime.channel({
channel: "state",
permission: "announcements.slideshow.view",
});
},
start({ realtime }) {
realtime.publish("state", {
mode: "on",
currentSlide: 0,
previousSlide: -1,
slides: [],
});
},
});
```
Channel names are flat lowercase kebab case, at most 48 characters. There are no global topics,
wildcards, rooms, broker addresses, or module-id arguments: Core closes the registrar and publisher
over the module identity, so a cooperating module cannot accidentally publish into another module's
channel. A module may declare at most 16 channels. Each payload must be plain, finite JSON no larger
than 64 KiB after UTF-8 serialization; functions, class instances, sparse arrays, accessors,
non-finite numbers, cycles, and `toJSON` conversion are rejected with fixed errors.
Every declared channel must receive an initial publication before that module's `start` completes.
Core captures that latest state atomically when a subscriber is installed, then supplies later
publications with a monotonically increasing per-channel sequence. This avoids a separate
GET-then-subscribe race. State is ephemeral and best effort: Core keeps no history, restart resets
the sequence, and reconnect receives a fresh authoritative snapshot instead of replay. A slow client
never blocks `publish`; each subscription retains only the newest pending publication, so
intermediate updates may be coalesced. There are at most 100 subscribers per channel.
In browser UI, subscribe through the host context rather than opening a transport yourself:
```ts
const subscription = context.realtime.subscribe<SlideshowState>("state", {
onSnapshot(state, sequence) {
replaceSlideshow(state, sequence);
},
onUpdate(state, sequence) {
replaceSlideshow(state, sequence);
},
onStatus(status) {
showConnectionStatus(status);
},
onError(error) {
showSafeRealtimeError(error.code);
},
});
return () => subscription.close();
```
The public UI vocabulary contains no SSE, WebSocket, Socket.IO, URL, cookie, token, origin, or retry
setting. Ekklesia currently implements it with credentialed Server-Sent Events and native browser
reconnect; a standalone host can implement the same interface differently. The host-generated
endpoint is always scoped to this module, and the server performs the channel's normal permission
check before subscribing. Ekklesia closes each stream after five minutes so reconnect rechecks the
session and permission. Client permission hints remain presentation-only.
On shutdown, Core refuses new subscribers and closes existing subscriptions before HTTP drain, while
letting requests already in flight finish publishing. Reverse-order stop then disables each module's
publisher immediately before that module's own stop hook. Call `close()` during UI unmount as well;
it is synchronous and idempotent. Direct object-delivery URLs are opaque strings in a payload and are
forwarded unchanged.
Realtime does not make those bearer URLs live longer, refresh them, or authorize them. Public
display/device identity, durable replay, multi-node fanout, client-to-server commands, and
display/kiosk hosting remain future work. See
[ADR 0032](../architecture/decisions/0032-native-module-realtime-transport.md).
## Drizzle schema definition
A module with tables keeps the generator-facing declarations in `src/schema/tables.ts`: