feat: add native module realtime transport
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export {
|
||||
type DashboardNavigationGroup,
|
||||
type EkklesiaHostConfig,
|
||||
type HostBootstrap,
|
||||
type HostEventSourceFactory,
|
||||
type HostEventSourceOptions,
|
||||
type HostModuleUiDescriptor,
|
||||
HostProtocolError,
|
||||
type HostProtocolErrorCode,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function entry(id: string, name: string, version = "0.1.0", permissions: readonl
|
||||
api: { baseUrl: `/api/v1/modules/${id}` },
|
||||
id,
|
||||
permissions,
|
||||
realtime: { baseUrl: `/api/v1/module-realtime/${id}` },
|
||||
ui: { url: `/api/v1/module-ui/${id}/${version}` },
|
||||
version,
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ const inventoryPayload = {
|
||||
api: { baseUrl: "/api/v1/modules/example" },
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/example" },
|
||||
ui: { url: "/api/v1/module-ui/example/0.1.0" },
|
||||
version: "0.1.0",
|
||||
},
|
||||
@@ -50,6 +51,7 @@ const calendarDescriptor: HostModuleUiDescriptor = {
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
|
||||
version: "2.0.0",
|
||||
};
|
||||
@@ -69,6 +71,7 @@ const navigationPayload = {
|
||||
api: { baseUrl: "/api/v1/modules/calendar" },
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtime: { baseUrl: "/api/v1/module-realtime/calendar" },
|
||||
ui: { url: "/api/v1/module-ui/calendar/2.0.0" },
|
||||
version: "2.0.0",
|
||||
},
|
||||
@@ -81,6 +84,7 @@ const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.records.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||
version: "0.1.0",
|
||||
};
|
||||
@@ -103,14 +107,18 @@ describe("host protocol — descriptor validation", () => {
|
||||
{ id: "example", version: "0.1.0" },
|
||||
{ ...base, ui: {} },
|
||||
{ ...base, api: { baseUrl: "" } },
|
||||
{ ...base, realtime: {} },
|
||||
{ ...base, permissions: "example.records.read" },
|
||||
{ ...base, permissions: [42] },
|
||||
// Both URLs must be root-relative, so a value that would resolve against another origin is
|
||||
// Every URL must be root-relative, so a value that would resolve against another origin is
|
||||
// refused rather than silently used to fetch browser code.
|
||||
{ ...base, ui: { url: "https://evil.example.test/x.js" } },
|
||||
{ ...base, ui: { url: "//evil.example.test/x.js" } },
|
||||
{ ...base, ui: { url: "relative/x.js" } },
|
||||
{ ...base, api: { baseUrl: "https://evil.example.test/api" } },
|
||||
{ ...base, realtime: { baseUrl: "https://evil.example.test/live" } },
|
||||
{ ...base, realtime: { baseUrl: "//evil.example.test/live" } },
|
||||
{ ...base, realtime: { baseUrl: "/\\evil.example.test/live" } },
|
||||
];
|
||||
for (const module of malformed) {
|
||||
expect(
|
||||
@@ -366,6 +374,7 @@ describe("module UI host context", () => {
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
permissions: [],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
uiUrl: "/api/v1/module-ui/calendar/2.0.0",
|
||||
version: "2.0.0",
|
||||
};
|
||||
@@ -392,12 +401,18 @@ describe("module UI host context", () => {
|
||||
expect(none.has("core.authorization.grants.manage")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes exactly the four contract members and the browser principal", () => {
|
||||
it("exposes exactly the five contract members and the browser principal", () => {
|
||||
const { implementation } = recordingFetch();
|
||||
|
||||
const context = contextFor(implementation);
|
||||
|
||||
expect(Object.keys(context).sort()).toEqual(["api", "moduleId", "permissions", "principal"]);
|
||||
expect(Object.keys(context).sort()).toEqual([
|
||||
"api",
|
||||
"moduleId",
|
||||
"permissions",
|
||||
"principal",
|
||||
"realtime",
|
||||
]);
|
||||
expect(context.moduleId).toBe("example");
|
||||
expect(context.principal).toEqual({ kind: "authenticated", userId: "user-1" });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { ModuleUiRealtimeError, ModuleUiRealtimeStatus } from "@ekklesia/module-ui-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
HostEventSourceFactory,
|
||||
HostEventSourceOptions,
|
||||
HostModuleUiDescriptor,
|
||||
} from "../src/host-protocol.js";
|
||||
import { createModuleUiContext } from "../src/module-context.js";
|
||||
|
||||
const apiBaseUrl = "https://ekklesia.example.test";
|
||||
|
||||
const exampleDescriptor: HostModuleUiDescriptor = {
|
||||
apiBaseUrl: "/api/v1/modules/example",
|
||||
id: "example",
|
||||
permissions: ["example.state.read"],
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/example",
|
||||
uiUrl: "/api/v1/module-ui/example/0.1.0",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
class FakeEventSource {
|
||||
readonly CLOSED = 2 as const;
|
||||
readonly CONNECTING = 0 as const;
|
||||
readonly OPEN = 1 as const;
|
||||
readonly listeners = new Map<string, Set<EventListener>>();
|
||||
closed = false;
|
||||
closeCalls = 0;
|
||||
readyState: number = this.CONNECTING;
|
||||
|
||||
addEventListener(type: string, listener: EventListener): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
removeEventListener(type: string, listener: EventListener): void {
|
||||
this.listeners.get(type)?.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.closeCalls += 1;
|
||||
this.readyState = this.CLOSED;
|
||||
}
|
||||
|
||||
emit(type: string, data?: string): void {
|
||||
if (type === "open") {
|
||||
this.readyState = this.OPEN;
|
||||
}
|
||||
const event = Object.freeze({ data, type }) as unknown as Event;
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface EventSourceHarness {
|
||||
readonly calls: Array<{
|
||||
readonly options: HostEventSourceOptions;
|
||||
readonly source: FakeEventSource;
|
||||
readonly url: string;
|
||||
}>;
|
||||
readonly factory: HostEventSourceFactory;
|
||||
}
|
||||
|
||||
function eventSourceHarness(): EventSourceHarness {
|
||||
const calls: EventSourceHarness["calls"] = [];
|
||||
const factory = ((url: string, options: HostEventSourceOptions): EventSource => {
|
||||
const source = new FakeEventSource();
|
||||
calls.push({ options, source, url });
|
||||
return source as unknown as EventSource;
|
||||
}) as HostEventSourceFactory;
|
||||
return { calls, factory };
|
||||
}
|
||||
|
||||
function context(factory: HostEventSourceFactory, descriptor = exampleDescriptor) {
|
||||
return createModuleUiContext({
|
||||
config: { apiBaseUrl, eventSourceFactory: factory },
|
||||
descriptor,
|
||||
principal: { kind: "authenticated", userId: "user-1" },
|
||||
});
|
||||
}
|
||||
|
||||
function envelope(sequence: number, payload: unknown): string {
|
||||
return JSON.stringify({ payload, sequence });
|
||||
}
|
||||
|
||||
describe("module UI realtime host adapter", () => {
|
||||
it("opens the server-provided module channel URL with host-owned credentials", () => {
|
||||
const harness = eventSourceHarness();
|
||||
|
||||
context(harness.factory).realtime.subscribe("slideshow-state", {});
|
||||
|
||||
expect(harness.calls).toHaveLength(1);
|
||||
expect(harness.calls[0]).toMatchObject({
|
||||
options: { withCredentials: true },
|
||||
url: `${apiBaseUrl}/api/v1/module-realtime/example/slideshow-state`,
|
||||
});
|
||||
});
|
||||
|
||||
it("scopes the same local channel to each descriptor without accepting a module id", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const calendar: HostModuleUiDescriptor = {
|
||||
...exampleDescriptor,
|
||||
apiBaseUrl: "/api/v1/modules/calendar",
|
||||
id: "calendar",
|
||||
realtimeBaseUrl: "/api/v1/module-realtime/calendar",
|
||||
uiUrl: "/api/v1/module-ui/calendar/1.0.0",
|
||||
version: "1.0.0",
|
||||
};
|
||||
|
||||
context(harness.factory).realtime.subscribe("state", {});
|
||||
context(harness.factory, calendar).realtime.subscribe("state", {});
|
||||
|
||||
expect(harness.calls.map((call) => call.url)).toEqual([
|
||||
`${apiBaseUrl}/api/v1/module-realtime/example/state`,
|
||||
`${apiBaseUrl}/api/v1/module-realtime/calendar/state`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("delivers snapshot first, ordered updates, and fresh state after automatic reconnect", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const snapshots: unknown[] = [];
|
||||
const updates: unknown[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
|
||||
context(harness.factory).realtime.subscribe<{ readonly mode: string }>("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onSnapshot: (state, sequence) => snapshots.push({ sequence, state }),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
onUpdate: (state, sequence) => updates.push({ sequence, state }),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
expect(source).toBeDefined();
|
||||
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
source?.emit("update", envelope(2, { mode: "off" }));
|
||||
// Native EventSource owns reconnect. The host reports it, then treats the next snapshot as a
|
||||
// fresh authoritative base even when its sequence is the same as the last state already seen.
|
||||
if (source) {
|
||||
source.readyState = source.CONNECTING;
|
||||
source.emit("error");
|
||||
}
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(2, { mode: "off" }));
|
||||
source?.emit("update", envelope(3, { mode: "on" }));
|
||||
|
||||
expect(statuses).toEqual(["connecting", "connected", "reconnecting", "connected"]);
|
||||
expect(snapshots).toEqual([
|
||||
{ sequence: 1, state: { mode: "on" } },
|
||||
{ sequence: 2, state: { mode: "off" } },
|
||||
]);
|
||||
expect(updates).toEqual([
|
||||
{ sequence: 2, state: { mode: "off" } },
|
||||
{ sequence: 3, state: { mode: "on" } },
|
||||
]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed envelopes and out-of-order deliveries with one fixed safe error", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const updates: unknown[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
context(harness.factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onUpdate: (state, sequence) => updates.push({ sequence, state }),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
|
||||
source?.emit("update", envelope(1, { secret: "not delivered" }));
|
||||
source?.emit("snapshot", "not json: /private/server/path");
|
||||
source?.emit("snapshot", JSON.stringify({ extra: true, payload: {}, sequence: 1 }));
|
||||
source?.emit("snapshot", envelope(0, {}));
|
||||
source?.emit("snapshot", envelope(2, { mode: "on" }));
|
||||
source?.emit("update", envelope(2, { mode: "stale" }));
|
||||
|
||||
expect(updates).toEqual([]);
|
||||
expect(errors).toHaveLength(5);
|
||||
expect(new Set(errors.map((error) => JSON.stringify(error)))).toEqual(
|
||||
new Set([
|
||||
JSON.stringify({
|
||||
code: "REALTIME_MESSAGE_INVALID",
|
||||
message: "The host received an invalid live update.",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(JSON.stringify(errors)).not.toContain("private");
|
||||
expect(JSON.stringify(errors)).not.toContain("secret");
|
||||
});
|
||||
|
||||
it("refuses invalid channel names before opening a connection", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const realtime = context(harness.factory).realtime;
|
||||
|
||||
for (const channel of [
|
||||
"",
|
||||
"State",
|
||||
"slide_state",
|
||||
"slide/state",
|
||||
"state.*",
|
||||
"-state",
|
||||
"state-",
|
||||
"a".repeat(49),
|
||||
]) {
|
||||
expect(() => realtime.subscribe(channel, {}), channel).toThrowError(
|
||||
expect.objectContaining({ code: "MODULE_REALTIME_CHANNEL_INVALID" }),
|
||||
);
|
||||
}
|
||||
expect(harness.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("closes idempotently, removes every listener, and delivers nothing afterward", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const snapshots = vi.fn();
|
||||
const subscription = context(harness.factory).realtime.subscribe("state", {
|
||||
onSnapshot: snapshots,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
|
||||
subscription.close();
|
||||
subscription.close();
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
source?.emit("open");
|
||||
|
||||
expect(source?.closed).toBe(true);
|
||||
expect(source?.closeCalls).toBe(1);
|
||||
expect(
|
||||
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
|
||||
(listeners) => listeners.size === 0,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(snapshots).not.toHaveBeenCalled();
|
||||
expect(statuses).toEqual(["connecting", "connected", "closed"]);
|
||||
});
|
||||
|
||||
it("treats a terminal native CLOSED error as closed and eagerly removes all listeners", () => {
|
||||
const harness = eventSourceHarness();
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
const snapshots = vi.fn();
|
||||
const subscription = context(harness.factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onSnapshot: snapshots,
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
const source = harness.calls[0]?.source;
|
||||
source?.emit("open");
|
||||
source?.emit("snapshot", envelope(1, { mode: "on" }));
|
||||
|
||||
if (source) {
|
||||
// Fatal HTTP/protocol failures leave native EventSource CLOSED; unlike CONNECTING, the browser
|
||||
// will not retry them, so the host must not promise `reconnecting`.
|
||||
source.readyState = source.CLOSED;
|
||||
source.emit("error");
|
||||
source.emit("error");
|
||||
source.emit("open");
|
||||
source.emit("snapshot", envelope(2, { mode: "off" }));
|
||||
}
|
||||
subscription.close();
|
||||
|
||||
expect(source?.closed).toBe(true);
|
||||
expect(source?.closeCalls).toBe(1);
|
||||
expect(
|
||||
[...((source?.listeners.values() ?? []) as Iterable<Set<EventListener>>)].every(
|
||||
(listeners) => listeners.size === 0,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(statuses).toEqual(["connecting", "connected", "closed"]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
expect(snapshots).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("sanitizes constructor failures and closes without exposing the caught value", () => {
|
||||
const errors: ModuleUiRealtimeError[] = [];
|
||||
const statuses: ModuleUiRealtimeStatus[] = [];
|
||||
const factory = vi.fn(() => {
|
||||
throw new Error("token=secret at /home/server/realtime");
|
||||
}) as unknown as HostEventSourceFactory;
|
||||
|
||||
const subscription = context(factory).realtime.subscribe("state", {
|
||||
onError: (error) => errors.push(error),
|
||||
onStatus: (status) => statuses.push(status),
|
||||
});
|
||||
subscription.close();
|
||||
|
||||
expect(statuses).toEqual(["connecting", "closed"]);
|
||||
expect(errors).toEqual([
|
||||
{
|
||||
code: "REALTIME_CONNECTION_FAILED",
|
||||
message: "The live connection could not be maintained.",
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(errors)).not.toContain("secret");
|
||||
expect(JSON.stringify(errors)).not.toContain("home");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user