Skip to content

fix(web): load the esm-only opencode client lazily for tsx daemons - #512

Open
ixjosemi wants to merge 14 commits into
mainfrom
fix/daemons-opencode-client-esm
Open

ixjosemi wants to merge 14 commits into
mainfrom
fix/daemons-opencode-client-esm

Conversation

@ixjosemi

@ixjosemi ixjosemi commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Since #504 the flows and reaper containers crash at startup, so scheduled flows never run and the reaper is down:

Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in /app/node_modules/@opencode/client/package.json

Both daemons run tsx src/*-daemon.ts. apps/web is a CommonJS package, so tsx compiles their module graph to require(). @opencode/client@2.0.32 is "type": "module" and its exports only declare import/types, so the static value imports in the opencode-v2 adapter (client.ts, events.ts) cannot be required. The Next.js server is unaffected because webpack bundles the package.

Fix

  • Load the client with a literal dynamic import('@opencode/client') (module-private loader in client.ts). Under tsx it stays a native ESM import that honours the import condition; under Next/webpack it is still bundled and traced, so the desktop standalone build is unchanged.
  • createConnectionClient is now async and takes an optional { fetch }; events.ts builds its client through it instead of duplicating the setup.
  • checkHealth keeps timeoutMs as a total budget exactly as before, except for the one-time cold module load, and returns early for an already-aborted caller signal.

Rejected alternatives:

  • tsx --conditions=import / NODE_OPTIONS: a global condition also switches pg to its ESM wrapper and the daemons then die with TypeError: Class extends value #<Object>; it would also have to be repeated in package.json, both compose templates and the one-click generator.
  • Function('return import(x)') (v1 importRuntimeModule pattern): hides the package from webpack/file tracing, so the desktop standalone build would need extra tracing includes.

Only the web image changes; compose files and launchers are untouched.

Tests

  • src/__tests__/daemons-runtime.test.ts starts the real daemon entrypoints with the tsx CLI (no DB, nothing contacted) and asserts they get past module resolution and fail only on the expected missing config (DATABASE_URL is required / kb_unavailable: <path>). Fails on main with the production error.
  • opencode-v2/__tests__/client-runtime.test.ts loads the v2 client and events modules through the tsx CLI from inside apps/web (CommonJS format) and builds a client.
  • client.test.ts / runtime-execution.test.ts: pre-warm the client so fake-timer tests do not race the cold import when run alone; the losing-probe test aborts while the request is in flight again; new tests prove the module load is outside the health budget and a slow instance lookup still yields healthcheck_timeout.
  • pnpm test: 6211 passed, 0 failed. pnpm lint: 0 errors. scripts/check-podman-images.sh: passed.

Verified on a real deployment

Deployed main (a978693) + this branch to a single-host deployment: [flow-daemon] Flow daemon started, [reaper-daemon] Reaper daemon started, the reaper pre-archived the V1 sessions, and the workspace started on V2. Reviewed with two independent code reviews, an adversarial review and a verifier pass.

🤖 Generated with Claude Code

ixjosemi and others added 9 commits October 23, 2026 08:51
The flows and reaper containers run `tsx src/*-daemon.ts`. apps/web is a
CommonJS package, so tsx compiles their module graph to require() calls.
@opencode/client is "type": "module" and its exports declare only `import`
and `types`, so the static imports in the opencode-v2 adapter crashed both
daemons at startup with ERR_PACKAGE_PATH_NOT_EXPORTED. The Next.js server
was unaffected because webpack bundles the package.

Load the client with a plain dynamic import() through loadOpenCode(). Under
tsx it becomes a native ESM import that honours the `import` condition; under
Next/webpack it is still bundled, so desktop standalone tracing is unchanged.
createConnectionClient is now async accordingly.

Rejected alternatives:
- `tsx --conditions=import` / NODE_OPTIONS: a global condition also switches
  pg to its ESM wrapper, and require('pg') then fails with "Class extends
  value #<Object>"; it would also have to be repeated in package.json, both
  compose templates and the one-click generator.
- Function('return import(x)') as in the v1 adapter: hides the package from
  webpack and file tracing, so the desktop standalone build would need extra
  outputFileTracingIncludes for the client and its dependencies.

Only the web image changes; compose files and launchers stay as they are.

Tests: daemons-runtime.test.ts starts the real daemon entrypoints with tsx
(no database, nothing contacted) and fails before this change with the
production error; client-runtime.test.ts loads the v2 client and events under
tsx. client.test.ts now rejects on an already-aborted signal like real fetch,
since the extra await lets the abort land before fetch is called.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
subscribeOpenCodeEvents duplicated the baseUrl and Authorization setup
and needed loadOpenCode exported. createConnectionClient now accepts an
optional fetch, so the loader stays private to client.ts. The comment
records why a literal import() is kept instead of Function().

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- pre-warm the ESM client import so fake-timer tests pass when run alone
- keep true in-flight cancellation in the losing-probe test: drop the
  already-aborted fetch guard and abort once fetch has been called
- run the client runtime test through the tsx CLI on a temp script that
  also loads the events module, with an explicit timeout
- move daemons-runtime.test.ts under src/__tests__ and force the flow
  daemon's startup failure through a missing KB config path

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Type it from the package's exported OpenCode.ClientOptions instead of
deriving it from OpenCode.make's parameters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Build the client before arming the deadline timer so a cold ESM import
cannot consume the health budget, and return early when the caller
signal is already aborted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Pin the reaper failure cause, use a constant missing KB config path,
write the client entry script under apps/web so it compiles as
CommonJS like the daemons, and report the child's stderr on failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@ixjosemi

Copy link
Copy Markdown
Contributor Author

cc @albertoperdomo2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant