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
+60
View File
@@ -0,0 +1,60 @@
// SSE support. `effect/unstable/httpapi` has no event-stream media type (verified at
// beta.99), so the status feed is a raw HttpRouter route beside the HttpApi contract —
// same wire shape the first-generation plugins used (`event: <name>` frames + comment
// pings), which is already proven through the console's reverse proxy.
import { Effect, Layer, Schedule, Stream } from "effect";
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
const encoder = new TextEncoder();
export interface SseRouteOptions<A> {
/** SSE `event:` name (default "message"). */
readonly event?: string;
/** Serialize one item (default JSON.stringify). */
readonly encode?: (a: A) => string;
/** Keepalive comment cadence in seconds (default 15; 0 disables). */
readonly pingSeconds?: number;
}
/**
* Register `GET <path>` streaming `stream` as server-sent events. Each subscriber gets
* an independent subscription; disconnects tear the stream down via scope closure.
*/
export const sseRoute = <A>(
path: `/${string}`,
stream: Stream.Stream<A>,
opts?: SseRouteOptions<A>,
): Layer.Layer<never, never, HttpRouter.HttpRouter> => {
const event = opts?.event ?? "message";
const encode = opts?.encode ?? ((a: A) => JSON.stringify(a));
const pingSeconds = opts?.pingSeconds ?? 15;
const frames = stream.pipe(
Stream.map((a) => `event: ${event}\ndata: ${encode(a)}\n\n`),
);
const pings =
pingSeconds > 0
? Stream.fromSchedule(Schedule.spaced(`${pingSeconds} seconds`)).pipe(
Stream.map(() => `: ping\n\n`),
)
: Stream.empty;
const body = Stream.merge(frames, pings).pipe(
Stream.map((s) => encoder.encode(s)),
);
return HttpRouter.add(
"GET",
path,
Effect.succeed(
HttpServerResponse.stream(body, {
contentType: "text/event-stream",
headers: {
"cache-control": "no-cache",
connection: "keep-alive",
"x-accel-buffering": "no",
},
}),
),
);
};