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,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
|
||||||
@@ -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": {
|
"devDependencies": {
|
||||||
"@punktfunk/host": "file:../sdk",
|
"@punktfunk/host": "file:../sdk",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
"effect": "4.0.0-beta.99",
|
"effect": "4.0.0-beta.99",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
},
|
},
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
"@punktfunk/host": "^0.1.1",
|
"@punktfunk/host": "^0.1.1",
|
||||||
"@unom/ui": ">=0.9.0",
|
"@unom/ui": ">=0.9.0",
|
||||||
"effect": "^4.0.0-beta.98",
|
"effect": "^4.0.0-beta.98",
|
||||||
"react": ">=19",
|
"react": "^19.2.0",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@effect/atom-react",
|
"@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" } }],
|
||||||
|
|
||||||
|
"@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=="],
|
"@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/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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||||
|
|||||||
@@ -39,20 +39,19 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "^4.0.0-beta.98",
|
"effect": "^4.0.0-beta.98",
|
||||||
"@punktfunk/host": "^0.1.1",
|
"@punktfunk/host": "^0.1.2",
|
||||||
"react": ">=19",
|
"react": "^19.2.0"
|
||||||
"@effect/atom-react": "^4.0.0-beta.99",
|
|
||||||
"@unom/ui": ">=0.9.0"
|
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react": { "optional": true },
|
"react": {
|
||||||
"@effect/atom-react": { "optional": true },
|
"optional": true
|
||||||
"@unom/ui": { "optional": true }
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"effect": "4.0.0-beta.99",
|
|
||||||
"@punktfunk/host": "file:../sdk",
|
"@punktfunk/host": "file:../sdk",
|
||||||
"@types/bun": "^1.3.0",
|
"@types/bun": "^1.3.0",
|
||||||
|
"@types/react": "^19.2.16",
|
||||||
|
"effect": "4.0.0-beta.99",
|
||||||
"typescript": "^5.9.3"
|
"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.
|
// @punktfunk/plugin-kit — Effect-based framework for punktfunk plugins.
|
||||||
// Modules land here as they are implemented; the spikes in test/ validate the
|
export * from "./errors.js";
|
||||||
// riskiest seams (HttpApi behind servePluginUi, client prefix handling) first.
|
export {
|
||||||
|
atomicWriteFile,
|
||||||
export {}
|
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 })),
|
.handle("echo", ({ payload }) => Effect.succeed({ echoed: payload.msg })),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Core-only environment for HttpApiBuilder: no platform package needed.
|
// Core-only environment for HttpApiBuilder: no platform package needed. FileSystem is
|
||||||
const env = Layer.mergeAll(
|
// provideMerge'd so both HttpPlatform.layer and HttpApiBuilder see it satisfied.
|
||||||
Etag.layerWeak,
|
const env = Layer.provideMerge(
|
||||||
Path.layer,
|
Layer.mergeAll(Etag.layerWeak, Path.layer, HttpPlatform.layer),
|
||||||
HttpPlatform.layer.pipe(Layer.provide(FileSystem.layerNoop({}))),
|
FileSystem.layerNoop({}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const appLayer = HttpApiBuilder.layer(api).pipe(
|
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