// 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 { useEffect, useState, type ReactNode } from "react"; import { Option, Schema } from "effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; /** `/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). */ export const postNavigate = (path: string): void => { try { if (window.parent !== window) { window.parent.postMessage({ type: "pf-ui:navigate", path }, "*"); } } catch { // cross-origin parent or detached — 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; });