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
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:
@@ -0,0 +1,140 @@
|
||||
// Schema-driven plugin config with raw round-trip semantics.
|
||||
//
|
||||
// The invariant that kills the raw-vs-resolved muddle of the first-generation plugins:
|
||||
// the file on disk is ALWAYS the operator-authored (raw / Encoded) shape; defaults live
|
||||
// ONLY in the Schema (`Schema.withDecodingDefaultKey(..., { encodingStrategy: "omit" })`)
|
||||
// and are applied at decode time. `saveRaw` validates by decoding but persists the raw
|
||||
// shape verbatim — a UI save never bakes defaults into the file.
|
||||
//
|
||||
// Ported semantics from rom-manager's state.ts: state dir 0700, atomic temp+rename 0600
|
||||
// writes, POSIX group/world-writable refusal (config controls commands run as the host
|
||||
// user), missing file == empty config.
|
||||
import * as fs from "node:fs";
|
||||
import { Effect, PubSub, Schema, Stream } from "effect";
|
||||
import {
|
||||
ConfigParseError,
|
||||
ConfigPermissionError,
|
||||
type ConfigWriteError,
|
||||
} from "./errors.js";
|
||||
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
|
||||
import { PluginInfo } from "./host-client.js";
|
||||
|
||||
export interface ConfigService<S extends Schema.Top> {
|
||||
/** Decode the raw file with Schema defaults applied. Missing file → all defaults. */
|
||||
readonly load: Effect.Effect<
|
||||
S["Type"],
|
||||
ConfigParseError | ConfigPermissionError
|
||||
>;
|
||||
/** The operator-authored shape, validated but NOT defaulted — what the UI edits. */
|
||||
readonly loadRaw: Effect.Effect<
|
||||
S["Encoded"],
|
||||
ConfigParseError | ConfigPermissionError
|
||||
>;
|
||||
/**
|
||||
* Validate-by-decode, persist the RAW shape verbatim (atomic), emit the decoded
|
||||
* config on `changes`, and return it.
|
||||
*/
|
||||
readonly saveRaw: (
|
||||
raw: unknown,
|
||||
) => Effect.Effect<
|
||||
S["Type"],
|
||||
ConfigParseError | ConfigWriteError
|
||||
>;
|
||||
/** Emits the decoded config after every successful `saveRaw`. */
|
||||
readonly changes: Stream.Stream<S["Type"]>;
|
||||
/** Absolute path of the config file (status views). */
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/** Refuse a group/world-writable config file (POSIX only; Windows state dir is DACL'd). */
|
||||
const checkNotWorldWritable = (
|
||||
file: string,
|
||||
): Effect.Effect<void, ConfigPermissionError> =>
|
||||
Effect.suspend(() => {
|
||||
if (process.platform === "win32") return Effect.void;
|
||||
let mode: number;
|
||||
try {
|
||||
mode = fs.statSync(file).mode;
|
||||
} catch {
|
||||
return Effect.void; // absent — nothing to guard
|
||||
}
|
||||
return (mode & 0o022) !== 0
|
||||
? Effect.fail(new ConfigPermissionError({ path: file, mode }))
|
||||
: Effect.void;
|
||||
});
|
||||
|
||||
const readRawObject = (
|
||||
file: string,
|
||||
): Effect.Effect<unknown, ConfigParseError | ConfigPermissionError> =>
|
||||
checkNotWorldWritable(file).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Effect.suspend(() => {
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
return Effect.succeed({} as unknown); // missing file == empty config
|
||||
}
|
||||
try {
|
||||
return Effect.succeed(JSON.parse(text) as unknown);
|
||||
} catch (e) {
|
||||
return Effect.fail(
|
||||
new ConfigParseError({ path: file, issue: String(e) }),
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const makeConfigService = <S extends Schema.Top>(opts: {
|
||||
readonly schema: S;
|
||||
readonly fileName?: string;
|
||||
}): Effect.Effect<ConfigService<S>, never, PluginInfo> =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* PluginInfo;
|
||||
const file = statePath(info.name, opts.fileName ?? "config.json");
|
||||
const hub = yield* PubSub.unbounded<S["Type"]>();
|
||||
|
||||
const decode = (
|
||||
raw: unknown,
|
||||
): Effect.Effect<S["Type"], ConfigParseError> =>
|
||||
Schema.decodeUnknownEffect(opts.schema)(raw).pipe(
|
||||
Effect.mapError(
|
||||
(e) => new ConfigParseError({ path: file, issue: String(e) }),
|
||||
),
|
||||
) as Effect.Effect<S["Type"], ConfigParseError>;
|
||||
|
||||
const load = readRawObject(file).pipe(Effect.flatMap(decode));
|
||||
|
||||
// Validated (a broken file must not masquerade as authored config), returned verbatim.
|
||||
const loadRaw = readRawObject(file).pipe(
|
||||
Effect.tap(decode),
|
||||
Effect.map((raw) => raw as S["Encoded"]),
|
||||
);
|
||||
|
||||
const saveRaw = (raw: unknown) =>
|
||||
decode(raw).pipe(
|
||||
Effect.tap(() => ensureStateDir(info.name)),
|
||||
Effect.tap(() =>
|
||||
atomicWriteFile(file, `${JSON.stringify(raw, null, 2)}\n`),
|
||||
),
|
||||
Effect.tap((decoded) => PubSub.publish(hub, decoded)),
|
||||
);
|
||||
|
||||
return {
|
||||
load,
|
||||
loadRaw,
|
||||
saveRaw,
|
||||
changes: Stream.fromPubSub(hub),
|
||||
path: file,
|
||||
} satisfies ConfigService<S>;
|
||||
});
|
||||
|
||||
// A `Context.Service` class factory is deliberately NOT provided — plugins define their
|
||||
// own service key over `ConfigService<their schema>` so the config type stays precise:
|
||||
//
|
||||
// class RomConfig extends Context.Service<RomConfig, ConfigService<typeof RomConfigSchema>>()(
|
||||
// "rom-manager/Config",
|
||||
// ) {
|
||||
// static layer = Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema }))
|
||||
// }
|
||||
Reference in New Issue
Block a user