8d72e1d27e
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>
89 lines
3.1 KiB
TypeScript
89 lines
3.1 KiB
TypeScript
// 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),
|
|
);
|
|
});
|