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:04 +02:00
parent c15c80718a
commit 8d72e1d27e
23 changed files with 1987 additions and 18 deletions
+69
View File
@@ -0,0 +1,69 @@
// The library-provider wire, owned by the kit so plugins stop hand-copying `wire.ts`.
// Schemas mirror the host's `ProviderEntryInput` (crates/punktfunk-host mgmt/library.rs);
// the transport stays the SDK's untyped `pf.request` seam (version-skew-safe under the
// runner-bundled SDK — design D7). These schemas are identity codecs (plain JSON shapes),
// so entries pass through unencoded; the value is the shared type + authoring validation.
import { Context, Effect, Layer, Schema } from "effect";
import type { HostRequestError } from "./errors.js";
import { HostClient } from "./host-client.js";
export const Artwork = Schema.Struct({
portrait: Schema.optionalKey(Schema.NullOr(Schema.String)),
hero: Schema.optionalKey(Schema.NullOr(Schema.String)),
logo: Schema.optionalKey(Schema.NullOr(Schema.String)),
header: Schema.optionalKey(Schema.NullOr(Schema.String)),
});
export type Artwork = typeof Artwork.Type;
export const LaunchSpec = Schema.Struct({
kind: Schema.Literal("command"),
value: Schema.String,
});
export type LaunchSpec = typeof LaunchSpec.Type;
export const PrepStep = Schema.Struct({
do: Schema.String,
undo: Schema.optionalKey(Schema.NullOr(Schema.String)),
});
export type PrepStep = typeof PrepStep.Type;
export const ProviderEntry = Schema.Struct({
external_id: Schema.String,
title: Schema.String,
art: Schema.optionalKey(Artwork),
launch: Schema.optionalKey(Schema.NullOr(LaunchSpec)),
prep: Schema.optionalKey(Schema.Array(PrepStep)),
});
export type ProviderEntry = typeof ProviderEntry.Type;
export interface ProviderClientService {
/** Full-replace reconcile: PUT the desired set; the host diffs by `external_id`. */
readonly reconcile: (
providerId: string,
entries: ReadonlyArray<ProviderEntry>,
) => Effect.Effect<void, HostRequestError>;
/** Remove every entry this provider owns (the explicit-uninstall path). */
readonly remove: (providerId: string) => Effect.Effect<void, HostRequestError>;
}
export class ProviderClient extends Context.Service<
ProviderClient,
ProviderClientService
>()("@punktfunk/plugin-kit/ProviderClient") {
static readonly layer: Layer.Layer<ProviderClient, never, HostClient> =
Layer.effect(ProviderClient)(
Effect.gen(function* () {
const host = yield* HostClient;
return {
reconcile: (providerId, entries) =>
host
.request("PUT", `/library/provider/${providerId}`, entries)
.pipe(Effect.asVoid),
remove: (providerId) =>
host
.request("DELETE", `/library/provider/${providerId}`)
.pipe(Effect.asVoid),
} satisfies ProviderClientService;
}),
);
}