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
+27 -3
View File
@@ -14,8 +14,17 @@ export interface EkklesiaHostConfig {
/** Injected so tests can drive the real protocol against a real in-process app without a socket.
* Production composition leaves this unset and the platform `fetch` is used. */
readonly fetch?: typeof fetch;
/** Test/composition seam for the browser's native live-event client. Production leaves this
* unset and the platform `EventSource` constructor is used. This never enters module UI. */
readonly eventSourceFactory?: HostEventSourceFactory;
}
export interface HostEventSourceOptions {
readonly withCredentials: true;
}
export type HostEventSourceFactory = (url: string, options: HostEventSourceOptions) => EventSource;
/** Root-relative platform paths. The shell never builds a module UI artifact URL or a module API
* URL — the server produces both and hands them over in the navigation view — so these two are the
* only URLs it knows, and they describe the platform itself rather than any module.
@@ -35,13 +44,16 @@ export type HostProtocolErrorCode =
/** The API could not be reached, or failed. */
| "HOST_REQUEST_FAILED"
/** A module UI asked for something outside its own API root. */
| "MODULE_API_PATH_INVALID";
| "MODULE_API_PATH_INVALID"
/** A module UI asked for a live channel outside the public local-channel grammar. */
| "MODULE_REALTIME_CHANNEL_INVALID";
const errorMessages: Readonly<Record<HostProtocolErrorCode, string>> = Object.freeze({
HOST_REQUEST_FAILED: "Ekklesia could not be reached.",
HOST_RESPONSE_INVALID: "Ekklesia returned a response this host does not understand.",
HOST_UNAUTHENTICATED: "You need to sign in to Ekklesia to use this page.",
MODULE_API_PATH_INVALID: "A module asked for a path outside its own API.",
MODULE_REALTIME_CHANNEL_INVALID: "A module asked for an invalid live channel.",
});
/**
@@ -77,6 +89,8 @@ export interface HostModuleUiDescriptor {
readonly uiUrl: string;
/** Server-produced root of this module's own HTTP API. */
readonly apiBaseUrl: string;
/** Server-produced root for this module's own live channels. */
readonly realtimeBaseUrl: string;
/** Granted ids only, already evaluated server-side. Presentation hints, never authority. */
readonly permissions: readonly string[];
}
@@ -94,7 +108,7 @@ function nestedString(value: unknown, key: string): string | undefined {
}
/**
* Both URLs in a descriptor must be **root-relative** paths, exactly as the server produces them.
* Every URL in a descriptor must be a **root-relative** path, exactly as the server produces it.
*
* Enforced rather than assumed. The shell resolves each against the configured API origin, so an
* absolute or protocol-relative value arriving here would silently redirect a module's script import
@@ -105,7 +119,14 @@ function nestedString(value: unknown, key: string): string | undefined {
*/
function rootRelativePath(value: unknown, key: string): string | undefined {
const candidate = nestedString(value, key);
if (candidate === undefined || !candidate.startsWith("/") || candidate.startsWith("//")) {
if (
candidate === undefined ||
!candidate.startsWith("/") ||
candidate.startsWith("//") ||
candidate.includes("\\") ||
candidate.includes("?") ||
candidate.includes("#")
) {
return undefined;
}
return candidate;
@@ -122,12 +143,14 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
const version = nestedString(entry, "version");
const uiUrl = isRecord(entry) ? rootRelativePath(entry.ui, "url") : undefined;
const apiBaseUrl = isRecord(entry) ? rootRelativePath(entry.api, "baseUrl") : undefined;
const realtimeBaseUrl = isRecord(entry) ? rootRelativePath(entry.realtime, "baseUrl") : undefined;
const permissions = isRecord(entry) ? entry.permissions : undefined;
if (
id === undefined ||
version === undefined ||
uiUrl === undefined ||
apiBaseUrl === undefined ||
realtimeBaseUrl === undefined ||
!Array.isArray(permissions) ||
permissions.some((permission) => typeof permission !== "string")
) {
@@ -137,6 +160,7 @@ function parseDescriptor(entry: unknown): HostModuleUiDescriptor {
apiBaseUrl,
id,
permissions: Object.freeze([...(permissions as string[])]),
realtimeBaseUrl,
uiUrl,
version,
});
+2
View File
@@ -16,6 +16,8 @@ export {
type DashboardNavigationGroup,
type EkklesiaHostConfig,
type HostBootstrap,
type HostEventSourceFactory,
type HostEventSourceOptions,
type HostModuleUiDescriptor,
HostProtocolError,
type HostProtocolErrorCode,
+189 -4
View File
@@ -2,6 +2,11 @@ import type {
ModuleUiApiClient,
ModuleUiHostContext,
ModuleUiPrincipal,
ModuleUiRealtimeClient,
ModuleUiRealtimeError,
ModuleUiRealtimeErrorCode,
ModuleUiRealtimeHandlers,
ModuleUiRealtimeStatus,
} from "@ekklesia/module-ui-sdk";
import {
type EkklesiaHostConfig,
@@ -11,6 +16,185 @@ import {
} from "./host-protocol.js";
const ABSOLUTE_URL_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/u;
const REALTIME_CHANNEL_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
const REALTIME_CHANNEL_MAX_LENGTH = 48;
const realtimeErrors: Readonly<Record<ModuleUiRealtimeErrorCode, ModuleUiRealtimeError>> =
Object.freeze({
REALTIME_CONNECTION_FAILED: Object.freeze({
code: "REALTIME_CONNECTION_FAILED",
message: "The live connection could not be maintained.",
}),
REALTIME_MESSAGE_INVALID: Object.freeze({
code: "REALTIME_MESSAGE_INVALID",
message: "The host received an invalid live update.",
}),
});
interface RealtimeEnvelope {
readonly payload: unknown;
readonly sequence: number;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Strictly parses the host transport's private envelope. The module sees neither this envelope nor
* the event implementation; it receives only `payload` and `sequence`. */
function parseRealtimeEnvelope(data: unknown): RealtimeEnvelope | undefined {
if (typeof data !== "string") {
return undefined;
}
let value: unknown;
try {
value = JSON.parse(data) as unknown;
} catch {
return undefined;
}
if (!isRecord(value)) {
return undefined;
}
const keys = Object.keys(value).sort();
if (
keys.length !== 2 ||
keys[0] !== "payload" ||
keys[1] !== "sequence" ||
!Number.isSafeInteger(value.sequence) ||
(value.sequence as number) <= 0
) {
return undefined;
}
return { payload: value.payload, sequence: value.sequence as number };
}
function realtimeChannelUrl(
config: EkklesiaHostConfig,
descriptor: HostModuleUiDescriptor,
channel: string,
): string {
const root = new URL(resolveApiUrl(config, descriptor.realtimeBaseUrl));
const rootPath = root.pathname.replace(/\/+$/u, "");
return new URL(`${rootPath}/${channel}`, root).href;
}
function createModuleRealtimeClient(
config: EkklesiaHostConfig,
descriptor: HostModuleUiDescriptor,
): ModuleUiRealtimeClient {
return Object.freeze({
subscribe<State>(channel: string, handlers: ModuleUiRealtimeHandlers<State>) {
if (channel.length > REALTIME_CHANNEL_MAX_LENGTH || !REALTIME_CHANNEL_PATTERN.test(channel)) {
throw new HostProtocolError("MODULE_REALTIME_CHANNEL_INVALID");
}
let closed = false;
let lastStatus: ModuleUiRealtimeStatus | undefined;
let awaitingSnapshot = true;
let lastSequence = 0;
const reportStatus = (status: ModuleUiRealtimeStatus): void => {
if (lastStatus !== status) {
lastStatus = status;
handlers.onStatus?.(status);
}
};
const reportError = (code: ModuleUiRealtimeErrorCode): void => {
handlers.onError?.(realtimeErrors[code]);
};
reportStatus("connecting");
const factory =
config.eventSourceFactory ??
((url: string, options: { readonly withCredentials: true }) =>
new EventSource(url, options));
let source: EventSource;
try {
source = factory(realtimeChannelUrl(config, descriptor, channel), {
withCredentials: true,
});
} catch {
reportError("REALTIME_CONNECTION_FAILED");
closed = true;
reportStatus("closed");
return Object.freeze({ close: (): void => undefined });
}
function onOpen(): void {
if (!closed) {
awaitingSnapshot = true;
reportStatus("connected");
}
}
function onConnectionError(): void {
if (closed) {
return;
}
reportError("REALTIME_CONNECTION_FAILED");
if (source.readyState === source.CLOSED) {
terminate();
return;
}
// EventSource itself retries established transport disconnects. The host reports that native
// transition and waits for the next `open`; it never creates a competing retry loop.
awaitingSnapshot = true;
reportStatus("reconnecting");
}
function onDelivery(kind: "snapshot" | "update", event: Event): void {
if (closed) {
return;
}
const envelope = parseRealtimeEnvelope((event as MessageEvent<unknown>).data);
const invalidOrder =
envelope !== undefined &&
(kind === "snapshot"
? !awaitingSnapshot
: awaitingSnapshot || envelope.sequence <= lastSequence);
if (envelope === undefined || invalidOrder) {
reportError("REALTIME_MESSAGE_INVALID");
return;
}
lastSequence = envelope.sequence;
awaitingSnapshot = false;
if (kind === "snapshot") {
handlers.onSnapshot?.(envelope.payload as State, envelope.sequence);
} else {
handlers.onUpdate?.(envelope.payload as State, envelope.sequence);
}
}
function onSnapshot(event: Event): void {
onDelivery("snapshot", event);
}
function onUpdate(event: Event): void {
onDelivery("update", event);
}
function terminate(): void {
if (closed) {
return;
}
closed = true;
// Detach first: a browser or injected implementation that synchronously reports closure
// from `close()` cannot re-enter `onConnectionError` and duplicate callbacks.
source.removeEventListener("open", onOpen);
source.removeEventListener("error", onConnectionError);
source.removeEventListener("snapshot", onSnapshot);
source.removeEventListener("update", onUpdate);
source.close();
reportStatus("closed");
}
source.addEventListener("open", onOpen);
source.addEventListener("error", onConnectionError);
source.addEventListener("snapshot", onSnapshot);
source.addEventListener("update", onUpdate);
return Object.freeze({
close: terminate,
});
},
});
}
/**
* Turns the relative path a module UI asks for into the real Ekklesia URL, and refuses anything
@@ -74,10 +258,10 @@ export interface CreateModuleUiContextOptions {
/**
* Builds the `ModuleUiHostContext` (ADR 0022) Ekklesia hands a module UI at mount time.
*
* All four members come from server-derived data: the module id and API root from the inventory
* descriptor, the principal from `/api/v1/principal`, and the permission hints from the server's own
* evaluation of that module's declared permissions against that principal. Nothing is guessed,
* defaulted to `true`, or inferred from a client-side role name.
* All five members come from host-derived data: the module id, API root, and realtime root from the
* inventory descriptor; the principal from `/api/v1/principal`; and permission hints from the
* server's evaluation of that module's declarations. Nothing is guessed, defaulted to `true`, or
* inferred from a client-side role name.
*
* The permission set is the module's own and only the module's own. A module UI cannot use this to
* discover what the viewer may do elsewhere in the platform, and `has` returning `true` is a
@@ -91,6 +275,7 @@ export function createModuleUiContext(options: CreateModuleUiContextOptions): Mo
moduleId: options.descriptor.id,
permissions: Object.freeze({ has: (permissionId: string) => granted.has(permissionId) }),
principal: options.principal,
realtime: createModuleRealtimeClient(options.config, options.descriptor),
});
}