Skip to content

fix(plugin): load Effect from the host for plugins - #53296

Closed
kitlangton wants to merge 1 commit into
v2from
plugin-host-effect
Closed

kitlangton wants to merge 1 commit into
v2from
plugin-host-effect

Conversation

@kitlangton

Copy link
Copy Markdown
Contributor

Why

Server and TUI plugins written with @opencode/plugin/effect (or using Effect Schema in tools and RPC contracts) previously resolved effect from their own node_modules. Two copies of Effect in the same process do not share module-private symbols, schema parser sentinels, or fiber internals:

  • Effect.log from an older Effect copy crashes inside the host fiber with logLevel.toUpperCase is not a function when the fiber log-level representation differs across versions.
  • Effect.runPromise of host effects crashes with fiber.succeedWith is not a function when fiber continuation internals differ between the host and plugin copies.
  • Tool input schemas using Schema.withDecodingDefault fail or crash with transformations[i] is not a function / Missing key when decoded by the host's SchemaParser.
  • Even when the plugin has the exact same Effect version in its own node_modules, schema checks such as Schema.Int, Schema.isPattern, and Schema.Trim fail through ToolRuntime.execute and Rpc.call because InternalParser.sameExit and InternalParser.missing are compared by reference across module instances.

What Changes

  • Host-provided Effect and @opencode/plugin runtime modules (Bun):
    • packages/plugin/src/runtime-modules.bun.ts dynamically discovers effect and every public effect/* subpath exported by the host's installed effect package (dist/**/*.js validated against effect/package.json#exports, excluding internal/*), along with @opencode/plugin, @opencode/plugin/effect, @opencode/plugin/effect/plugin, @opencode/plugin/effect/tool, @opencode/plugin/promise/plugin, @opencode/plugin/promise/tool, and @opencode/plugin/rpc. Each specifier is registered lazily so unused subpaths are not eagerly evaluated.
    • packages/plugin/src/runtime.bun.ts registers a Bun runtime plugin on Host.load / prepareSource that binds opentui:runtime-module:<specifier> virtual modules to the host's module instances and prescans the plugin's local source graph and node_modules ESM dependency graph, rewriting matching imports to the virtual module IDs.
    • packages/cli/script/build.ts injects lazy () => require(specifier) thunks into runtime-modules.bun.ts during bun build --compile so all exported effect/* subpaths are available inside the standalone compiled binary without requiring node_modules on disk or evaluating every module at startup.
  • TUI runtime plugin support:
    • packages/tui/src/plugin/runtime-plugin-support.bun.ts spreads pluginRuntimeModules() into ensureRuntimePluginSupport({ additional }) so TUI plugins and their node_modules dependencies share the same host effect, effect/*, and @opencode/plugin instances alongside solid-js and @opentui/*.
  • Node and workerd runtime variants:
    • packages/plugin/src/runtime.node.ts uses module.registerHooks to redirect effect, effect/*, and @opencode/plugin/* imports from plugins and their node_modules dependencies to the host's in-memory module instances.
    • packages/plugin/src/runtime.workerd.ts provides a no-op stub for the workerd condition so Cloudflare Worker bundles remain free of Bun/Node loader hooks.
  • Bundled Effect detection:
    • packages/core/src/plugin.ts and packages/core/src/plugin/module.ts verify that the Effect returned by plugin.effect(ctx) shares Effect.void.pipe with the host's Effect prototype, failing plugin activation with a clear error message if a plugin bundled its own copy of effect.
  • Documentation:
    • Updated services/www/src/docs/content/build/plugins/effect.mdx, packages/plugin/README.md, packages/plugin/src/README.md, and packages/plugin/src/effect/README.md to instruct plugin authors to declare effect as a peerDependency, keep effect and effect/* external when bundling, and target the host OpenCode release's Effect API surface.

Scope

  • Pre-bundled plugins that inline effect into their own JavaScript file cannot have their internal effect imports redirected by the module loader; Effect plugins that do so are detected at activation time and rejected with an actionable error.
  • Redirecting effect to the host copy unifies runtime module identity, but cannot polyfill Effect APIs or module paths that were removed or renamed between Effect releases.
  • @effect/* adapter packages are not host-provided because OpenCode only installs a small internal subset and does not pass @effect/* types across the plugin boundary; any @effect/* package installed in a plugin's node_modules has its own effect and effect/* imports rewritten to the host's Effect instance automatically.

Verification

  • Tests (all confirmed to fail without the change):
    • packages/plugin/test/host.test.ts: verifies Host.load redirects effect, effect/* subpaths (effect/Option, effect/Brand, effect/unstable/http), and @opencode/plugin/* to the host's module instances (=== reference equality) across a plugin with its own 4.0.0-rc.111 copy in node_modules/effect, a local helper, and a transitive node_modules dependency, under both Bun and Node.
    • packages/core/test/plugin/module.test.ts: verifies PluginModule.load and Plugin.Service.activate with a fixture plugin carrying a 4.0.0-rc.111 copy of effect and a transitive dependency in node_modules, proving module identity (===), Effect.log inside the host fiber, Effect.runPromise on ctx.plugin.list(), Schema.withDecodingDefault through ToolRuntime.execute, and Schema.Int / Schema.Trim / Schema.isPattern through ToolRuntime.execute and Rpc.Service.call, plus rejection of a plugin returning an Effect from a bundled copy.
    • packages/tui/test/plugin-source.test.ts: verifies TUI plugins and their node_modules dependencies resolve effect and effect/* subpaths to the host copy.
  • Compiled binary check & size:
    • Built dist/cli-darwin-arm64/bin/opencode via packages/cli/script/build.ts (--single --skip-install --skip-web-ui):
      • Before: 155,404,146 bytes (148.20 MiB)
      • After (with all 361 effect and effect/* subpaths included as lazy thunks): 181,658,226 bytes (173.24 MiB)
    • Spawned the compiled opencode serve binary against a fixture project whose .opencode/plugins/ plugin had a throwing node_modules/effect copy and verified plugin activation, Effect.log, Effect.runPromise(ctx.plugin.list()), effect/Option, effect/Brand, Schema.withDecodingDefault, and Schema.Int/Schema.isPattern over HTTP RPC.
  • bun run check, packages/plugin, packages/core, packages/tui, packages/server, packages/cli, and packages/sdk (verify:package) all pass.

@kitlangton

Copy link
Copy Markdown
Contributor Author

Superseded by #53422, which uses Bun's build.module from the CLI instead of rewriting plugin sources (+7.8 MiB binary instead of +25 MiB, and no new activation failures).

@kitlangton kitlangton closed this Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant