feat(plugin-kit): @punktfunk/plugin-kit 0.1.0 — the Effect plugin framework
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>
This commit is contained in:
2026-07-20 18:41:11 +02:00
co-authored by Claude Fable 5
parent c15c80718a
commit 8d72e1d27e
23 changed files with 1987 additions and 18 deletions
+143
View File
@@ -0,0 +1,143 @@
// 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;
});