feat: add native module realtime transport
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import type { ModuleUiRealtimeClient } from "./realtime.js";
|
||||
|
||||
/**
|
||||
* Who is viewing, as a browser sees it.
|
||||
*
|
||||
@@ -53,6 +55,8 @@ export interface ModuleUiHostContext {
|
||||
readonly principal: ModuleUiPrincipal;
|
||||
/** Presentation-only permission hints. See {@link ModuleUiPermissions}. */
|
||||
readonly permissions: ModuleUiPermissions;
|
||||
/** Host-owned delivery of this module's own runtime-published state. */
|
||||
readonly realtime: ModuleUiRealtimeClient;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./define-module-ui.js";
|
||||
export * from "./host-context.js";
|
||||
export * from "./module-ui.js";
|
||||
export * from "./realtime.js";
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* A JSON value carried from a module's server runtime to its browser UI.
|
||||
*
|
||||
* Realtime state is deliberately data, not transport vocabulary. A module does not open a socket,
|
||||
* choose an origin, attach credentials, or know how its host keeps a subscription alive. The host
|
||||
* owns all of that and gives the module only typed snapshots and updates.
|
||||
*/
|
||||
export type ModuleUiRealtimeJsonValue =
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| null
|
||||
| readonly ModuleUiRealtimeJsonValue[]
|
||||
| { readonly [key: string]: ModuleUiRealtimeJsonValue };
|
||||
|
||||
/** Connection state useful for rendering a small, honest status indicator. */
|
||||
export type ModuleUiRealtimeStatus = "connecting" | "connected" | "reconnecting" | "closed";
|
||||
|
||||
/** Stable, browser-safe failures. A host never exposes a caught platform or network error. */
|
||||
export type ModuleUiRealtimeErrorCode = "REALTIME_CONNECTION_FAILED" | "REALTIME_MESSAGE_INVALID";
|
||||
|
||||
export interface ModuleUiRealtimeError {
|
||||
readonly code: ModuleUiRealtimeErrorCode;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
/** Callbacks for one module-local channel. Snapshot and update are distinct on purpose: a UI can
|
||||
* replace all derived state when it (re)connects, then apply later authoritative states normally. */
|
||||
export interface ModuleUiRealtimeHandlers<State = ModuleUiRealtimeJsonValue> {
|
||||
readonly onSnapshot?: (state: State, sequence: number) => void;
|
||||
readonly onUpdate?: (state: State, sequence: number) => void;
|
||||
readonly onStatus?: (status: ModuleUiRealtimeStatus) => void;
|
||||
readonly onError?: (error: ModuleUiRealtimeError) => void;
|
||||
}
|
||||
|
||||
/** One live subscription. Closing is synchronous and idempotent. */
|
||||
export interface ModuleUiRealtimeSubscription {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A module UI's access to its own runtime-published state.
|
||||
*
|
||||
* `channel` is local to the module and uses lowercase kebab-case. The host supplies the module id,
|
||||
* endpoint, origin, credentials, reconnection policy, and concrete transport. Modules can neither
|
||||
* subscribe to another module nor supply a bearer token through this API.
|
||||
*
|
||||
* State must be JSON-compatible. The generic is intentionally unconstrained so a normal named
|
||||
* interface can describe a payload without needing an index signature; the runtime wire boundary
|
||||
* still validates that every delivered payload is JSON.
|
||||
*/
|
||||
export interface ModuleUiRealtimeClient {
|
||||
subscribe<State = ModuleUiRealtimeJsonValue>(
|
||||
channel: string,
|
||||
handlers: ModuleUiRealtimeHandlers<State>,
|
||||
): ModuleUiRealtimeSubscription;
|
||||
}
|
||||
@@ -17,7 +17,16 @@ export default defineModuleUi({
|
||||
container.textContent = describePrincipal(context.principal);
|
||||
void context.api.request("records");
|
||||
void context.permissions.has("example.records.read");
|
||||
const subscription = context.realtime.subscribe<{ readonly count: number }>("record-count", {
|
||||
onSnapshot(state, sequence) {
|
||||
container.textContent = `${state.count}:${sequence}`;
|
||||
},
|
||||
onStatus(status) {
|
||||
container.setAttribute("data-realtime", status);
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
subscription.close();
|
||||
container.textContent = "";
|
||||
};
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type ModuleUiApiClient,
|
||||
type ModuleUiDefinition,
|
||||
type ModuleUiHostContext,
|
||||
type ModuleUiRealtimeClient,
|
||||
parseModuleUiDefinition,
|
||||
parseModuleUiExport,
|
||||
} from "../src/index.js";
|
||||
@@ -19,6 +20,10 @@ function validDefinition(): ModuleUiDefinition {
|
||||
* contract, and this package deliberately does not take a DOM dependency to obtain one. */
|
||||
const noop = (): void => undefined;
|
||||
|
||||
const noopRealtime: ModuleUiRealtimeClient = Object.freeze({
|
||||
subscribe: () => Object.freeze({ close: noop }),
|
||||
});
|
||||
|
||||
function fakeContainer(): Element & { textContent: string } {
|
||||
return { textContent: "" } as unknown as Element & { textContent: string };
|
||||
}
|
||||
@@ -131,6 +136,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
realtime: noopRealtime,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -154,6 +160,7 @@ describe("the same module UI artifact hosted by two different hosts", () => {
|
||||
api,
|
||||
principal: { kind: "anonymous" },
|
||||
permissions: { has: () => canPublish },
|
||||
realtime: noopRealtime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user