diff --git a/.gitea/workflows/plugin-kit-publish.yml b/.gitea/workflows/plugin-kit-publish.yml new file mode 100644 index 00000000..61ec3ead --- /dev/null +++ b/.gitea/workflows/plugin-kit-publish.yml @@ -0,0 +1,69 @@ +# Publish the plugin framework (@punktfunk/plugin-kit) to the Gitea npm registry +# (https://git.unom.io/api/packages/unom/npm/). +# +# Trigger: push a tag `plugin-kit-vX.Y.Z` (must equal plugin-kit/package.json "version"), +# or run manually. Versions independently of the app's `v*` and the SDK's `sdk-v*` tags. +# +# The kit's devDependency on @punktfunk/host is `file:../sdk`, so the SDK's dist must be +# built BEFORE the kit's `bun install` copies it. +# +# Auth: REGISTRY_TOKEN — the same repo Actions secret sdk-publish.yml uses. +name: plugin-kit-publish + +on: + push: + tags: ['plugin-kit-v*'] + workflow_dispatch: + +jobs: + publish: + runs-on: ubuntu-24.04 + container: + image: oven/bun:1 + timeout-minutes: 15 + steps: + # oven/bun's slim base ships neither git, a CA bundle, nor node — actions/checkout's HTTPS + # fetch needs git + ca-certificates, and the version-guard step below uses node. + - name: Install git + node + CA certs + run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git nodejs + + - uses: actions/checkout@v4 + + - name: Build the SDK (file:../sdk dependency source) + working-directory: sdk + run: | + bun install --frozen-lockfile --ignore-scripts + bun run build + + - name: Install dependencies + working-directory: plugin-kit + run: bun install --frozen-lockfile --ignore-scripts + + - name: Typecheck + working-directory: plugin-kit + run: bun run typecheck + + - name: Test + working-directory: plugin-kit + run: bun test + + - name: Build (dist/ JS + .d.ts + theme.css) + working-directory: plugin-kit + run: bun run build + + - name: Tag matches package version + if: startsWith(github.ref, 'refs/tags/') + working-directory: plugin-kit + run: | + TAG="${GITHUB_REF_NAME#plugin-kit-v}" + PKG="$(node -p "require('./package.json').version")" + test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME does not match package version $PKG"; exit 1; } + + - name: Publish to Gitea registry + working-directory: plugin-kit + env: + NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + run: | + test -n "$NODE_AUTH_TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } + printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$NODE_AUTH_TOKEN" >> .npmrc + bun publish diff --git a/plugin-kit/README.md b/plugin-kit/README.md new file mode 100644 index 00000000..8004ecad --- /dev/null +++ b/plugin-kit/README.md @@ -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` | ` ` 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. diff --git a/plugin-kit/bun.lock b/plugin-kit/bun.lock index c45f8296..8206a312 100644 --- a/plugin-kit/bun.lock +++ b/plugin-kit/bun.lock @@ -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=="], diff --git a/plugin-kit/package.json b/plugin-kit/package.json index c1c72326..06367b4b 100644 --- a/plugin-kit/package.json +++ b/plugin-kit/package.json @@ -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" } } diff --git a/plugin-kit/src/cache-store.ts b/plugin-kit/src/cache-store.ts new file mode 100644 index 00000000..3be9d4ea --- /dev/null +++ b/plugin-kit/src/cache-store.ts @@ -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 { + readonly get: Effect.Effect; + /** Atomically update the cache (Ref + write-through). Returns the `modify` result. */ + readonly modify: ( + f: (current: S["Type"]) => readonly [A, S["Type"]], + ) => Effect.Effect; + /** `modify` without a result. */ + readonly update: ( + f: (current: S["Type"]) => S["Type"], + ) => Effect.Effect; + /** Absolute path of the cache file (status views). */ + readonly path: string; +} + +export const makeCacheStore = (opts: { + readonly schema: S; + readonly empty: S["Type"]; + readonly fileName?: string; +}): Effect.Effect, 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; + } catch { + return Effect.succeed(opts.empty); + } + }); + const ref = yield* Ref.make(initial); + + const persist = (value: S["Type"]) => + ensureStateDir(info.name).pipe( + Effect.flatMap(() => + atomicWriteFile(file, JSON.stringify(value)), + ), + ); + + const modify = (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; + }); diff --git a/plugin-kit/src/cli.ts b/plugin-kit/src/cli.ts new file mode 100644 index 00000000..fcad0385 --- /dev/null +++ b/plugin-kit/src/cli.ts @@ -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 ` [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 { + readonly summary: string; + /** Set when the command works without a running host (scan/preview style). */ + readonly offline?: boolean; + readonly run: ( + argv: ReadonlyArray, + ) => Effect.Effect; +} + +/** 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 = ( + def: { name: string; version?: string }, + commands: Record>, +): 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} [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 (opts: { + readonly def: PluginKitDef; + readonly commands: Record>; + readonly argv?: ReadonlyArray; +}): Promise => { + 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(); + } +}; diff --git a/plugin-kit/src/config.ts b/plugin-kit/src/config.ts new file mode 100644 index 00000000..9c9411a3 --- /dev/null +++ b/plugin-kit/src/config.ts @@ -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 { + /** 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; + /** 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 => + 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 => + 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 = (opts: { + readonly schema: S; + readonly fileName?: string; +}): Effect.Effect, never, PluginInfo> => + Effect.gen(function* () { + const info = yield* PluginInfo; + const file = statePath(info.name, opts.fileName ?? "config.json"); + const hub = yield* PubSub.unbounded(); + + const decode = ( + raw: unknown, + ): Effect.Effect => + Schema.decodeUnknownEffect(opts.schema)(raw).pipe( + Effect.mapError( + (e) => new ConfigParseError({ path: file, issue: String(e) }), + ), + ) as Effect.Effect; + + 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; + }); + +// A `Context.Service` class factory is deliberately NOT provided — plugins define their +// own service key over `ConfigService` so the config type stays precise: +// +// class RomConfig extends Context.Service>()( +// "rom-manager/Config", +// ) { +// static layer = Layer.effect(RomConfig)(makeConfigService({ schema: RomConfigSchema })) +// } diff --git a/plugin-kit/src/errors.ts b/plugin-kit/src/errors.ts new file mode 100644 index 00000000..c9a34728 --- /dev/null +++ b/plugin-kit/src/errors.ts @@ -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; +}> {} diff --git a/plugin-kit/src/host-client.ts b/plugin-kit/src/host-client.ts new file mode 100644 index 00000000..ea3caf5a --- /dev/null +++ b/plugin-kit/src/host-client.ts @@ -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; + /** + * 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()( + "@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 => + 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()( + "@punktfunk/plugin-kit/PluginInfo", +) {} + +export const pluginInfoLayer = ( + info: PluginInfoService, +): Layer.Layer => Layer.succeed(PluginInfo)(info); diff --git a/plugin-kit/src/index.ts b/plugin-kit/src/index.ts index 277591fd..3cb0187c 100644 --- a/plugin-kit/src/index.ts +++ b/plugin-kit/src/index.ts @@ -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"; diff --git a/plugin-kit/src/logging.ts b/plugin-kit/src/logging.ts new file mode 100644 index 00000000..8f2f8d8c --- /dev/null +++ b/plugin-kit/src/logging.ts @@ -0,0 +1,24 @@ +// Runner-journal logging: one stamped line per log call, ` [] `, +// 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 => + 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}`, + ); + }), + ]); diff --git a/plugin-kit/src/paths.ts b/plugin-kit/src/paths.ts new file mode 100644 index 00000000..1ae7cae6 --- /dev/null +++ b/plugin-kit/src/paths.ts @@ -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 => + 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 => + 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); diff --git a/plugin-kit/src/react/index.tsx b/plugin-kit/src/react/index.tsx new file mode 100644 index 00000000..c4857f70 --- /dev/null +++ b/plugin-kit/src/react/index.tsx @@ -0,0 +1,143 @@ +// Browser/React glue for plugin UIs served through the console's /plugin-ui// 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//`), 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/` 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 = ( + routes: ReadonlyArray, + fallback: Route, +): Route => { + const isRoute = (s: string | undefined): s is Route => + s !== undefined && (routes as ReadonlyArray).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 = >( + routes: Routes, + fallback: Routes[number], +) => { + type Route = Routes[number]; + const usePluginRoute = (): { + route: Route; + navigate: (r: Route) => void; + } => { + const [route, setRoute] = useState(() => + initialRoute(routes as ReadonlyArray, fallback as Route), + ); + useEffect(() => { + const onHash = () => { + const seg = window.location.hash.replace(/^#\/?/, ""); + if ((routes as ReadonlyArray).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 { + readonly result: AsyncResult.AsyncResult; + /** 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 = ( + props: ResultGateProps, +): 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 { + /** 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; +} + +/** + * 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 = ( + opts: SseAtomOptions, +): Atom.Atom => + Atom.make((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; + }); diff --git a/plugin-kit/src/reconcile.ts b/plugin-kit/src/reconcile.ts new file mode 100644 index 00000000..22bc8ebd --- /dev/null +++ b/plugin-kit/src/reconcile.ts @@ -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, + ) => Effect.Effect; + /** Remove every entry this provider owns (the explicit-uninstall path). */ + readonly remove: (providerId: string) => Effect.Effect; +} + +export class ProviderClient extends Context.Service< + ProviderClient, + ProviderClientService +>()("@punktfunk/plugin-kit/ProviderClient") { + static readonly layer: Layer.Layer = + 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; + }), + ); +} diff --git a/plugin-kit/src/runtime.ts b/plugin-kit/src/runtime.ts new file mode 100644 index 00000000..a2907ab0 --- /dev/null +++ b/plugin-kit/src/runtime.ts @@ -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 { + /** 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; + /** 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 ( + def: PluginKitDef, + pf: Punktfunk, +): Promise => { + 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 = (def: PluginKitDef): 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 ( + def: PluginKitDef, +): Promise => { + const pf = await connect(); + try { + await runWithFacade(def, pf); + } finally { + pf.close(); + } +}; diff --git a/plugin-kit/src/sse.ts b/plugin-kit/src/sse.ts new file mode 100644 index 00000000..576b0756 --- /dev/null +++ b/plugin-kit/src/sse.ts @@ -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: ` 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 { + /** 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 ` streaming `stream` as server-sent events. Each subscriber gets + * an independent subscription; disconnects tear the stream down via scope closure. + */ +export const sseRoute = ( + path: `/${string}`, + stream: Stream.Stream, + opts?: SseRouteOptions, +): Layer.Layer => { + 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", + }, + }), + ), + ); +}; diff --git a/plugin-kit/src/sync-engine.ts b/plugin-kit/src/sync-engine.ts new file mode 100644 index 00000000..d247356a --- /dev/null +++ b/plugin-kit/src/sync-engine.ts @@ -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 = + | { readonly _tag: "Applied"; readonly report: Report; readonly count: number } + | { readonly _tag: "Unchanged"; readonly report: Report } + | { readonly _tag: "AlreadyRunning" }; + +export interface SyncStatus { + 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; +} + +export interface SyncEngineOptions< + Report, + Entries extends ReadonlyArray, + 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; + /** 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; + readonly set: (last: LastSync) => Effect.Effect; + }; + /** Re-read on every `reconfigure` — loops restart with fresh settings. */ + readonly settings: Effect.Effect; +} + +export interface SyncEngine { + readonly sync: ( + reason: SyncReason, + ) => Effect.Effect, SyncError>; + readonly status: Effect.Effect>; + /** Emits on every sync start/finish — the UI's SSE feed. */ + readonly changes: Stream.Stream>; + /** Initial sync + start poll/watch loops (scoped to the construction Scope). */ + readonly start: Effect.Effect; + /** Restart loops with fresh settings, then sync("config-change"). */ + readonly reconfigure: Effect.Effect; +} + +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, + 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, + R, +>( + opts: SyncEngineOptions, +): Effect.Effect, never, R | Scope.Scope> => + Effect.gen(function* () { + const ctx = yield* Effect.context(); + const run = (eff: Effect.Effect): Effect.Effect => + Effect.provide(eff, ctx); + const fingerprint = opts.fingerprint ?? defaultFingerprint; + + const flags = yield* Ref.make({ syncing: false, pending: false }); + const lastReport = yield* Ref.make(undefined); + const hub = yield* PubSub.unbounded>(); + + const status: Effect.Effect> = 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, 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 => + sync(reason).pipe( + Effect.catch((e: SyncError) => + Effect.logWarning(`sync (${reason}) failed: ${e.cause}`), + ), + Effect.asVoid, + ); + + const sync = ( + reason: SyncReason, + ): Effect.Effect, 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(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 = 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((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; + }); diff --git a/plugin-kit/src/theme.css b/plugin-kit/src/theme.css new file mode 100644 index 00000000..57a82ddc --- /dev/null +++ b/plugin-kit/src/theme.css @@ -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 `` (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; + } +} diff --git a/plugin-kit/src/ui-server.ts b/plugin-kit/src/ui-server.ts new file mode 100644 index 00000000..e30a223e --- /dev/null +++ b/plugin-kit/src/ui-server.ts @@ -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; + /** 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 => { + 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), + ); + }); diff --git a/plugin-kit/test/config.test.ts b/plugin-kit/test/config.test.ts new file mode 100644 index 00000000..2ea2b95d --- /dev/null +++ b/plugin-kit/test/config.test.ts @@ -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 = ( + f: (svc: ConfigService) => Effect.Effect, +): Promise => + Effect.runPromise( + makeConfigService({ schema: TestSchema }).pipe( + Effect.flatMap(f), + Effect.provide(pluginInfoLayer({ name: PLUGIN })), + ) as Effect.Effect, + ); + +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 } }, + ]); + }); +}); diff --git a/plugin-kit/test/spike-httpapi.test.ts b/plugin-kit/test/spike-httpapi.test.ts index 1b6071bb..a9435f94 100644 --- a/plugin-kit/test/spike-httpapi.test.ts +++ b/plugin-kit/test/spike-httpapi.test.ts @@ -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( diff --git a/plugin-kit/test/sse.test.ts b/plugin-kit/test/sse.test.ts new file mode 100644 index 00000000..7c847093 --- /dev/null +++ b/plugin-kit/test/sse.test.ts @@ -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(); + } + }); +}); diff --git a/plugin-kit/test/sync-engine.test.ts b/plugin-kit/test/sync-engine.test.ts new file mode 100644 index 00000000..d53936d1 --- /dev/null +++ b/plugin-kit/test/sync-engine.test.ts @@ -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; +}) => + Effect.gen(function* () { + const applied = yield* Ref.make(0); + const last = yield* Ref.make(undefined); + const entries = opts?.entries ?? (() => ["a", "b"]); + const engine = yield* makeSyncEngine, 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 = (eff: Effect.Effect): Promise => + Effect.runPromise(Effect.scoped(eff) as Effect.Effect); + +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) => 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); + }); +});