Files
punktfunk/plugin-kit/src/react/index.tsx
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

154 lines
5.8 KiB
TypeScript

// Browser/React glue for plugin UIs served through the console's /plugin-ui/<id>/ 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/<id>/<route>`), 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/<id>` 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 = <Route extends string>(
routes: ReadonlyArray<Route>,
fallback: Route,
): Route => {
const isRoute = (s: string | undefined): s is Route =>
s !== undefined && (routes as ReadonlyArray<string>).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 = <const Routes extends ReadonlyArray<string>>(
routes: Routes,
fallback: Routes[number],
) => {
type Route = Routes[number];
const usePluginRoute = (): {
route: Route;
navigate: (r: Route) => void;
} => {
const [route, setRoute] = useState<Route>(() =>
initialRoute(routes as ReadonlyArray<Route>, fallback as Route),
);
useEffect(() => {
const onHash = () => {
const seg = window.location.hash.replace(/^#\/?/, "");
if ((routes as ReadonlyArray<string>).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<A, E> {
readonly result: AsyncResult.AsyncResult<A, E>;
/** 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 = <A, E>(props: ResultGateProps<A, E>): 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<A, I> {
/** 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<A, I>;
}
/**
* 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 = <A, I>(
opts: SseAtomOptions<A, I>,
): Atom.Atom<A | undefined> =>
Atom.make<A | undefined>((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;
});