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
+15
View File
@@ -19,6 +19,7 @@ import type {
ModuleConfigurationLookup,
ModuleLifecycleStore,
ModuleStorageLookup,
ModuleRealtimeRegistry,
ModuleUiPublicationRegistry,
} from "@ekklesia/core/modules";
import { Hono } from "hono";
@@ -28,6 +29,7 @@ import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js
import { createDashboardRoutes } from "./dashboard-routes.js";
import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
import { mountModuleRealtimeRoutes } from "./module-realtime-routes.js";
import { createModuleConfigurationRoutes } from "./module-configuration-routes.js";
import { createModuleLifecycleRoutes } from "./module-lifecycle-routes.js";
import { createModuleUiInventoryRoutes } from "./module-ui-inventory.js";
@@ -50,6 +52,9 @@ export interface CreateApiAppOptions {
* caller not exercising modules must say so explicitly (an empty frozen array), never rely on an
* implicit fallback. */
readonly moduleRoutes: readonly RegisteredModuleHttpRoute[];
/** The started runtime's immutable module-owned realtime registry. Required explicitly even when
* empty, for the same reason as module routes: application composition never guesses what ran. */
readonly moduleRealtime: ModuleRealtimeRegistry;
/** The same frozen services object the caller's started module runtime already passed to every
* module's `start`/`stop` hooks (`apps/api/src/server.ts`) — reference-identical, never a fresh
* object built here, so a module's HTTP handlers and lifecycle hooks share exactly one
@@ -310,11 +315,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
}),
);
// A sibling platform-owned prefix, never nested under `/api/v1/modules/` where installed modules
// own every path. One exact route per runtime-published channel, authorized before subscription.
mountModuleRealtimeRoutes(app, {
evaluator: options.authorization.evaluator,
realtime: options.moduleRealtime,
registry: options.authorization.registry,
runtime,
});
mountModuleHttpRoutes(app, options.moduleRoutes, {
configurations: options.moduleConfiguration.snapshots,
storages: options.moduleStorage.snapshots,
evaluator: options.authorization.evaluator,
registry: options.authorization.registry,
realtime: options.moduleRealtime,
runtime,
services: options.moduleServices,
trustedOrigins: allowedOrigins,
+12 -4
View File
@@ -6,7 +6,11 @@ import {
type ModuleRuntimeServices,
type RegisteredModuleHttpRoute,
} from "@ekklesia/contracts";
import type { ModuleConfigurationLookup, ModuleStorageLookup } from "@ekklesia/core/modules";
import type {
ModuleConfigurationLookup,
ModuleRealtimeRegistry,
ModuleStorageLookup,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
@@ -53,6 +57,8 @@ export interface ModuleHttpRoutesOptions extends AuthorizationMiddlewareOptions
* its own module's storage, because that is the only entry looked up for it.
*/
readonly storages: ModuleStorageLookup;
/** The same started registry whose scoped publishers lifecycle hooks receive. */
readonly realtime: ModuleRealtimeRegistry;
}
/** Never carries the offending permission id, route path, module id, or any other configuration
@@ -195,9 +201,10 @@ function sanitizeModuleResponse(response: Response): Response {
* implicit `HEAD` handling, which re-enters this exact same middleware chain and strips the
* response body afterward — there is no separate `HEAD` registration or permission path.
* `route.handler` is invoked only after authorization succeeds, with the raw Fetch `Request` and a
* frozen context carrying exactly `moduleId`, `principal`, `params`, and `services` — never a Hono
* `Context`, authorization store, or configuration; `services.database` is the module's only
* reachable database surface, and is unreachable to anything short of a successfully authorized
* frozen context carrying exactly `config`, `moduleId`, `params`, `principal`, `realtime`,
* `services`, and `storage` — never a Hono `Context`, authorization store, raw provider, or
* deployment-wide configuration. `services.database` is the module's only reachable database
* surface, and the whole context is unreachable to anything short of a successfully authorized
* handler invocation (a denied request, a rejected trusted-origin check, and `OPTIONS` never reach
* this handler at all).
*
@@ -239,6 +246,7 @@ export function mountModuleHttpRoutes(
storage: options.storages.get(route.moduleId) ?? EMPTY_MODULE_STORAGE,
params: Object.freeze({ ...c.req.param() }),
principal: getAuthorizedPrincipal(c),
realtime: options.realtime.publisherFor(route.moduleId),
services: options.services,
});
+252
View File
@@ -0,0 +1,252 @@
import { ModuleRealtimeError, type ModuleId, type PermissionId } from "@ekklesia/contracts";
import type {
ModuleRealtimePublication,
ModuleRealtimeRegistry,
RegisteredModuleRealtimeChannel,
} from "@ekklesia/core/modules";
import type { Context, Hono } from "hono";
import {
type AuthorizationMiddlewareOptions,
requirePermission,
} from "./authorization-middleware.js";
const MODULE_REALTIME_PREFIX = "/api/v1/module-realtime";
/** Host-owned keepalive interval. Comments keep idle HTTP streams alive without entering module code,
* consuming a sequence, or becoming a browser-visible state update. */
export const MODULE_REALTIME_KEEPALIVE_MS = 15_000;
/** Forces a fresh cookie/permission evaluation periodically rather than letting an authorization
* decision live for an unbounded stream lifetime. EventSource reconnects after the clean EOF. */
export const MODULE_REALTIME_MAX_CONNECTION_AGE_MS = 5 * 60_000;
export interface ModuleRealtimeTransportTiming {
/** Test-only narrowing of the production keepalive. Production composition never sets this. */
readonly keepaliveMs?: number;
/** Test-only narrowing of the production reauthorization interval. */
readonly maxConnectionAgeMs?: number;
}
export interface ModuleRealtimeRoutesOptions
extends AuthorizationMiddlewareOptions,
ModuleRealtimeTransportTiming {
readonly realtime: ModuleRealtimeRegistry;
}
/** The server is the sole author of this deployment-private base. Module UI receives it through the
* host descriptor and never reconstructs the platform prefix or supplies its own module id. */
export function moduleRealtimeBaseUrl(moduleId: string): string {
return `${MODULE_REALTIME_PREFIX}/${encodeURIComponent(moduleId)}`;
}
function moduleRealtimeChannelUrl(moduleId: ModuleId, channel: string): string {
return `${moduleRealtimeBaseUrl(moduleId)}/${encodeURIComponent(channel)}`;
}
/** A forged runtime snapshot that could not be permission-gated is a composition failure. Carries no
* module/channel/permission value and is thrown before any route from that snapshot is usable. */
export class ModuleRealtimeConfigurationError extends Error {
constructor() {
super("A published module realtime channel references a permission that is not registered.");
this.name = "ModuleRealtimeConfigurationError";
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
function realtimeEnvelope(publication: ModuleRealtimePublication): string {
// `serializedPayload` was validated and serialized exactly once by Core. Concatenating it as the
// JSON value preserves every opaque string — including signed object-delivery URLs — byte for byte.
return `{"sequence":${publication.sequence},"payload":${publication.serializedPayload}}`;
}
function realtimeEvent(
kind: "snapshot" | "update",
publication: ModuleRealtimePublication,
): string {
// Core's compact JSON serialization contains no literal CR/LF bytes inside strings, so one `data`
// line is sufficient. Deliberately no SSE `id`: this transport does not implement Last-Event-ID
// replay, and a process-local sequence must not be mistaken for a durable cursor.
return `event: ${kind}\ndata: ${realtimeEnvelope(publication)}\n\n`;
}
function sanitizedRealtimeFailure(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store");
const code =
error instanceof ModuleRealtimeError && error.code === "REALTIME_SUBSCRIBER_LIMIT"
? "REALTIME_SUBSCRIBER_LIMIT"
: "REALTIME_UNAVAILABLE";
return c.json({ code }, 503);
}
interface CancelableDelay {
readonly promise: Promise<"keepalive">;
cancel(): void;
}
function keepaliveDelay(milliseconds: number): CancelableDelay {
let timer: ReturnType<typeof setTimeout> | undefined;
const promise = new Promise<"keepalive">((resolve) => {
timer = setTimeout(() => resolve("keepalive"), milliseconds);
});
return {
cancel: () => clearTimeout(timer),
promise,
};
}
function createRealtimeStream(
definition: RegisteredModuleRealtimeChannel,
registry: ModuleRealtimeRegistry,
keepaliveMs: number,
maxConnectionAgeMs: number,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const subscription = registry.subscribe(definition.moduleId, definition.channel);
let snapshotPending = true;
let closed = false;
let pendingRead: Promise<ModuleRealtimePublication | undefined> | undefined;
let activeDelay: CancelableDelay | undefined;
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
const close = (): void => {
if (closed) {
return;
}
closed = true;
activeDelay?.cancel();
activeDelay = undefined;
clearTimeout(expiryTimer);
expiryTimer = undefined;
subscription.close();
};
return new ReadableStream<Uint8Array>({
start(controller) {
expiryTimer = setTimeout(() => {
close();
try {
controller.close();
} catch {
// A simultaneous browser cancellation or completed pull already closed the controller.
}
}, maxConnectionAgeMs);
},
cancel: () => close(),
async pull(controller) {
if (closed) {
return;
}
try {
if (snapshotPending) {
snapshotPending = false;
controller.enqueue(encoder.encode(realtimeEvent("snapshot", subscription.snapshot)));
return;
}
// Only one Core read is ever outstanding. If a keepalive wins the race, the same read is
// retained for the next pull instead of allocating a second listener. Core itself holds only
// one newest pending publication, and this stream asks for work only when the browser-facing
// ReadableStream has demand, so a slow client cannot create an array at either layer.
pendingRead ??= subscription.read();
activeDelay = keepaliveDelay(keepaliveMs);
const result = await Promise.race([pendingRead, activeDelay.promise]);
activeDelay.cancel();
activeDelay = undefined;
if (result === "keepalive") {
controller.enqueue(encoder.encode(": keepalive\n\n"));
return;
}
pendingRead = undefined;
if (result === undefined) {
close();
controller.close();
return;
}
controller.enqueue(encoder.encode(realtimeEvent("update", result)));
} catch {
close();
try {
controller.close();
} catch {
// The browser may have canceled between the awaited read/timer and this close. Cancellation
// already performed the same idempotent subscription cleanup.
}
}
},
});
}
function mountChannel(
app: Hono,
definition: RegisteredModuleRealtimeChannel,
options: ModuleRealtimeRoutesOptions,
keepaliveMs: number,
maxConnectionAgeMs: number,
): void {
const requireChannelPermission = requirePermission(
options,
definition.permission as PermissionId,
);
app.get(
moduleRealtimeChannelUrl(definition.moduleId, definition.channel),
requireChannelPermission,
(c) => {
// Hono deliberately lets a registered GET handler answer HEAD. That is useful for finite
// resources, but unsafe for an infinite stream: the adapter discards the response body without
// necessarily canceling it, which could retain a Core subscriber forever. Refuse HEAD before
// allocating anything. Authentication/authorization has still run through the same middleware.
if (c.req.method !== "GET") {
return new Response(null, {
headers: { Allow: "GET", "Cache-Control": "no-store" },
status: 405,
});
}
let stream: ReadableStream<Uint8Array>;
try {
stream = createRealtimeStream(
definition,
options.realtime,
keepaliveMs,
maxConnectionAgeMs,
);
} catch (error) {
return sanitizedRealtimeFailure(c, error);
}
return new Response(stream, {
headers: {
"Cache-Control": "no-store",
Connection: "keep-alive",
"Content-Type": "text/event-stream; charset=utf-8",
Vary: "Origin, Cookie",
// Honored by nginx and harmless elsewhere. Other proxies must likewise disable buffering for
// this path; no proxy-specific behavior enters the module-facing contract.
"X-Accel-Buffering": "no",
},
status: 200,
});
},
);
}
/** Mounts one exact, immutable, permission-gated GET route per successfully started module channel.
* There is no dynamic module/channel lookup route and no request-controlled permission selection. */
export function mountModuleRealtimeRoutes(app: Hono, options: ModuleRealtimeRoutesOptions): void {
const keepaliveMs = options.keepaliveMs ?? MODULE_REALTIME_KEEPALIVE_MS;
const maxConnectionAgeMs = options.maxConnectionAgeMs ?? MODULE_REALTIME_MAX_CONNECTION_AGE_MS;
if (!Number.isSafeInteger(keepaliveMs) || keepaliveMs <= 0) {
throw new TypeError("The module realtime keepalive interval is invalid.");
}
if (!Number.isSafeInteger(maxConnectionAgeMs) || maxConnectionAgeMs <= 0) {
throw new TypeError("The module realtime maximum connection age is invalid.");
}
for (const definition of options.realtime.channels) {
if (!options.registry.has(definition.permission)) {
throw new ModuleRealtimeConfigurationError();
}
}
for (const definition of options.realtime.channels) {
mountChannel(app, definition, options, keepaliveMs, maxConnectionAgeMs);
}
}
+5
View File
@@ -9,6 +9,7 @@ import {
requirePermission,
} from "./authorization-middleware.js";
import { moduleApiBaseUrl } from "./module-http-routes.js";
import { moduleRealtimeBaseUrl } from "./module-realtime-routes.js";
import { MODULE_UI_ASSET_PREFIX, moduleUiArtifactUrl } from "./module-ui-routes.js";
/**
@@ -44,6 +45,9 @@ export interface HostModuleUiDescriptor {
* `ui.url` is: `/api/v1/modules/<id>` is Ekklesia's private deployment detail, and neither the
* module nor the browser host should be the one spelling it. */
readonly api: { readonly baseUrl: string };
/** Server-produced root for this module's host-neutral browser realtime facade. The browser host,
* not module UI, resolves and appends a validated local channel. */
readonly realtime: { readonly baseUrl: string };
/**
* The subset of **this module's own declared permissions** that the current principal holds,
* evaluated server-side by Core's one authorization evaluator.
@@ -115,6 +119,7 @@ async function describeModule(
api: { baseUrl: moduleApiBaseUrl(published.moduleId) },
id: published.moduleId,
permissions: await grantedPermissionIds(evaluator, principal, published.permissions),
realtime: { baseUrl: moduleRealtimeBaseUrl(published.moduleId) },
ui: { url: moduleUiArtifactUrl(published) },
version: published.version,
};
+8 -1
View File
@@ -521,6 +521,7 @@ export async function startApi(
store: authorizationStore,
},
moduleRoutes: moduleRuntime.routes,
moduleRealtime: moduleRuntime.realtime,
moduleServices: services,
// Built from the exact **eligible** set the runtime just started, never the raw discovered
// inventory, and only once startup has succeeded — so the published set is the running set. A
@@ -585,7 +586,9 @@ export async function startApi(
// would previously get an already-resolved `undefined` immediately, before the listener had
// actually drained or the pool had actually closed. Storing the promise itself means every
// caller — concurrent or sequential — awaits the exact same drain-then-close operation and
// observes it complete (or fail) exactly once. Order matters, and is now additionally
// observes it complete (or fail) exactly once. Realtime subscriptions are closed *before* HTTP
// drain begins: they are intentionally long-lived HTTP responses and would otherwise keep every
// clean shutdown open until the forced-drain deadline. Order matters, and is now additionally
// load-bearing for the module database facade: the HTTP listener is drained and closed first so
// no new or in-flight request can reach a module handler while the module runtime is stopping;
// then the module runtime stops — its stop hooks may still use `services.database`, which is
@@ -598,6 +601,10 @@ export async function startApi(
const shutdown = (): Promise<void> => {
if (!shutdownPromise) {
shutdownPromise = (async () => {
// Close long-lived streams so HTTP can drain, but leave publishers active until the drain
// completes: an in-flight module command may still commit and publish its resulting state.
// `moduleRuntime.stop()` deactivates publishers immediately before stop hooks run.
moduleRuntime.realtime.closeSubscriptions();
const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
let moduleStopError: unknown;
try {