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,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>;
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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 }))
|
||||
// }
|
||||
@@ -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;
|
||||
}> {}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user