// Browser/React glue for plugin UIs served through the console's /plugin-ui// proxy. // Design-system-free on purpose: ResultGate takes render props (the plugin wraps it once // with its @unom/ui skeleton/error visuals), so the kit's only peer here is react. // // Routing model (fixes the broken deep-link restore of the first-generation UIs): the // console pins the iframe src to the deep-linked PATH (`/plugin-ui//`), so // route init must read the last pathname segment — the hash is only a standalone-tab // fallback. Navigation posts `pf-ui:navigate` so the console mirrors the route into its // own URL (replace: true; the iframe src stays pinned — no reload loop). import { Option, Schema } from "effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { type ReactNode, useEffect, useState } from "react"; /** `/plugin-ui/` when served through the console proxy, "" in dev/standalone. */ export const resolvePluginBase = (): string => { const m = window.location.pathname.match(/^\/plugin-ui\/[a-z][a-z0-9-]*/); return m ? m[0] : ""; }; /** True when running inside the console's iframe. */ export const useIsEmbedded = (): boolean => typeof window !== "undefined" && window.parent !== window; /** * Mirror a route into the console's address bar (best-effort, embedded only). * * The `"*"` target origin is load-bearing and must stay: the console frames plugin UIs from a * DIFFERENT ORIGIN than its own (they get their own port, so a plugin cannot act as the logged-in * operator — security-review 2026-08-05 H-3). Narrowing this to `window.location.origin` would * target the PLUGIN's origin, not the console's, and every message would be silently dropped. * * `"*"` is safe here because the payload is a route path the plugin itself just navigated to — * nothing secret — and the console verifies `event.origin` against the plugin origin before acting * on it, so the trust decision is made on the receiving side where it belongs. */ export const postNavigate = (path: string): void => { try { if (window.parent !== window) { window.parent.postMessage({ type: "pf-ui:navigate", path }, "*"); } } catch { // detached parent — deep-link sync is best-effort } }; const initialRoute = ( routes: ReadonlyArray, fallback: Route, ): Route => { const isRoute = (s: string | undefined): s is Route => s !== undefined && (routes as ReadonlyArray).includes(s); const lastSegment = window.location.pathname .split("/") .filter(Boolean) .at(-1); if (isRoute(lastSegment)) return lastSegment; const hashSegment = window.location.hash.replace(/^#\/?/, ""); if (isRoute(hashSegment)) return hashSegment; return fallback; }; /** * Flat single-segment routes, no router library. Returns a hook: route state initialized * from path-then-hash, `navigate` updates state + hash (standalone back/forward) + the * console deep-link bridge. Listens to hashchange for browser navigation. */ export const createPluginRouter = >( routes: Routes, fallback: Routes[number], ) => { type Route = Routes[number]; const usePluginRoute = (): { route: Route; navigate: (r: Route) => void; } => { const [route, setRoute] = useState(() => initialRoute(routes as ReadonlyArray, fallback as Route), ); useEffect(() => { const onHash = () => { const seg = window.location.hash.replace(/^#\/?/, ""); if ((routes as ReadonlyArray).includes(seg)) { setRoute(seg as Route); } }; window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); const navigate = (r: Route) => { setRoute(r); window.location.hash = `/${r}`; postNavigate(r); }; return { route, navigate }; }; return { routes, usePluginRoute }; }; export interface ResultGateProps { readonly result: AsyncResult.AsyncResult; /** Rendered while the first value loads (page skeleton). */ readonly waiting?: ReactNode; /** Rendered on failure; `retry` re-triggers via the caller (registry refresh). */ readonly failure?: (error: E, retry?: () => void) => ReactNode; readonly retry?: () => void; readonly children: (value: A) => ReactNode; } /** * The one loading/error/success convention for plugin pages. Keeps showing the last * value while a refresh is in flight (no skeleton flash on invalidation). */ export const ResultGate = (props: ResultGateProps): ReactNode => { const { result } = props; if (AsyncResult.isSuccess(result)) return props.children(result.value); if (AsyncResult.isFailure(result)) { const error = Option.getOrUndefined(AsyncResult.error(result)) as E; return props.failure?.(error, props.retry) ?? null; } // Initial or waiting-without-value. return props.waiting ?? null; }; export interface SseAtomOptions { /** Absolute-or-relative URL of the SSE endpoint (prefix it via resolvePluginBase). */ readonly url: string; /** SSE `event:` name (default "message"). */ readonly event?: string; readonly schema: Schema.Codec; } /** * An Atom over a reconnecting EventSource: emits each schema-valid frame, `undefined` * until the first one. The browser reconnects EventSource automatically; the atom closes * it when the last subscriber unmounts. */ export const sseAtom = ( opts: SseAtomOptions, ): Atom.Atom => Atom.make((get) => { const source = new EventSource(opts.url); const decode = Schema.decodeUnknownSync(opts.schema); source.addEventListener(opts.event ?? "message", (e) => { try { get.setSelf(decode(JSON.parse((e as MessageEvent).data))); } catch { // skip schema-invalid frames (version skew tolerance) } }); get.addFinalizer(() => source.close()); return undefined; });