深度解密 OpenCode 2 插件加载机制:Dual-Version (V1/V2) 适配实战与官方文档的“隐形陷阱”

导读 :随着 OpenCode 2.0 的正式发布,插件生态迎来了从 V1 的 Hooks 架构向 V2 的 Plugin SDK / Transforms 架构的全面代际跃迁。为了保证存量用户与新版本用户的平滑过渡,开发兼具 V1 与 V2 兼容能力的"双模(Combined)插件"成为当下开发者的必然选择。然而,当我们严格按照 OpenCode 官方文档编写 Dual-Version 代码时,却遇到了本地调试无法加载、静默失败等一系列诡异问题。本文基于 OpenCode v2.0.15 标签中的官方 TypeScript 源码,拆解其内部底层的加载与探测算法,还原 npm 包与本地开发模式下的差异,并给出工业级的双版本打包实践指南。


一、官方文档的"理想蓝图"

在 OpenCode 官方提供的 V2 插件迁移指南中,关于如何让一个 npm 包同时兼容 OpenCode V1 和 OpenCode V2,官方给出了如下代码范例(此处增加注释并区分日志文案):

typescript 复制代码
import { Plugin } from "@opencode/plugin"

export default {
  // OpenCode V2 契约:展开 Plugin.define 返回的对象
  ...Plugin.define({
    id: "example",
    async setup(ctx) {
      await ctx.tool.hook("execute.before", () => {
        console.log("A tool is about to run (V2)")
      })
    },
  }),

  // OpenCode V1 (>= 1.18.29) 契约:保留顶层 server 函数
  async server() {
    return {
      "tool.execute.before": async () => {
        console.log("A tool is about to run (V1)")
      },
    }
  },
}

官方文档宣称的运行机制是:

  • OpenCode V1(>= 1.18.29) 检测到模块导出了 server 函数,将其作为 V1 插件执行;
  • OpenCode V2 检测到导出了 id 与 setup,将其作为 V2 插件注册;
  • 开发者只需维护单一入口,即可无缝支持两代宿主。

这套逻辑在 JavaScript 对象的概念模型上挑不出毛病。然而,当你满怀信心地在现代 TypeScript 工程(src/ 源码,编译输出到 dist/)中写下这段代码并尝试在本地通过 opencode.json 测试时,现实会给你一记重锤:插件根本没有被加载,终端没有任何报错,宿主直接静默忽略了它。


二、遇坑现场:本地配置为何频频失效?

在实际测试中,我们配置了本地插件路径,尝试了如下常见写法:

现场 1:直接指向编译后的单文件

json 复制代码
{
  "plugins": [
    {
      "package": "file:///path/to/project/dist/index.js",
      "options": {}
    }
  ]
}

结果:OpenCode 2 会记录 warning 并跳过该条配置,而不是把异常抛到启动流程:

text 复制代码
configured plugin path must be a directory

这里确实存在文档与实现不一致:同一标签的迁移指南仍展示 "package": "./plugin/local.ts",而配置扫描实现会跳过这种文件路径。这个限制针对显式 plugins 配置;自动发现的 .opencode/plugin/、.opencode/plugins/ 下的 .ts/.js 单文件仍受支持,见 PluginSourceDirectory.discover。

现场 2:按常理指向项目根目录

既然强制要求目录,且 package.json 中配置了 "main": "./dist/index.js",那么指向项目根目录总行了吧?

json 复制代码
{
  "plugins": [
    {
      "package": "file:///path/to/project",
      "options": {}
    }
  ]
}

结果 :没有报错,但插件完全没被激活。通过 GET /api/plugin 审查,列表中根本没有我们的插件。

现场 3:偶然发现指向子目录却能成功?

在我们尝试把路径指向 TypeScript 源码目录或构建输出目录时:

  • file:///path/to/project/src-v2(里面有 index.ts)👉 成功加载!
  • file:///path/to/project/dist(里面有构建好的 server.js 或 index.js)👉 成功加载!

为什么指向根目录不行,指向子目录就可以?package.json 里的 main 和 exports 难道被无视了吗?


三、官方源码:揭开 OpenCode 2 的加载器底牌

为了彻底弄清真相,本文直接读取官方仓库 anomalyco/opencode 的 v2.0.15 源码。相关实现位于:

本次通过 gh api 核实的标签提交为 6f3639d82ed0760091792189b78f8eeb44f699b1。可复现的读取命令如下;将路径替换为上面的其他文件即可逐项核对:

bash 复制代码
gh api repos/anomalyco/opencode/git/ref/tags/v2.0.15 --jq '.object'
gh api 'repos/anomalyco/opencode/contents/packages/plugin/src/host.ts?ref=6f3639d82ed0760091792189b78f8eeb44f699b1' \
  -H 'Accept: application/vnd.github.raw'

以下结论限定于该源码版本。原文声称来自同版本二进制的抛错片段与标签源码不一致,本文采用可复查的标签源码,不将原二进制观察当作已验证事实。

1. 本地路径校验的死命令

在 ConfigPluginSource.scan 中,对配置得到的绝对路径会先检查是否为文件。真实源码不是抛出异常,而是记录 warning 并返回 Option.none(),使该配置项被过滤掉:

typescript 复制代码
if (yield* fs.isFile(operation.target)) {
  yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
  return Option.none<Operation>()
}

这就是为什么通过 plugins 配置直接指向 .js 文件不会进入正常的 V2 配置插件加载流程;它会被记录 warning 后忽略。PluginModule.load 仍保留了面向旧的自动发现来源的单文件兼容分支,但这不改变配置扫描阶段的行为。

2. 双轨制解析逻辑:Host.resolve

以下逐字摘录 packages/plugin/src/host.ts 的完整 resolve 函数:

typescript 复制代码
export function resolve(target: Target): Entrypoints {
  const entry = (subpaths: readonly string[]) => {
    for (const subpath of subpaths) {
      const specifier = target.name
        ? [target.name, subpath].filter(Boolean).join("/")
        : path.resolve(target.directory, subpath || "index")
      try {
        return resolveModule(specifier, target.directory)
      } catch (error) {
        if (
          !(error instanceof Error) ||
          !("code" in error) ||
          ![
            "ENOENT",
            "ENOTDIR",
            "MODULE_NOT_FOUND",
            "ERR_MODULE_NOT_FOUND",
            "ERR_PACKAGE_PATH_NOT_EXPORTED",
            "ERR_UNSUPPORTED_DIR_IMPORT",
          ].includes(String(error.code))
        )
          throw error
      }
    }
    return undefined
  }
  return { server: entry(["server", ""]), tui: entry(["tui"]), rpc: entry(["rpc"]) }
}

通过这段源码,可以确认以下行为:

核心区别 1:本地目录不按包根 main / exports 选择入口

当用户配置本地路径(如 file:///Users/.../project)时,最终会转换为绝对目录路径,target.name 为空。 解析器走的是右侧分支:path.resolve(target.directory, subpath || "index")。 它会硬编码按顺序执行两次探测:

  1. path.resolve(directory, "server") ➡️ 寻找目录下的 server.ts、server.js 等;
  2. path.resolve(directory, "index") ➡️ 寻找目录下的 index.ts、index.js 等。

它不会把本地目录本身作为包根交给解析器,因此仅在项目根目录的 package.json 中设置 main / exports 指向 dist/,不能让这里自动找到构建文件。 这不等于整个加载过程完全不接触 package.json:扫描逻辑会读取它的修改时间,底层运行时也有自己的模块解析规则。

  • 如果你指向项目根目录,而根目录只有 src/ 和 dist/,没有根级 server.js 或 index.js,探测全部落空;
  • Host.resolve 最终返回 { server: undefined };
  • 上层扫描逻辑发现没有 server 入口后返回空操作列表,因此该目录不会被激活;
  • 而指向 src-v2/ 命中 src-v2/index.ts,指向 dist/ 命中 dist/server.js 或 dist/index.js,所以能跑通。

核心秘密 2:npm 包走规范的 exports 子路径寻址

对于包配置(例如 "package": "opencode-models-discovery"),PluginModule.load 将 npm 解析/安装结果传给 Host.resolve。 当 target.name 存在时,解析器走左侧分支:[target.name, subpath].filter(Boolean).join("/")。 subpaths 参数是 ["server", ""],因此它会依次尝试解析:

  1. opencode-models-discovery/server
  2. opencode-models-discovery

此时,resolveModule 会调用运行时解析器(Bun 版本中是 Bun.resolveSync),由包解析规则处理 package.json:

  • 优先去匹配 package.json 中的 "exports": { "./server": "..." };
  • 如果第一个候选触发代码中列出的可忽略解析错误,再尝试包根。包根可以由 exports["."] 解析;没有 exports 限制时才按运行时规则使用 main 等传统入口。不能理解为有 exports 但缺少 . 时一定回退 main。

注意:Host.resolve 只吞掉列举的解析错误,其他错误会重新抛出;原反编译片段中的空 catch 丢失了这一重要条件。

3. 找到入口之后,还要校验默认导出

PluginModule.load 导入模块后,校验默认导出是否有字符串 id 和函数 setup 或 effect。校验失败会产生 PluginModule.LoadError:

text 复制代码
Plugin must export a default definition with an id and an effect or setup function.

V2 不会把 server() 返回的 V1 hooks 自动转换为 V2 注册。另一方面,扫描一个已存在的本地目录时,源码中的 if (!entrypoints.server) return [] 确实会无日志过滤无入口目录;这不能推广成所有加载失败都静默处理。加载阶段还存在 Plugin entrypoint not found 错误。目录入口还必须通过 FSUtil.contains(root, server) 检查,解析到目录之外会被过滤。


四、官方文档与现实的冲突总结

关注维度 官方文档传达的心智 OpenCode 2 底层真实实现
入口对象定义 单一模块 default 导出 { id, setup, server } 即可 代码结构确实如此,但前提是物理文件必须先被探测器找到
本地开发路径 迁移指南仍展示单文件配置 配置中的单个 .js 文件会记录 warning 并跳过;目录则继续执行入口探测
本地目录解析 开发者以为会遵循 package.json 的 main 按目录下 server / index 路径解析,不按项目包根的 main 定位 dist/
入口寻址优先级 隐性假定为包根入口 优先寻址 /server 子路径,其次才寻址包根入口
错误反馈 预期会有找不到入口的友好提示 找不到入口时该配置操作会被过滤;单文件配置会记录 configured plugin path must be a directory warning

五、工业级解决方案:双模插件最佳打包实践

依据以上源码,可以采用下面的双入口打包方式。双入口是工程组织选择,并非宿主硬性要求;只有根入口的包也可能通过回退解析成功。发布前仍需分别验证目标 V1、V2 版本。

1. 显式构建双入口

在项目中建立独立的 combined 入口文件,确保输出 dist/index.js 和 dist/server.js。

typescript 复制代码
// src/server.ts 或 src/index.ts
import { Plugin } from "@opencode/plugin"
import { ModelDiscoveryPlugin } from "./plugin/index.js" // V1 业务实现
import { setupV2 } from "../src-v2/index.js"            // V2 业务实现

const combinedPlugin = {
  // 注入 V2 规范协议
  ...Plugin.define({
    id: "opencode.models-discovery",
    setup: setupV2,
  }),
  // 注入 V1 规范协议
  server: ModelDiscoveryPlugin,
}

export { ModelDiscoveryPlugin }
export default combinedPlugin

2. 配置 package.json 的 exports 矩阵

在 package.json 中显式暴露 . 与 ./server 两个子路径导出:

json 复制代码
{
  "name": "opencode-models-discovery",
  "main": "./dist/index.js",
  "exports": {
    ".": {
      "default": "./dist/index.js"
    },
    "./server": {
      "default": "./dist/server.js"
    }
  },
  "files": [
    "dist",
    "README.md",
    "LICENSE"
  ]
}

3. 多场景适配指南

遵循以上规范构建后,插件在各种场景下的表现如下:

  1. npm 包发布场景(面向最终用户):

    • 用户在 OpenCode 2 中配置 "package": "opencode-models-discovery"。
    • OpenCode 2 自动请求 opencode-models-discovery/server,命中 dist/server.js 并激活 V2。
    • OpenCode 1 v1.18.29 配置 "plugin": ["opencode-models-discovery"] 时,也会优先使用 exports["./server"],此例会加载 dist/server.js,然后调用默认导出的 server 函数;没有该子路径时才考虑 main。依据为该版本的 shared.ts 与 index.ts。因此两个构建入口均应导出 combined 对象,不能假定 V1 必然加载 index.js。
  2. 本地调试场景(面向插件开发者):

    • 推荐方案 A(指向构建目录) :配置 "package": "file:///path/to/project/dist"。OpenCode 2 进入 dist/,直接命中 dist/server.js,无需在根目录创建临时胶水代码。
    • 备选方案 B(根目录软链/转发) :在项目根目录下放置一个转发式的 server.js 或 index.js,指向 ./dist/。此时直接写项目根目录 file:///path/to/project 亦可生效。

六、结语

开发跨版本插件需要分别验证入口寻址、默认导出契约和业务 API 适配。源码能确认这些分支的行为,但不能仅凭实现推断作者的性能取舍动机。

本文通过官方源码确认了目录与 npm 包的寻址差异,也纠正了原文对抛错行为、异常捕获范围和 V1 入口优先级的描述。源码核对为双版本打包提供依据,但不能替代对最终发布产物的 V1/V2 运行验证。

相关推荐
前端之虎陈随易6 小时前
bm2,Node.js 与 Bun 项目部署新选择
node.js
梦帮科技1 天前
【3.0修订版】 RNS 代币架构:ERC20 五件套扩展与六钱包分配
数据结构·后端·算法·架构·node.js·区块链·php
niyongsheng2 天前
后端零改动,给若依换一套现代化前端
vue.js·开源·node.js
liangshanbo12152 天前
面试题:Webpack 的 publicPath 有什么作用?
前端·webpack·node.js
hasty3 天前
不上传新包,也能改变用户拿到的版本:npm dist-tag 的 OIDC 权限治理
前端·npm·node.js
福兮说3 天前
前后端算的 MD5、SHA-256 对不上?编码、换行、BOM、HMAC、JSON 顺序,八个原因逐个实测
前端·javascript·node.js·json·哈希算法
lingchen19063 天前
Node.js的下载安装配置
node.js
LRL_4 天前
【实战指南】Node.js 跨平台依赖下载:如何在 Windows/Linux 环境下互跨下载目标系统的 npm/pnpm 包
linux·windows·node.js
福兮说4 天前
URL 编码的七个坑:c++ 传到后端变空格、%25 套娃、截断 emoji 直接报错
开发语言·前端·javascript·node.js·url