Files
punktfunk/plugin-kit/src/runtime.ts
T
enricobuehler 10a0ef3283 style(plugin-kit): adopt the biome config its own plugins already use
The kit had NO biome config and no lint script, while every plugin repo that
consumes it has both. So its source quietly drifted — unused imports, unsorted
imports, formatting — with nothing to catch any of it. Running biome here for
the first time reported 20 findings across 8 files.

Adds `plugin-kit/biome.json` mirroring the plugin repos' (tab indent, double
quotes, recommended lint preset, organizeImports), a `check` script, and
`@biomejs/biome` pinned to the same `^2.5.2` the plugins pin — without that pin
`bunx biome` resolved 2.4.6, which rejects the 2.5 `rules.preset` key.

Two deliberate differences from the plugin repos' copy:

  * no `vcs.useIgnoreFile` — those are standalone repos with a .gitignore beside
    the config; plugin-kit is a directory inside this one, and biome errors with
    "couldn't find an ignore file". The `files.includes` exclusions cover it.
  * `!examples/**/dist` instead of `!ui/dist` — the kit has examples, not a UI.

`css.parser.tailwindDirectives` is carried over and is load-bearing: without it
biome cannot parse `@theme` in src/theme.css and reports three parse errors on
CSS that is perfectly valid Tailwind v4.

Everything here is formatter/import churn except two real findings, both fixed:

  * `Layer` (library/define.ts) and `Cause` (sync-engine.ts) were imported and
    never used;
  * test/spike-httpapi.test.ts read `(reg?.body as …).ui.secret` one line after
    `expect(reg).toBeDefined()`. The optional chain undoes the assertion: had
    `reg` been undefined the `.ui` access would throw a TypeError instead of
    failing the test readably. Now asserted to the type system too.

Wired into plugin-kit-publish.yml as a `Lint & format` step ahead of Typecheck,
so this cannot rot again.

Gates after: biome clean (42 files), tsc clean, 67/67 tests, build clean.
2026-08-08 02:19:06 +02:00

135 lines
4.5 KiB
TypeScript

// 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 {
connect,
definePlugin,
type PluginDef,
type Punktfunk,
} from "@punktfunk/host";
import {
Cause,
Effect,
Exit,
Fiber,
Layer,
ManagedRuntime,
type 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();
}
};