docs(opencode): annotate plugin loader flow (#23160)

This commit is contained in:
Dax 2026-04-17 15:56:52 -04:00 committed by opencode
parent 9b6c397171
commit b708e8431e

View file

@ -12,31 +12,41 @@ import { ConfigPlugin } from "@/config/plugin"
import { InstallationVersion } from "@/installation/version" import { InstallationVersion } from "@/installation/version"
export namespace PluginLoader { export namespace PluginLoader {
// A normalized plugin declaration derived from config before any filesystem or npm work happens.
export type Plan = { export type Plan = {
spec: string spec: string
options: ConfigPlugin.Options | undefined options: ConfigPlugin.Options | undefined
deprecated: boolean deprecated: boolean
} }
// A plugin that has been resolved to a concrete target and entrypoint on disk.
export type Resolved = Plan & { export type Resolved = Plan & {
source: PluginSource source: PluginSource
target: string target: string
entry: string entry: string
pkg?: PluginPackage pkg?: PluginPackage
} }
// A plugin target we could inspect, but which does not expose the requested kind of entrypoint.
export type Missing = Plan & { export type Missing = Plan & {
source: PluginSource source: PluginSource
target: string target: string
pkg?: PluginPackage pkg?: PluginPackage
message: string message: string
} }
// A resolved plugin whose module has been imported successfully.
export type Loaded = Resolved & { export type Loaded = Resolved & {
mod: Record<string, unknown> mod: Record<string, unknown>
} }
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan } type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
type Report = { type Report = {
// Called before each attempt so callers can log initial load attempts and retries uniformly.
start?: (candidate: Candidate, retry: boolean) => void start?: (candidate: Candidate, retry: boolean) => void
// Called when the package exists but does not provide the requested entrypoint.
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
// Called for operational failures such as install, compatibility, or dynamic import errors.
error?: ( error?: (
candidate: Candidate, candidate: Candidate,
retry: boolean, retry: boolean,
@ -46,19 +56,25 @@ export namespace PluginLoader {
) => void ) => void
} }
// Normalize a config item into the loader's internal representation.
function plan(item: ConfigPlugin.Spec): Plan { function plan(item: ConfigPlugin.Spec): Plan {
const spec = ConfigPlugin.pluginSpecifier(item) const spec = ConfigPlugin.pluginSpecifier(item)
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) } return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
} }
// Resolve a configured plugin into a concrete entrypoint that can later be imported.
//
// The stages here intentionally separate install/target resolution, entrypoint detection,
// and compatibility checks so callers can report the exact reason a plugin was skipped.
export async function resolve( export async function resolve(
plan: Plan, plan: Plan,
kind: PluginKind, kind: PluginKind,
): Promise< ): Promise<
| { ok: true; value: Resolved } | { ok: true; value: Resolved }
| { ok: false; stage: "missing"; value: Missing } | { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> { > {
// First make sure the plugin exists locally, installing npm plugins on demand.
let target = "" let target = ""
try { try {
target = await resolvePluginTarget(plan.spec) target = await resolvePluginTarget(plan.spec)
@ -67,6 +83,7 @@ export namespace PluginLoader {
} }
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) } if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
// Then inspect the target for the requested server/tui entrypoint.
let base let base
try { try {
base = await createPluginEntry(plan.spec, target, kind) base = await createPluginEntry(plan.spec, target, kind)
@ -86,6 +103,8 @@ export namespace PluginLoader {
}, },
} }
// npm plugins can declare which opencode versions they support; file plugins are treated
// as local development code and skip this compatibility gate.
if (base.source === "npm") { if (base.source === "npm") {
try { try {
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg) await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
@ -96,6 +115,7 @@ export namespace PluginLoader {
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } } return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
} }
// Import the resolved module only after all earlier validation has succeeded.
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
let mod let mod
try { try {
@ -107,6 +127,8 @@ export namespace PluginLoader {
return { ok: true, value: { ...row, mod } } return { ok: true, value: { ...row, mod } }
} }
// Run one candidate through the full pipeline: resolve, optionally surface a missing entry,
// import the module, and finally let the caller transform the loaded plugin into any result type.
async function attempt<R>( async function attempt<R>(
candidate: Candidate, candidate: Candidate,
kind: PluginKind, kind: PluginKind,
@ -116,11 +138,17 @@ export namespace PluginLoader {
report: Report | undefined, report: Report | undefined,
): Promise<R | undefined> { ): Promise<R | undefined> {
const plan = candidate.plan const plan = candidate.plan
// Deprecated plugin packages are silently ignored because they are now built in.
if (plan.deprecated) return if (plan.deprecated) return
report?.start?.(candidate, retry) report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind) const resolved = await resolve(plan, kind)
if (!resolved.ok) { if (!resolved.ok) {
if (resolved.stage === "missing") { if (resolved.stage === "missing") {
// Missing entrypoints are handled separately so callers can still inspect package metadata,
// for example to load theme files from a tui plugin package that has no code entrypoint.
if (missing) { if (missing) {
const value = await missing(resolved.value, candidate.origin, retry) const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value if (value !== undefined) return value
@ -131,11 +159,15 @@ export namespace PluginLoader {
report?.error?.(candidate, retry, resolved.stage, resolved.error) report?.error?.(candidate, retry, resolved.stage, resolved.error)
return return
} }
const loaded = await load(resolved.value) const loaded = await load(resolved.value)
if (!loaded.ok) { if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value) report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return return
} }
// The default behavior is to return the successfully loaded plugin as-is, but callers can
// provide a finisher to adapt the result into a more specific runtime shape.
if (!finish) return loaded.value as R if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry) return finish(loaded.value, candidate.origin, retry)
} }
@ -149,6 +181,11 @@ export namespace PluginLoader {
report?: Report report?: Report
} }
// Resolve and load all configured plugins in parallel.
//
// If `wait` is provided, file-based plugins that initially failed are retried once after the
// caller finishes preparing dependencies. This supports local plugins that depend on an install
// step happening elsewhere before their entrypoint becomes loadable.
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> { export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) })) const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = [] const list: Array<Promise<R | undefined>> = []
@ -160,6 +197,9 @@ export namespace PluginLoader {
let deps: Promise<void> | undefined let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) { for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue if (out[i] !== undefined) continue
// Only local file plugins are retried. npm plugins already attempted installation during
// the first pass, while file plugins may need the caller's dependency preparation to finish.
const candidate = candidates[i] const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait() deps ??= input.wait()
@ -167,6 +207,8 @@ export namespace PluginLoader {
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report) out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
} }
} }
// Drop skipped/failed entries while preserving the successful result order.
const ready: R[] = [] const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item) for (const item of out) if (item !== undefined) ready.push(item)
return ready return ready