import { readFileSync, readdirSync } from "node:fs" import { fileURLToPath, pathToFileURL } from "node:url" import path from "node:path" import { Hash } from "@opencode/util/hash" // Keep source fingerprints and import attempts together. Filesystem events // should reload changed local graphs, not repeat unchanged evaluations. export function createPluginSources(watch: (file: string) => Promise) { const sources = new Map() const cleanups: Array<() => void> = [] const watching = new Set>() return { read: async (entrypoint: string) => { await Promise.all(watching) const previous = sources.get(entrypoint) if (previous && [...previous.files].every(([file, item]) => item.digest === digest(file, item.directory))) return previous.loaded const files: Source["files"] = new Map() const track = (file: string, directory = false) => { if (files.has(file)) return files.set(file, { digest: digest(file, directory), directory }) const pending = watch(file).finally(() => watching.delete(pending)) watching.add(pending) // Watch setup can be interrupted while module evaluation is still pending. void pending.catch(() => {}) } track(fileURLToPath(entrypoint)) const { prepareSource } = await import("#plugin-source") const prepared: { version: string; load: () => Promise; dispose?: () => void } = await prepareSource( entrypoint, track, ) if (prepared.dispose) cleanups.push(prepared.dispose) // Cache the attempt before evaluating it: unchanged failing modules must // not repeat import-time effects on every filesystem notification. const loaded = prepared.load().then((module) => ({ version: prepared.version, module })) sources.set(entrypoint, { loaded, files }) return loaded.finally(() => Promise.all(watching)) }, dispose: () => { for (const cleanup of cleanups.splice(0)) cleanup() sources.clear() }, } } type Source = { loaded: Promise<{ version: string; module: unknown }> files: Map } function digest(file: string, directory: boolean) { try { return Hash.sha256(directory ? JSON.stringify(readdirSync(file).sort()) : readFileSync(file)) } catch { return "missing" } } export function localSource(spec: string, directory: string) { if (spec.startsWith("file://")) return new URL(spec) if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)) return pathToFileURL(path.resolve(directory, spec)) return undefined }