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
+68
View File
@@ -0,0 +1,68 @@
// Disposable derived state (cache.json): corrupt or absent falls back to `empty` and
// never fails a read; every `modify` is write-through with the same atomic-write
// discipline as config. Held in a Ref so reads are cheap and mutations are ordered.
import * as fs from "node:fs";
import { Effect, Ref, Schema } from "effect";
import type { ConfigWriteError } from "./errors.js";
import { atomicWriteFile, ensureStateDir, statePath } from "./paths.js";
import { PluginInfo } from "./host-client.js";
export interface CacheStore<S extends Schema.Top> {
readonly get: Effect.Effect<S["Type"]>;
/** Atomically update the cache (Ref + write-through). Returns the `modify` result. */
readonly modify: <A>(
f: (current: S["Type"]) => readonly [A, S["Type"]],
) => Effect.Effect<A, ConfigWriteError>;
/** `modify` without a result. */
readonly update: (
f: (current: S["Type"]) => S["Type"],
) => Effect.Effect<void, ConfigWriteError>;
/** Absolute path of the cache file (status views). */
readonly path: string;
}
export const makeCacheStore = <S extends Schema.Top>(opts: {
readonly schema: S;
readonly empty: S["Type"];
readonly fileName?: string;
}): Effect.Effect<CacheStore<S>, never, PluginInfo> =>
Effect.gen(function* () {
const info = yield* PluginInfo;
const file = statePath(info.name, opts.fileName ?? "cache.json");
const initial = yield* Effect.suspend(() => {
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
return Schema.decodeUnknownEffect(opts.schema)(parsed).pipe(
Effect.orElseSucceed(() => opts.empty),
) as Effect.Effect<S["Type"]>;
} catch {
return Effect.succeed(opts.empty);
}
});
const ref = yield* Ref.make<S["Type"]>(initial);
const persist = (value: S["Type"]) =>
ensureStateDir(info.name).pipe(
Effect.flatMap(() =>
atomicWriteFile(file, JSON.stringify(value)),
),
);
const modify = <A>(f: (current: S["Type"]) => readonly [A, S["Type"]]) =>
Ref.modify(ref, (current) => {
const [a, next] = f(current);
return [[a, next] as const, next] as const;
}).pipe(
Effect.flatMap(([a, next]) =>
persist(next).pipe(Effect.as(a)),
),
);
return {
get: Ref.get(ref),
modify,
update: (f) => modify((c) => [undefined, f(c)] as const),
path: file,
} satisfies CacheStore<S>;
});
+88
View File
@@ -0,0 +1,88 @@
// Minimal plugin CLI scaffold. Deliberately NOT `effect/unstable/cli`: its runner needs
// Stdio/Terminal/FileSystem service implementations that only ship in platform packages,
// which would add a runtime dependency to every plugin for what is a five-verb ops tool.
// A plugin CLI is `<bin> <command> [args...]` — this dispatcher gives that shape the same
// ManagedRuntime + layer graph as the plugin entry, so commands reuse the exact services.
import { connect, type Punktfunk } from "@punktfunk/host";
import { Effect, Layer, ManagedRuntime } from "effect";
import {
type HostClient,
hostClientFromFacade,
type PluginInfo,
pluginInfoLayer,
} from "./host-client.js";
import { HostRequestError } from "./errors.js";
import { loggingLayer } from "./logging.js";
import type { PluginKitDef } from "./runtime.js";
export interface CliCommand<R> {
readonly summary: string;
/** Set when the command works without a running host (scan/preview style). */
readonly offline?: boolean;
readonly run: (
argv: ReadonlyArray<string>,
) => Effect.Effect<void, unknown, R | HostClient | PluginInfo>;
}
/** A HostClient whose calls fail — the offline lane for host-free commands. */
const offlineFacade = (name: string): Punktfunk =>
({
request: async (method: string, path: string) => {
throw new Error(
`${name}: this command ran offline but tried ${method} ${path} — is the host running?`,
);
},
close: () => {},
}) as unknown as Punktfunk;
const usage = <R>(
def: { name: string; version?: string },
commands: Record<string, CliCommand<R>>,
): string => {
const rows = Object.entries(commands)
.map(([cmd, c]) => ` ${cmd.padEnd(12)} ${c.summary}`)
.join("\n");
return `${def.name}${def.version ? ` ${def.version}` : ""}\n\nUsage: punktfunk-plugin-${def.name} <command> [args...]\n\nCommands:\n${rows}\n`;
};
/**
* Run one CLI invocation: dispatch `process.argv[2]`, build the plugin's layer graph,
* run the command, tear down. Exits the process (0 ok / 1 failure / 2 usage).
*/
export const runPluginCli = async <E, R>(opts: {
readonly def: PluginKitDef<E, R>;
readonly commands: Record<string, CliCommand<R>>;
readonly argv?: ReadonlyArray<string>;
}): Promise<void> => {
const argv = opts.argv ?? process.argv.slice(2);
const [name, ...rest] = argv;
const command = name ? opts.commands[name] : undefined;
if (!command) {
console.log(usage(opts.def, opts.commands));
process.exit(name === undefined || name === "help" ? 0 : 2);
}
const pf = command.offline
? offlineFacade(opts.def.name)
: await connect();
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: opts.def.name, version: opts.def.version }),
loggingLayer(opts.def.name),
);
const rt = ManagedRuntime.make(Layer.provideMerge(opts.def.layer, base));
try {
await rt.runPromise(Effect.scoped(command.run(rest)));
process.exitCode = 0;
} catch (e) {
const hint =
e instanceof HostRequestError
? " (is the punktfunk host running?)"
: "";
console.error(`${opts.def.name}: ${name} failed: ${e}${hint}`);
process.exitCode = 1;
} finally {
await rt.dispose();
pf.close();
}
};
+140
View File
@@ -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 }))
// }
+49
View File
@@ -0,0 +1,49 @@
// Kit-level error taxonomy. `Data.TaggedError` (matching the SDK's idiom in
// sdk/src/client.ts) — these never cross HTTP; a plugin's UI-API contract defines its own
// Schema-based errors with status annotations.
import { Data } from "effect";
/** A management-API call through the pf facade failed. */
export class HostRequestError extends Data.TaggedError("HostRequestError")<{
readonly method: string;
readonly path: string;
readonly cause: unknown;
}> {}
/** config.json exists but does not parse/decode. */
export class ConfigParseError extends Data.TaggedError("ConfigParseError")<{
readonly path: string;
readonly issue: string;
}> {}
/**
* config.json is group/world-writable (POSIX). This file controls commands run as the
* host user, so the kit refuses it — the same sshd rule the runner applies to unit files.
*/
export class ConfigPermissionError extends Data.TaggedError(
"ConfigPermissionError",
)<{
readonly path: string;
readonly mode: number;
}> {
override get message(): string {
return `refusing ${this.path}: it is group/world-writable (chmod go-w it first) — this file controls commands run as the host user`;
}
}
/** Persisting config/state failed. */
export class ConfigWriteError extends Data.TaggedError("ConfigWriteError")<{
readonly path: string;
readonly cause: unknown;
}> {}
/** The plugin UI server could not be started/registered. */
export class UiServeError extends Data.TaggedError("UiServeError")<{
readonly cause: unknown;
}> {}
/** A sync pass failed (compute or apply). */
export class SyncError extends Data.TaggedError("SyncError")<{
readonly reason: string;
readonly cause: unknown;
}> {}
+53
View File
@@ -0,0 +1,53 @@
// The pf seam as Effect services. `HostClient.request` keeps the SDK's deliberately
// untyped `pf.request` wire (version-skew-safe against the runner-bundled SDK — design D7);
// typing happens one level up (reconcile.ts owns the wire schemas). Only the plain `pf`
// object crosses the plugin boundary, so no Effect values are shared with the runner's
// bundled effect instance.
import type { Punktfunk } from "@punktfunk/host";
import { Context, Effect, Layer } from "effect";
import { HostRequestError } from "./errors.js";
export interface HostClientService {
/** Raw management-API call (loopback + bearer, via the SDK facade). */
readonly request: (
method: string,
path: string,
body?: unknown,
) => Effect.Effect<unknown, HostRequestError>;
/**
* Escape hatch for SDK helpers that take the facade directly (`servePluginUi`).
* Never share this with code that could leak Effect values back to the runner.
*/
readonly facade: Punktfunk;
}
export class HostClient extends Context.Service<HostClient, HostClientService>()(
"@punktfunk/plugin-kit/HostClient",
) {}
/** Wrap the facade the runner hands to `main` (or `connect()` in the CLI/dev paths). */
export const hostClientFromFacade = (
pf: Punktfunk,
): Layer.Layer<HostClient> =>
Layer.succeed(HostClient)({
request: (method, path, body) =>
Effect.tryPromise({
try: () => pf.request(method, path, body),
catch: (cause) => new HostRequestError({ method, path, cause }),
}),
facade: pf,
});
export interface PluginInfoService {
/** The plugin id (`definePlugin` name, `[a-z][a-z0-9-]*`). */
readonly name: string;
readonly version?: string;
}
export class PluginInfo extends Context.Service<PluginInfo, PluginInfoService>()(
"@punktfunk/plugin-kit/PluginInfo",
) {}
export const pluginInfoLayer = (
info: PluginInfoService,
): Layer.Layer<PluginInfo> => Layer.succeed(PluginInfo)(info);
+45 -4
View File
@@ -1,5 +1,46 @@
// @punktfunk/plugin-kit — Effect-based framework for punktfunk plugins.
// Modules land here as they are implemented; the spikes in test/ validate the
// riskiest seams (HttpApi behind servePluginUi, client prefix handling) first.
export {}
export * from "./errors.js";
export {
atomicWriteFile,
ensureStateDir,
pluginIngestDir,
pluginStateDir,
statePath,
} from "./paths.js";
export {
HostClient,
hostClientFromFacade,
type HostClientService,
PluginInfo,
pluginInfoLayer,
type PluginInfoService,
} from "./host-client.js";
export { loggingLayer } from "./logging.js";
export { type ConfigService, makeConfigService } from "./config.js";
export { type CacheStore, makeCacheStore } from "./cache-store.js";
export {
Artwork,
LaunchSpec,
PrepStep,
ProviderClient,
type ProviderClientService,
ProviderEntry,
} from "./reconcile.js";
export {
definePluginKit,
type PluginKitDef,
runPluginKitDirect,
} from "./runtime.js";
export {
type LastSync,
makeSyncEngine,
type SyncEngine,
type SyncEngineOptions,
type SyncOutcome,
type SyncReason,
type SyncSettings,
type SyncStatus,
} from "./sync-engine.js";
export { httpApiEnv, serveUi, type ServeUiOptions } from "./ui-server.js";
export { sseRoute, type SseRouteOptions } from "./sse.js";
export { type CliCommand, runPluginCli } from "./cli.js";
+24
View File
@@ -0,0 +1,24 @@
// Runner-journal logging: one stamped line per log call, `<ISO> [<plugin>] <message>`,
// matching the format the scripting runner journals (and what the previous hand-rolled
// plugin loggers emitted), so kit-based plugins read consistently in
// `journalctl --user -u punktfunk-scripting`.
import { Cause, Layer, Logger } from "effect";
const render = (message: unknown): string => {
if (typeof message === "string") return message;
if (Array.isArray(message)) return message.map(render).join(" ");
return typeof message === "object" ? JSON.stringify(message) : String(message);
};
/** Replace the default logger with the runner-journal format. */
export const loggingLayer = (plugin: string): Layer.Layer<never> =>
Logger.layer([
Logger.make(({ message, logLevel, cause, date }) => {
const level = logLevel === "Info" ? "" : ` ${logLevel.toUpperCase()}:`;
const causeSuffix =
cause.reasons.length > 0 ? ` ${Cause.pretty(cause)}` : "";
console.log(
`${date.toISOString()} [${plugin}]${level} ${render(message)}${causeSuffix}`,
);
}),
]);
+45
View File
@@ -0,0 +1,45 @@
// Filesystem seam: the SDK owns the canonical path layout (plugin-state, ingest); the kit
// re-exports those and adds the two write primitives every plugin needs — a 0700 state dir
// and atomic (temp + rename, 0600) file writes. All IO is node:fs, wrapped in Effect.
import * as fs from "node:fs";
import * as path from "node:path";
import { pluginIngestDir, pluginStateDir } from "@punktfunk/host";
import { Effect } from "effect";
import { ConfigWriteError } from "./errors.js";
export { pluginIngestDir, pluginStateDir };
/** Create `pluginStateDir(name)` 0700 if absent (idempotent). Returns the dir. */
export const ensureStateDir = (
name: string,
): Effect.Effect<string, ConfigWriteError> =>
Effect.try({
try: () => {
const dir = pluginStateDir(name);
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
return dir;
},
catch: (cause) =>
new ConfigWriteError({ path: pluginStateDir(name), cause }),
});
/**
* Atomic write: temp file (0600) in the same dir, then rename — a crash never leaves a
* torn file, and readers only ever see complete content.
*/
export const atomicWriteFile = (
file: string,
data: string,
): Effect.Effect<void, ConfigWriteError> =>
Effect.try({
try: () => {
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, data, { mode: 0o600 });
fs.renameSync(tmp, file);
},
catch: (cause) => new ConfigWriteError({ path: file, cause }),
});
/** Join inside a plugin's state dir. */
export const statePath = (name: string, file: string): string =>
path.join(pluginStateDir(name), file);
+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;
});
+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;
}),
);
}
+134
View File
@@ -0,0 +1,134 @@
// The async-main boundary — the one place the two-effect-instances problem is handled.
//
// The packaged runner bundles its OWN copy of effect + the SDK; a plugin package's imports
// resolve to the plugin's node_modules. An Effect-shaped `main` would therefore hand the
// runner Effect values built by a different effect instance (Context.Tag identity is not
// shared across instances). The kit sidesteps this by construction: the plugin exports a
// plain async `main`, and EVERYTHING Effect happens inside a ManagedRuntime built from the
// plugin's own effect instance. Only the plain `pf` facade object crosses the boundary.
//
// Shutdown: the runner interrupts its supervision tree on SIGINT/SIGTERM, but it cannot
// cancel an in-flight promise — so the kit installs its own signal hooks, interrupts the
// plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush)
// and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves.
import {
definePlugin,
type PluginDef,
type Punktfunk,
connect,
} from "@punktfunk/host";
import {
Cause,
Effect,
Exit,
Fiber,
Layer,
ManagedRuntime,
Scope,
} from "effect";
import {
type HostClient,
hostClientFromFacade,
type PluginInfo,
pluginInfoLayer,
} from "./host-client.js";
import { loggingLayer } from "./logging.js";
export interface PluginKitDef<E, R> {
/** Plugin id (`[a-z][a-z0-9-]*`) — also the registry id and provider id. */
readonly name: string;
readonly version?: string;
/** The plugin's service graph, built over the kit base (HostClient | PluginInfo). */
readonly layer: Layer.Layer<R, E, HostClient | PluginInfo>;
/** The long-running program. Scoped: acquired resources release on interruption. */
readonly main: Effect.Effect<
void,
E,
R | HostClient | PluginInfo | Scope.Scope
>;
/** Upper bound on graceful teardown after a signal (default 5000 ms). */
readonly shutdownGraceMs?: number;
}
const sleep = (ms: number): Promise<"timeout"> =>
new Promise((resolve) => {
const t = setTimeout(() => resolve("timeout"), ms);
(t as { unref?: () => void }).unref?.();
});
const runWithFacade = async <E, R>(
def: PluginKitDef<E, R>,
pf: Punktfunk,
): Promise<void> => {
const base = Layer.mergeAll(
hostClientFromFacade(pf),
pluginInfoLayer({ name: def.name, version: def.version }),
loggingLayer(def.name),
);
const rt = ManagedRuntime.make(Layer.provideMerge(def.layer, base));
const graceMs = def.shutdownGraceMs ?? 5000;
const fiber = rt.runFork(Effect.scoped(def.main));
// Fires graceMs after the first signal; never before a signal — so racing against it
// is a no-op in normal operation and a hard teardown bound once a stop is requested.
let fireGrace: (v: "timeout") => void = () => {};
const gracePromise = new Promise<"timeout">((resolve) => {
fireGrace = resolve;
});
let stopping = false;
const onSignal = () => {
if (stopping) return;
stopping = true;
void sleep(graceMs).then(fireGrace);
rt.runFork(Fiber.interrupt(fiber));
};
process.on("SIGINT", onSignal);
process.on("SIGTERM", onSignal);
try {
const joined = rt.runPromise(Effect.exit(Fiber.join(fiber)));
const exit = await Promise.race([joined, gracePromise]);
if (exit === "timeout") {
console.error(
`[${def.name}] shutdown exceeded ${graceMs}ms — abandoning teardown`,
);
return;
}
if (Exit.isFailure(exit)) {
const cause = exit.cause;
// Pure interruption (signal-driven) is a clean stop; real failures propagate so
// the runner records the crash and restarts with backoff.
if (Cause.hasFails(cause) || Cause.hasDies(cause)) {
throw new Error(Cause.pretty(cause));
}
}
} finally {
process.removeListener("SIGINT", onSignal);
process.removeListener("SIGTERM", onSignal);
await Promise.race([rt.dispose(), sleep(2000)]);
}
};
/**
* Build the plugin's `PluginDef` (the default export the runner discovers) from an
* Effect-native definition. The returned def has a plain async `main`, so the runner's
* sanity checks and supervision treat it exactly like any hand-written plugin.
*/
export const definePluginKit = <E, R>(def: PluginKitDef<E, R>): PluginDef =>
definePlugin({
name: def.name,
main: (pf: Punktfunk) => runWithFacade(def, pf),
});
/** Dev/CLI entry: `connect()` a facade ourselves and run the same program. */
export const runPluginKitDirect = async <E, R>(
def: PluginKitDef<E, R>,
): Promise<void> => {
const pf = await connect();
try {
await runWithFacade(def, pf);
} finally {
pf.close();
}
};
+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",
},
}),
),
);
};
+285
View File
@@ -0,0 +1,285 @@
// The generic sync engine — the poll/watch/debounce/coalesce/fingerprint machinery that
// was ~duplicated between rom-manager and playnite, as one Effect service.
//
// Semantics are a faithful port of the original Engine guard:
// - single-flight: a sync while one runs records a pending trigger and returns
// AlreadyRunning; the running pass re-fires once ("coalesced") when it finishes
// - content fingerprint (sha256 of the entries JSON) skips the apply when unchanged
// - interval poll + best-effort fs watchers (recursive where the OS supports it, top-dir
// fallback on Linux) with debounce; the poll is the real safety net on SMB/NFS
// - every transition publishes a SyncStatus (the UI's SSE feed)
// All loops live in a private scope that `reconfigure` closes and rebuilds, and the whole
// engine tears down with the Scope it was constructed in.
import { createHash } from "node:crypto";
import * as fs from "node:fs";
import {
Cause,
type Duration,
Effect,
Exit,
PubSub,
Queue,
Ref,
Scope,
Stream,
} from "effect";
import { SyncError } from "./errors.js";
export type SyncReason =
| "startup"
| "poll"
| "fs-change"
| "config-change"
| "manual"
| "coalesced";
export interface LastSync {
readonly fingerprint: string;
readonly count: number;
readonly at: number;
}
export type SyncOutcome<Report> =
| { readonly _tag: "Applied"; readonly report: Report; readonly count: number }
| { readonly _tag: "Unchanged"; readonly report: Report }
| { readonly _tag: "AlreadyRunning" };
export interface SyncStatus<Report> {
readonly syncing: boolean;
readonly lastReport?: Report;
readonly lastSync?: LastSync;
}
export interface SyncSettings {
readonly pollInterval: Duration.Duration;
readonly watch: boolean;
readonly debounce: Duration.Duration;
readonly watchDirs: ReadonlyArray<string>;
}
export interface SyncEngineOptions<
Report,
Entries extends ReadonlyArray<unknown>,
R,
> {
/** Produce the full desired state. Pure of host effects — `apply` does the write. */
readonly compute: (
reason: SyncReason,
) => Effect.Effect<
{ readonly entries: Entries; readonly report: Report },
unknown,
R
>;
/** Push the desired state to the host (usually `ProviderClient.reconcile`). */
readonly apply: (entries: Entries) => Effect.Effect<void, unknown, R>;
/** Override the content fingerprint (default: sha256 of the entries JSON). */
readonly fingerprint?: (entries: Entries) => string;
/** Durable fingerprint storage (usually the plugin's CacheStore). */
readonly lastSync: {
readonly get: Effect.Effect<LastSync | undefined, never, R>;
readonly set: (last: LastSync) => Effect.Effect<void, never, R>;
};
/** Re-read on every `reconfigure` — loops restart with fresh settings. */
readonly settings: Effect.Effect<SyncSettings, never, R>;
}
export interface SyncEngine<Report> {
readonly sync: (
reason: SyncReason,
) => Effect.Effect<SyncOutcome<Report>, SyncError>;
readonly status: Effect.Effect<SyncStatus<Report>>;
/** Emits on every sync start/finish — the UI's SSE feed. */
readonly changes: Stream.Stream<SyncStatus<Report>>;
/** Initial sync + start poll/watch loops (scoped to the construction Scope). */
readonly start: Effect.Effect<void>;
/** Restart loops with fresh settings, then sync("config-change"). */
readonly reconfigure: Effect.Effect<void>;
}
const defaultFingerprint = (entries: unknown): string =>
createHash("sha256").update(JSON.stringify(entries)).digest("hex");
/** Best-effort watcher set over `dirs`: recursive where supported, top-dir fallback. */
const openWatchers = (
dirs: ReadonlyArray<string>,
onEvent: () => void,
): fs.FSWatcher[] => {
const out: fs.FSWatcher[] = [];
for (const dir of dirs) {
try {
out.push(fs.watch(dir, { recursive: true }, onEvent));
} catch {
try {
out.push(fs.watch(dir, onEvent));
} catch {
// unwatchable (poll covers it)
}
}
}
return out;
};
export const makeSyncEngine = <
Report,
Entries extends ReadonlyArray<unknown>,
R,
>(
opts: SyncEngineOptions<Report, Entries, R>,
): Effect.Effect<SyncEngine<Report>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const ctx = yield* Effect.context<R>();
const run = <A, E>(eff: Effect.Effect<A, E, R>): Effect.Effect<A, E> =>
Effect.provide(eff, ctx);
const fingerprint = opts.fingerprint ?? defaultFingerprint;
const flags = yield* Ref.make({ syncing: false, pending: false });
const lastReport = yield* Ref.make<Report | undefined>(undefined);
const hub = yield* PubSub.unbounded<SyncStatus<Report>>();
const status: Effect.Effect<SyncStatus<Report>> = Effect.gen(function* () {
const f = yield* Ref.get(flags);
return {
syncing: f.syncing,
lastReport: yield* Ref.get(lastReport),
lastSync: yield* run(opts.lastSync.get),
};
});
const publish = status.pipe(
Effect.flatMap((s) => PubSub.publish(hub, s)),
Effect.asVoid,
);
const doSync = (
reason: SyncReason,
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
Effect.gen(function* () {
const { entries, report } = yield* run(opts.compute(reason)).pipe(
Effect.mapError((cause) => new SyncError({ reason, cause })),
);
yield* Ref.set(lastReport, report);
const fp = fingerprint(entries);
const prev = yield* run(opts.lastSync.get);
if (prev?.fingerprint === fp) {
yield* Effect.log(
`sync (${reason}): no changes (${entries.length} entries)`,
);
return { _tag: "Unchanged", report } as const;
}
yield* run(opts.apply(entries)).pipe(
Effect.mapError((cause) => new SyncError({ reason, cause })),
);
yield* run(
opts.lastSync.set({
fingerprint: fp,
count: entries.length,
at: Date.now(),
}),
);
yield* Effect.log(
`sync (${reason}): reconciled ${entries.length} entries`,
);
return { _tag: "Applied", report, count: entries.length } as const;
});
// Errors must never kill a loop — log and carry on (the original's catch).
const safeSync = (reason: SyncReason): Effect.Effect<void> =>
sync(reason).pipe(
Effect.catch((e: SyncError) =>
Effect.logWarning(`sync (${reason}) failed: ${e.cause}`),
),
Effect.asVoid,
);
const sync = (
reason: SyncReason,
): Effect.Effect<SyncOutcome<Report>, SyncError> =>
Ref.modify(flags, (f) =>
f.syncing
? ([false, { ...f, pending: true }] as const)
: ([true, { ...f, syncing: true }] as const),
).pipe(
Effect.flatMap((acquired) => {
if (!acquired) {
return Effect.succeed({ _tag: "AlreadyRunning" } as const);
}
return publish.pipe(
Effect.andThen(doSync(reason)),
Effect.ensuring(
Effect.gen(function* () {
const pending = yield* Ref.modify(
flags,
(f) =>
[f.pending, { syncing: false, pending: false }] as const,
);
yield* publish;
if (pending) {
yield* Effect.forkDetach(safeSync("coalesced"));
}
}),
),
);
}),
);
// ---------------------------------------------------------------- loops
const loopScope = yield* Ref.make<Scope.Closeable | undefined>(undefined);
const stopLoops = Effect.gen(function* () {
const prev = yield* Ref.getAndSet(loopScope, undefined);
if (prev) yield* Scope.close(prev, Exit.void);
});
const startLoops: Effect.Effect<void> = Effect.gen(function* () {
yield* stopLoops;
const scope = yield* Scope.make();
yield* Ref.set(loopScope, scope);
const settings = yield* run(opts.settings);
const pollLoop = Effect.forever(
Effect.sleep(settings.pollInterval).pipe(
Effect.andThen(safeSync("poll")),
),
);
yield* Effect.forkIn(pollLoop, scope);
if (settings.watch && settings.watchDirs.length > 0) {
const watchStream = Stream.callback<void>((queue) =>
Effect.acquireRelease(
Effect.sync(() =>
openWatchers(settings.watchDirs, () => {
Queue.offerUnsafe(queue, undefined);
}),
),
(watchers) =>
Effect.sync(() => {
for (const w of watchers) {
try {
w.close();
} catch {
// already closed
}
}
}),
),
);
const watchLoop = watchStream.pipe(
Stream.debounce(settings.debounce),
Stream.runForEach(() => safeSync("fs-change")),
);
yield* Effect.forkIn(watchLoop, scope);
}
});
// Loop teardown rides the construction Scope (plugin shutdown).
yield* Effect.addFinalizer(() => stopLoops);
return {
sync,
status,
changes: Stream.fromPubSub(hub),
start: safeSync("startup").pipe(Effect.andThen(startLoops)),
reconfigure: startLoops.pipe(
Effect.andThen(safeSync("config-change")),
),
} satisfies SyncEngine<Report>;
});
+241
View File
@@ -0,0 +1,241 @@
/* punktfunk plugin-UI theme — the console's violet identity, packaged so plugin UIs
* render indistinguishably from the console they're embedded in.
*
* Extracted from the console's web/src/styles.css + timing-functions.css (keep in sync —
* console/theme unification is a tracked follow-up). Consume as the FIRST import of the
* plugin UI's Tailwind entry, then add the app-specific lines yourself:
*
* @import "tailwindcss";
* @import "tw-animate-css";
* @import "@punktfunk/plugin-kit/theme.css";
* @custom-variant dark (&:is(.dark *));
* @source "../node_modules/@unom/ui/dist/**\/*.{js,mjs}";
*
* Pin `<html class="dark">` (the console does) — removing the class yields light.
*/
/* ── Penner easing tokens — @unom/ui's accordion/collapsible/material resolve these. ── */
@theme {
--ease-in-sine: cubic-bezier(0.47, 0, 0.745, 0.715);
--ease-out-sine: cubic-bezier(0.39, 0.575, 0.565, 1);
--ease-in-out-sine: cubic-bezier(0.445, 0.05, 0.55, 0.95);
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
}
/* ── punktfunk brand · violet product chrome ────────────────────────────────
Light (lavender) is the :root default; dark (violet-tinted app-icon chrome) is the
`.dark` override. The token set feeds BOTH @unom/ui's semantic contract
(--brand/--main/--neutral…) and the shadcn-style vocabulary (--background/--card/…),
mapped onto one palette. Indirection tokens live in :root only — CSS resolves var()
per-theme at use time, so .dark overrides just the raw values. */
:root {
--radius: 0.625rem;
/* Brand — the violet lens mark (from the punktfunk app icon). Theme-independent. */
--pf-brand: #6c5bf3; /* deep violet — primary on light */
--pf-brand-light: #a79ff8; /* light violet — primary on dark */
--pf-highlight: #d2c9fb; /* lens highlight */
/* Surfaces — light · lavender (white bg, faint-violet cards/borders). */
--background: #ffffff;
--foreground: #1b1430;
--card: #f6f2ff;
--card-foreground: #1b1430;
--popover: #ffffff;
--popover-foreground: #1b1430;
--muted: #f1ecfd;
--muted-foreground: #6f6a86;
--secondary: #ece6fb;
--secondary-foreground: #1b1430;
/* shadcn `accent` = subtle hover surface; also @unom/ui's card ring colour,
so we tint it toward the brand violet (the same in both themes). */
--accent: var(--pf-brand);
--accent-foreground: #ffffff;
--border: #e4dcf7;
--input: #e4dcf7;
--ring: var(--pf-brand);
/* Primary = the brand (buttons, active nav, default badges). */
--primary: var(--pf-brand);
--primary-foreground: #ffffff;
--success: oklch(0.6 0.14 160);
--warn: oklch(0.84 0.16 84);
--destructive: oklch(0.55 0.22 18);
--destructive-foreground: #ffffff;
/* ── @unom/ui semantic token contract (its components read these names). ──
These are indirections — they follow the raw tokens above per-theme. */
--main: var(--foreground);
--brand: var(--pf-brand);
--brand-light: var(--pf-brand-light);
--highlight: var(--pf-highlight);
--neutral: var(--card); /* @unom card default surface (bg-neutral) */
--neutral-accent: var(
--secondary
); /* accent / nested surface (bg-neutral-accent) */
--neutral-highlight: var(--border);
--error: var(--destructive);
--font-display: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
--font-sans: "Geist Variable", ui-sans-serif, system-ui, sans-serif;
/* @unom/ui radius/spacing contract (pill buttons, rounded cards, tall inputs). */
--radius-card-min: var(--radius);
}
/* Dark · the violet-tinted app-icon chrome. Overrides only the raw values —
the indirection tokens in :root resolve to these automatically. */
.dark {
--background: #141019;
--foreground: oklch(0.985 0 0);
--card: #1c1530;
--card-foreground: oklch(0.985 0 0);
--popover: #1c1530;
--popover-foreground: oklch(0.985 0 0);
--muted: #1f1830;
--muted-foreground: oklch(0.728 0.03 286);
--secondary: #241c3d;
--secondary-foreground: oklch(0.985 0 0);
--border: #2a2148;
--input: #2a2148;
--ring: var(--pf-brand-light);
/* Lighter violet reads better against the dark surface. */
--primary: var(--pf-brand-light);
--primary-foreground: #141019;
--success: oklch(0.7 0.15 160);
--warn: oklch(0.87 0.15 84);
--destructive: oklch(0.62 0.21 18);
--destructive-foreground: oklch(0.985 0 0);
}
/* Map the palette to Tailwind colour/util tokens — both the shadcn vocabulary
and @unom/ui's, resolved to one set of values. */
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-button: 9999px;
--radius-card: calc(var(--radius) * 2);
--radius-main: calc(var(--radius) * 2);
--spacing-input-height: 3rem;
--spacing-padding-card: 1.25rem;
--spacing-card: 1.5rem;
--spacing-main: 15px;
--font-sans: var(--font-sans);
--font-display: var(--font-display);
/* shadcn-style colour tokens. */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-success: var(--success);
--color-warn: var(--warn);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
/* @unom/ui colour tokens. */
--color-main: var(--main);
--color-brand: var(--brand);
--color-brand-light: var(--brand-light);
--color-neutral: var(--neutral);
--color-neutral-accent: var(--neutral-accent);
--color-neutral-highlight: var(--neutral-highlight);
--color-highlight: var(--highlight);
--color-error: var(--error);
}
/* Accordion / collapsible keyframes @unom/ui's interactive surfaces animate to. */
@theme {
--animate-accordion-down: accordion-down 0.4s var(--ease-out-quart);
--animate-accordion-up: accordion-up 0.4s var(--ease-out-quart);
--animate-collapsible-down: collapsible-down 0.4s var(--ease-out-quart);
--animate-collapsible-up: collapsible-up 0.4s var(--ease-out-quart);
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(--radix-accordion-content-height);
}
}
@keyframes accordion-up {
from {
height: var(--radix-accordion-content-height);
}
to {
height: 0;
}
}
@keyframes collapsible-down {
from {
height: 0;
opacity: 0;
}
to {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
}
@keyframes collapsible-up {
from {
height: var(--radix-collapsible-content-height);
opacity: 1;
}
to {
height: 0;
opacity: 0;
}
}
}
@layer base {
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-sans);
font-feature-settings:
"rlig" 1,
"calt" 1;
}
}
+88
View File
@@ -0,0 +1,88 @@
// The Effect face of `servePluginUi`: build the plugin's local API from an HttpApi (or
// any HttpRouter route layers), mount it as the SDK server's `fetch` handler, and manage
// register/renew/deregister through Scope. Validated end-to-end by the phase-0 spike:
// core-only env layers, no platform package, SPA fallthrough preserved.
import { type PluginUiHandle, servePluginUi } from "@punktfunk/host";
import { Effect, FileSystem, Layer, Path, Scope } from "effect";
import { Etag, HttpPlatform, HttpRouter } from "effect/unstable/http";
import { UiServeError } from "./errors.js";
import { HostClient, PluginInfo } from "./host-client.js";
/**
* Everything `HttpApiBuilder.layer` needs beyond the router, satisfied from effect core —
* plugins never pull a platform package for their UI API.
*/
export const httpApiEnv = Layer.provideMerge(
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
FileSystem.layerNoop({}),
);
export interface ServeUiOptions {
/** Console nav title. */
readonly title: string;
/** lucide icon name for the console nav. */
readonly icon?: string;
/** Defaults to `PluginInfo.version`. */
readonly version?: string;
/** Built SPA directory (served with SPA fallback by the SDK). */
readonly staticDir?: string | URL;
/**
* The plugin API: `HttpApiBuilder.layer(api)` + group handler layers + raw routes
* (e.g. `sseRoute`), with plugin services already provided. `httpApiEnv` is provided
* here — only `HttpRouter` may remain open.
*/
readonly api: Layer.Layer<never, never, HttpRouter.HttpRouter>;
/** Path prefix owned by the API handler (default "/api/"). */
readonly apiPrefix?: string;
}
/**
* Serve the plugin UI (scoped): the API handler and the loopback server come up together
* and the release path deregisters from the host, stops the server, and disposes the
* handler runtime — in that order.
*/
export const serveUi = (
opts: ServeUiOptions,
): Effect.Effect<
PluginUiHandle,
UiServeError,
HostClient | PluginInfo | Scope.Scope
> =>
Effect.gen(function* () {
const host = yield* HostClient;
const info = yield* PluginInfo;
const prefix = opts.apiPrefix ?? "/api/";
const { handler, dispose } = HttpRouter.toWebHandler(
Layer.provide(opts.api, httpApiEnv),
);
yield* Effect.addFinalizer(() =>
Effect.promise(() => dispose()).pipe(Effect.ignore),
);
const fetch = async (req: Request): Promise<Response | undefined> => {
const url = new URL(req.url);
if (!url.pathname.startsWith(prefix)) return undefined; // → static SPA
return handler(req);
};
return yield* Effect.acquireRelease(
Effect.tryPromise({
try: () =>
servePluginUi(host.facade, {
id: info.name,
title: opts.title,
...(opts.icon !== undefined ? { icon: opts.icon } : {}),
...((opts.version ?? info.version) !== undefined
? { version: opts.version ?? info.version }
: {}),
...(opts.staticDir !== undefined
? { staticDir: opts.staticDir }
: {}),
fetch,
}),
catch: (cause) => new UiServeError({ cause }),
}),
(handle) => Effect.promise(() => handle.close()).pipe(Effect.ignore),
);
});