ci / rust (push) Has been cancelled
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / docs-site (push) Has been cancelled
deb / build-publish-host (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
windows-host / package (push) Has been cancelled
The ~80% of every plugin that was copy-pasted (rom-manager ↔ playnite), extracted as one Effect-v4 package: - runtime: definePluginKit — async-main boundary hiding a ManagedRuntime (two-effect-instances discipline), signal-driven interruption with a bounded shutdown grace - config: Schema-driven raw round-trip (defaults only in the Schema via withDecodingDefaultKey + encodingStrategy omit; file stays authored-shape), atomic writes, world-writable refusal, changes stream - cache-store: disposable derived state, corrupt→empty, write-through - reconcile: kit-owned provider wire schemas + typed client over the skew-safe untyped pf.request seam - sync-engine: generic poll/watch/debounce/single-flight-coalesce/ fingerprint-skip engine with a status PubSub (the SSE feed) - ui-server + sse: effect/unstable/httpapi behind servePluginUi with core-only env layers (validated by the phase-0 spikes); raw SSE route (httpapi has no event-stream media type) - cli: minimal command dispatcher reusing the plugin layer graph (deliberately not effect/unstable/cli — it needs platform packages) - react subpath: plugin router (path→hash→fallback deep-link restore + pf-ui:navigate bridge), ResultGate, sseAtom, resolvePluginBase - theme.css: the console's violet identity packaged for plugin UIs 18 bun tests incl. the two phase-0 spikes; publish workflow mirrors sdk-publish (tag plugin-kit-v*; 0.1.0 published manually). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
144 lines
5.1 KiB
TypeScript
144 lines
5.1 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 { useEffect, useState, type ReactNode } from "react";
|
|
import { Option, Schema } from "effect";
|
|
import { AsyncResult, Atom } from "effect/unstable/reactivity";
|
|
|
|
/** `/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). */
|
|
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 = <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;
|
|
});
|