feat: add native module realtime transport
This commit is contained in:
@@ -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`:
|
||||
|
||||
Reference in New Issue
Block a user