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