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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user