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,60 @@
|
||||
# @punktfunk/plugin-kit
|
||||
|
||||
The Effect-based framework punktfunk plugins are built on. It owns everything that is the
|
||||
same in every plugin — lifecycle, config/state, the sync engine, UI serving, the CLI
|
||||
scaffold, logging — so a plugin is just its domain logic, its HttpApi contract, and its UI.
|
||||
The reference consumer (and the blueprint to copy) is
|
||||
[`punktfunk-plugin-rom-manager`](https://git.unom.io/unom/punktfunk-plugin-rom-manager).
|
||||
|
||||
Built on [`@punktfunk/host`](../sdk) (the SDK stays the low-level host client; the kit is
|
||||
the opinionated plugin layer on top). Effect `4.x` and the SDK are peer dependencies —
|
||||
the plugin's own copies are the only copies.
|
||||
|
||||
## The one rule: async at the boundary, Effect inside
|
||||
|
||||
The packaged runner bundles its own effect + SDK; a plugin's imports resolve to the
|
||||
plugin's node_modules. Effect values must therefore never cross the plugin boundary
|
||||
(`Context.Tag` identity is per-instance). `definePluginKit` enforces this by construction:
|
||||
you write Effect, it exports a plain async-`main` `PluginDef`, and a `ManagedRuntime`
|
||||
built from *your* effect instance runs everything. SIGINT/SIGTERM interrupt the plugin
|
||||
fiber (scoped finalizers run: UI deregistration, watcher close), bounded by
|
||||
`shutdownGraceMs`.
|
||||
|
||||
```ts
|
||||
import { definePluginKit, serveUi } from "@punktfunk/plugin-kit";
|
||||
import { Effect, Layer } from "effect";
|
||||
|
||||
export default definePluginKit({
|
||||
name: "my-plugin",
|
||||
version: "0.1.0",
|
||||
layer: MyServices.layer, // over the kit base: HostClient | PluginInfo
|
||||
main: Effect.gen(function* () {
|
||||
const engine = yield* MySync;
|
||||
yield* engine.start;
|
||||
yield* serveUi({ title: "My Plugin", icon: "puzzle", staticDir, api: MyApiLive });
|
||||
yield* Effect.never;
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Export | What it owns |
|
||||
| --- | --- |
|
||||
| `definePluginKit` / `runPluginKitDirect` | the async-main boundary + ManagedRuntime + signal handling |
|
||||
| `HostClient`, `PluginInfo` | the `pf` facade as services (`request` = the skew-safe untyped seam) |
|
||||
| `makeConfigService` | Schema-driven config: raw shape on disk, defaults ONLY in the Schema (`withDecodingDefaultKey` + `encodingStrategy: "omit"`), atomic writes, world-writable refusal, `changes` stream |
|
||||
| `makeCacheStore` | disposable derived state (corrupt/absent → empty, write-through) |
|
||||
| `ProviderClient` + wire schemas | typed library-provider reconcile over the untyped wire |
|
||||
| `makeSyncEngine` | poll + fs-watch + debounce + single-flight coalescing + fingerprint skip + status feed |
|
||||
| `serveUi` / `httpApiEnv` | an `effect/unstable/httpapi` HttpApi behind the SDK's `servePluginUi`, core-only layers |
|
||||
| `sseRoute` | the status SSE endpoint (httpapi has no event-stream media type) |
|
||||
| `runPluginCli` | `<bin> <command>` dispatcher reusing the plugin's layer graph (deliberately not `effect/unstable/cli` — that would drag platform packages into every plugin) |
|
||||
| `loggingLayer` | runner-journal line format |
|
||||
| `@punktfunk/plugin-kit/react` | browser glue: `createPluginRouter` (path→hash→fallback deep-link restore + `pf-ui:navigate`), `resolvePluginBase`, `useIsEmbedded`, `ResultGate`, `sseAtom` |
|
||||
| `@punktfunk/plugin-kit/theme.css` | the console's violet identity for plugin UIs (import first in your Tailwind entry) |
|
||||
|
||||
## Publishing
|
||||
|
||||
Tag `plugin-kit-vX.Y.Z` (matching `package.json`) — `.gitea/workflows/plugin-kit-publish.yml`
|
||||
typechecks, tests, builds, and publishes to the Gitea registry.
|
||||
+8
-1
@@ -7,6 +7,7 @@
|
||||
"devDependencies": {
|
||||
"@punktfunk/host": "file:../sdk",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "^19.2.16",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
@@ -15,7 +16,7 @@
|
||||
"@punktfunk/host": "^0.1.1",
|
||||
"@unom/ui": ">=0.9.0",
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"react": ">=19",
|
||||
"react": "^19.2.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@effect/atom-react",
|
||||
@@ -49,12 +50,16 @@
|
||||
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
|
||||
"@punktfunk/host": ["@punktfunk/host@file:../sdk", { "devDependencies": { "@effect/openapi-generator": "4.0.0-beta.98", "@effect/platform-node": "4.0.0-beta.98", "@types/bun": "^1.3.0", "effect": "^4.0.0-beta.98", "typescript": "^5.9.3" }, "optionalDependencies": { "undici": "^7.0.0" }, "peerDependencies": { "effect": "^4.0.0-beta.98" }, "bin": { "punktfunk-scripting": "./dist/runner-cli.js" } }],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="],
|
||||
|
||||
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -73,6 +78,8 @@
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||
|
||||
@@ -39,20 +39,19 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "^4.0.0-beta.98",
|
||||
"@punktfunk/host": "^0.1.1",
|
||||
"react": ">=19",
|
||||
"@effect/atom-react": "^4.0.0-beta.99",
|
||||
"@unom/ui": ">=0.9.0"
|
||||
"@punktfunk/host": "^0.1.2",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": { "optional": true },
|
||||
"@effect/atom-react": { "optional": true },
|
||||
"@unom/ui": { "optional": true }
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect": "4.0.0-beta.99",
|
||||
"@punktfunk/host": "file:../sdk",
|
||||
"@types/bun": "^1.3.0",
|
||||
"@types/react": "^19.2.16",
|
||||
"effect": "4.0.0-beta.99",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// ConfigService semantics (the canonical suite — ported from rom-manager's state.test.ts
|
||||
// obligations): raw round-trip, schema defaults at decode only, atomic writes, missing
|
||||
// file == defaults, world-writable refusal, changes stream.
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Effect, Fiber, Schema, Stream } from "effect";
|
||||
import {
|
||||
type ConfigService,
|
||||
makeConfigService,
|
||||
pluginInfoLayer,
|
||||
pluginStateDir,
|
||||
} from "../src/index.js";
|
||||
|
||||
const TestSchema = Schema.Struct({
|
||||
roots: Schema.Array(Schema.String).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed([]), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
sync: Schema.Struct({
|
||||
pollMinutes: Schema.Number.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(15), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
watch: Schema.Boolean.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(true), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
}).pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed({}), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const PLUGIN = "kit-config-test";
|
||||
let tmp: string;
|
||||
|
||||
const withService = <A>(
|
||||
f: (svc: ConfigService<typeof TestSchema>) => Effect.Effect<A, unknown>,
|
||||
): Promise<A> =>
|
||||
Effect.runPromise(
|
||||
makeConfigService({ schema: TestSchema }).pipe(
|
||||
Effect.flatMap(f),
|
||||
Effect.provide(pluginInfoLayer({ name: PLUGIN })),
|
||||
) as Effect.Effect<A, never>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-kit-config-"));
|
||||
process.env.PUNKTFUNK_CONFIG_DIR = tmp;
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.PUNKTFUNK_CONFIG_DIR;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("ConfigService", () => {
|
||||
test("missing file decodes to full defaults", async () => {
|
||||
const loaded = await withService((svc) => svc.load);
|
||||
expect(loaded).toEqual({
|
||||
roots: [],
|
||||
sync: { pollMinutes: 15, watch: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("saveRaw persists the RAW shape verbatim (no defaults baked in)", async () => {
|
||||
await withService((svc) => svc.saveRaw({ roots: ["/roms"] }));
|
||||
const onDisk = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
expect(onDisk).toEqual({ roots: ["/roms"] }); // no sync block materialized
|
||||
const loaded = await withService((svc) => svc.load);
|
||||
expect(loaded.sync.pollMinutes).toBe(15);
|
||||
});
|
||||
|
||||
test("saveRaw rejects an invalid raw config and leaves the file untouched", async () => {
|
||||
await withService((svc) => svc.saveRaw({ roots: ["/a"] }));
|
||||
const before = fs.readFileSync(
|
||||
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||
"utf8",
|
||||
);
|
||||
const exit = await withService((svc) =>
|
||||
Effect.exit(svc.saveRaw({ roots: "not-an-array" })),
|
||||
);
|
||||
expect(exit._tag).toBe("Failure");
|
||||
const after = fs.readFileSync(
|
||||
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||
"utf8",
|
||||
);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
test("unknown keys in the file are tolerated and survive loadRaw", async () => {
|
||||
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginStateDir(PLUGIN), "config.json"),
|
||||
JSON.stringify({ roots: [], ui: { port: 5885 }, devEntry: true }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
const raw = (await withService((svc) => svc.loadRaw)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(raw.ui).toEqual({ port: 5885 }); // verbatim — a save decides what survives
|
||||
});
|
||||
|
||||
test("refuses a group/world-writable config file (POSIX)", async () => {
|
||||
if (process.platform === "win32") return;
|
||||
fs.mkdirSync(pluginStateDir(PLUGIN), { recursive: true, mode: 0o700 });
|
||||
const file = path.join(pluginStateDir(PLUGIN), "config.json");
|
||||
fs.writeFileSync(file, "{}");
|
||||
fs.chmodSync(file, 0o666); // bypass umask — writeFileSync's mode is masked
|
||||
const exit = await withService((svc) => Effect.exit(svc.load));
|
||||
expect(exit._tag).toBe("Failure");
|
||||
if (exit._tag === "Failure") {
|
||||
expect(String(exit.cause)).toContain("ConfigPermissionError");
|
||||
}
|
||||
});
|
||||
|
||||
test("changes stream emits the decoded config after saveRaw", async () => {
|
||||
const decoded = await withService((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* Effect.forkChild(
|
||||
svc.changes.pipe(Stream.take(1), Stream.runCollect),
|
||||
);
|
||||
yield* Effect.sleep("20 millis"); // let the subscription attach
|
||||
yield* svc.saveRaw({ roots: ["/x"], sync: { pollMinutes: 5 } });
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
);
|
||||
expect(decoded).toEqual([
|
||||
{ roots: ["/x"], sync: { pollMinutes: 5, watch: true } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -43,11 +43,11 @@ const groupLive = HttpApiBuilder.group(api, "spike", (handlers) =>
|
||||
.handle("echo", ({ payload }) => Effect.succeed({ echoed: payload.msg })),
|
||||
);
|
||||
|
||||
// Core-only environment for HttpApiBuilder: no platform package needed.
|
||||
const env = Layer.mergeAll(
|
||||
Etag.layerWeak,
|
||||
Path.layer,
|
||||
HttpPlatform.layer.pipe(Layer.provide(FileSystem.layerNoop({}))),
|
||||
// Core-only environment for HttpApiBuilder: no platform package needed. FileSystem is
|
||||
// provideMerge'd so both HttpPlatform.layer and HttpApiBuilder see it satisfied.
|
||||
const env = Layer.provideMerge(
|
||||
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
|
||||
FileSystem.layerNoop({}),
|
||||
);
|
||||
|
||||
const appLayer = HttpApiBuilder.layer(api).pipe(
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// sseRoute: frame format + integration with the toWebHandler pipeline.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Layer, Schedule, Stream } from "effect";
|
||||
import { HttpRouter } from "effect/unstable/http";
|
||||
import { httpApiEnv, sseRoute } from "../src/index.js";
|
||||
|
||||
describe("sseRoute", () => {
|
||||
test("streams event frames in SSE wire format", async () => {
|
||||
const stream = Stream.fromSchedule(Schedule.spaced("5 millis")).pipe(
|
||||
Stream.map((n) => ({ tick: Number(n) })),
|
||||
Stream.take(3),
|
||||
);
|
||||
const routes = sseRoute("/api/events", stream, {
|
||||
event: "status",
|
||||
pingSeconds: 0,
|
||||
});
|
||||
const { handler, dispose } = HttpRouter.toWebHandler(
|
||||
Layer.provide(routes, httpApiEnv),
|
||||
);
|
||||
try {
|
||||
const res = await handler(
|
||||
new Request("http://127.0.0.1/api/events"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain(
|
||||
"text/event-stream",
|
||||
);
|
||||
const text = await res.text();
|
||||
expect(text).toContain('event: status\ndata: {"tick":0}\n\n');
|
||||
expect(text).toContain('event: status\ndata: {"tick":2}\n\n');
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
// SyncEngine semantics: fingerprint skip, single-flight coalescing, status feed.
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { Duration, Effect, Fiber, Ref, Scope, Stream } from "effect";
|
||||
import {
|
||||
type LastSync,
|
||||
makeSyncEngine,
|
||||
type SyncOutcome,
|
||||
} from "../src/index.js";
|
||||
|
||||
interface Report {
|
||||
readonly included: number;
|
||||
}
|
||||
|
||||
const harness = (opts?: {
|
||||
computeDelayMs?: number;
|
||||
entries?: () => ReadonlyArray<string>;
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const applied = yield* Ref.make(0);
|
||||
const last = yield* Ref.make<LastSync | undefined>(undefined);
|
||||
const entries = opts?.entries ?? (() => ["a", "b"]);
|
||||
const engine = yield* makeSyncEngine<Report, ReadonlyArray<string>, never>(
|
||||
{
|
||||
compute: () =>
|
||||
Effect.suspend(() => {
|
||||
const e = entries();
|
||||
return Effect.succeed({
|
||||
entries: e,
|
||||
report: { included: e.length },
|
||||
});
|
||||
}).pipe(
|
||||
opts?.computeDelayMs
|
||||
? Effect.delay(Duration.millis(opts.computeDelayMs))
|
||||
: (x) => x,
|
||||
),
|
||||
apply: () => Ref.update(applied, (n) => n + 1),
|
||||
lastSync: {
|
||||
get: Ref.get(last),
|
||||
set: (l) => Ref.set(last, l),
|
||||
},
|
||||
settings: Effect.succeed({
|
||||
pollInterval: Duration.minutes(60),
|
||||
watch: false,
|
||||
debounce: Duration.millis(10),
|
||||
watchDirs: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
return { engine, applied, last };
|
||||
});
|
||||
|
||||
const run = <A>(eff: Effect.Effect<A, unknown, Scope.Scope>): Promise<A> =>
|
||||
Effect.runPromise(Effect.scoped(eff) as Effect.Effect<A>);
|
||||
|
||||
describe("SyncEngine", () => {
|
||||
test("first sync applies; unchanged content skips the apply", async () => {
|
||||
const { first, second, count } = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
const first = yield* h.engine.sync("manual");
|
||||
const second = yield* h.engine.sync("manual");
|
||||
return {
|
||||
first,
|
||||
second,
|
||||
count: yield* Ref.get(h.applied),
|
||||
};
|
||||
}),
|
||||
);
|
||||
expect(first._tag).toBe("Applied");
|
||||
if (first._tag === "Applied") expect(first.count).toBe(2);
|
||||
expect(second._tag).toBe("Unchanged");
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("changed content re-applies", async () => {
|
||||
let call = 0;
|
||||
const { outcomes, count } = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness({
|
||||
entries: () => (call++ === 0 ? ["a"] : ["a", "b"]),
|
||||
});
|
||||
const o1 = yield* h.engine.sync("manual");
|
||||
const o2 = yield* h.engine.sync("manual");
|
||||
return { outcomes: [o1, o2], count: yield* Ref.get(h.applied) };
|
||||
}),
|
||||
);
|
||||
expect(outcomes.map((o: SyncOutcome<Report>) => o._tag)).toEqual([
|
||||
"Applied",
|
||||
"Applied",
|
||||
]);
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
|
||||
test("concurrent trigger returns AlreadyRunning and coalesces into a follow-up", async () => {
|
||||
const { during, count } = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness({ computeDelayMs: 50 });
|
||||
const fiber = yield* Effect.forkChild(h.engine.sync("manual"));
|
||||
yield* Effect.sleep("10 millis");
|
||||
const during = yield* h.engine.sync("manual");
|
||||
yield* Fiber.join(fiber);
|
||||
// the coalesced re-run is forked detached — give it a beat
|
||||
yield* Effect.sleep("120 millis");
|
||||
return { during, count: yield* Ref.get(h.applied) };
|
||||
}),
|
||||
);
|
||||
expect(during._tag).toBe("AlreadyRunning");
|
||||
// first sync applied; the coalesced pass found identical content → skipped
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
test("status feed publishes syncing transitions", async () => {
|
||||
const statuses = await run(
|
||||
Effect.gen(function* () {
|
||||
const h = yield* harness();
|
||||
const fiber = yield* Effect.forkChild(
|
||||
h.engine.changes.pipe(Stream.take(2), Stream.runCollect),
|
||||
);
|
||||
yield* Effect.sleep("20 millis");
|
||||
yield* h.engine.sync("manual");
|
||||
return yield* Fiber.join(fiber);
|
||||
}),
|
||||
);
|
||||
expect(statuses[0]?.syncing).toBe(true);
|
||||
expect(statuses[1]?.syncing).toBe(false);
|
||||
expect(statuses[1]?.lastSync?.count).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user