// The async-main boundary — the one place the two-effect-instances problem is handled. // // The packaged runner bundles its OWN copy of effect + the SDK; a plugin package's imports // resolve to the plugin's node_modules. An Effect-shaped `main` would therefore hand the // runner Effect values built by a different effect instance (Context.Tag identity is not // shared across instances). The kit sidesteps this by construction: the plugin exports a // plain async `main`, and EVERYTHING Effect happens inside a ManagedRuntime built from the // plugin's own effect instance. Only the plain `pf` facade object crosses the boundary. // // Shutdown: the runner interrupts its supervision tree on SIGINT/SIGTERM, but it cannot // cancel an in-flight promise — so the kit installs its own signal hooks, interrupts the // plugin fiber (running scoped finalizers: UI deregistration, watcher close, cache flush) // and bounds the whole teardown with `shutdownGraceMs` so `main` always resolves. import { definePlugin, type PluginDef, type Punktfunk, connect, } from "@punktfunk/host"; import { Cause, Effect, Exit, Fiber, Layer, ManagedRuntime, Scope, } from "effect"; import { type HostClient, hostClientFromFacade, type PluginInfo, pluginInfoLayer, } from "./host-client.js"; import { loggingLayer } from "./logging.js"; export interface PluginKitDef { /** Plugin id (`[a-z][a-z0-9-]*`) — also the registry id and provider id. */ readonly name: string; readonly version?: string; /** The plugin's service graph, built over the kit base (HostClient | PluginInfo). */ readonly layer: Layer.Layer; /** The long-running program. Scoped: acquired resources release on interruption. */ readonly main: Effect.Effect< void, E, R | HostClient | PluginInfo | Scope.Scope >; /** Upper bound on graceful teardown after a signal (default 5000 ms). */ readonly shutdownGraceMs?: number; } const sleep = (ms: number): Promise<"timeout"> => new Promise((resolve) => { const t = setTimeout(() => resolve("timeout"), ms); (t as { unref?: () => void }).unref?.(); }); const runWithFacade = async ( def: PluginKitDef, pf: Punktfunk, ): Promise => { const base = Layer.mergeAll( hostClientFromFacade(pf), pluginInfoLayer({ name: def.name, version: def.version }), loggingLayer(def.name), ); const rt = ManagedRuntime.make(Layer.provideMerge(def.layer, base)); const graceMs = def.shutdownGraceMs ?? 5000; const fiber = rt.runFork(Effect.scoped(def.main)); // Fires graceMs after the first signal; never before a signal — so racing against it // is a no-op in normal operation and a hard teardown bound once a stop is requested. let fireGrace: (v: "timeout") => void = () => {}; const gracePromise = new Promise<"timeout">((resolve) => { fireGrace = resolve; }); let stopping = false; const onSignal = () => { if (stopping) return; stopping = true; void sleep(graceMs).then(fireGrace); rt.runFork(Fiber.interrupt(fiber)); }; process.on("SIGINT", onSignal); process.on("SIGTERM", onSignal); try { const joined = rt.runPromise(Effect.exit(Fiber.join(fiber))); const exit = await Promise.race([joined, gracePromise]); if (exit === "timeout") { console.error( `[${def.name}] shutdown exceeded ${graceMs}ms — abandoning teardown`, ); return; } if (Exit.isFailure(exit)) { const cause = exit.cause; // Pure interruption (signal-driven) is a clean stop; real failures propagate so // the runner records the crash and restarts with backoff. if (Cause.hasFails(cause) || Cause.hasDies(cause)) { throw new Error(Cause.pretty(cause)); } } } finally { process.removeListener("SIGINT", onSignal); process.removeListener("SIGTERM", onSignal); await Promise.race([rt.dispose(), sleep(2000)]); } }; /** * Build the plugin's `PluginDef` (the default export the runner discovers) from an * Effect-native definition. The returned def has a plain async `main`, so the runner's * sanity checks and supervision treat it exactly like any hand-written plugin. */ export const definePluginKit = (def: PluginKitDef): PluginDef => definePlugin({ name: def.name, main: (pf: Punktfunk) => runWithFacade(def, pf), }); /** Dev/CLI entry: `connect()` a facade ourselves and run the same program. */ export const runPluginKitDirect = async ( def: PluginKitDef, ): Promise => { const pf = await connect(); try { await runWithFacade(def, pf); } finally { pf.close(); } };