20 KiB
Server package extraction
Practical reference for extracting a future packages/server from the current packages/opencode monolith while packages/core is still being migrated to Effect.
This document is intentionally execution-oriented.
It should give an agent enough context to land one incremental PR at a time without needing to rediscover the package strategy, route migration rules, or current constraints.
Goal
Create packages/server as the home for:
- HTTP contract definitions
- HTTP handler implementations
- OpenAPI generation
- eventual embeddable server APIs for Node apps
Do this without blocking on the full packages/core extraction.
Future state
Target package layout:
packages/core- all opencode services, Effect-first source of truthpackages/server- opencode server, with separate contract and implementation, still producingopenapi.jsonpackages/cli- TUI + CLI entrypointspackages/sdk- generated from the server OpenAPI spec, may add higher-level wrapperspackages/plugin- generated or semi-hand-rolled non-Effect package built from core plugin definitions
Desired user stories:
- import from
coreand build a custom agent or app-specific runtime - import from
serverand embed the full opencode server into an existing Node app - spawn the CLI and talk to the server through that boundary
Current state
Everything still lives in packages/opencode.
Important current facts:
- there is no
packages/coreorpackages/cliworkspace yet packages/servernow exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet- the main host server is still Hono-based in
src/server/server.ts - current OpenAPI generation is Hono-based through
Server.openapi()andcli/cmd/generate.ts - the Effect runtime and app layer are centralized in
src/effect/app-runtime.tsandsrc/effect/run-service.ts - there is already one experimental Effect
HttpApislice atsrc/server/instance/httpapi/question.ts - that experimental slice is mounted under
/experimental/httpapi/question - that experimental slice already has an end-to-end test at
test/server/question-httpapi.test.ts
This means the package split should start from an extraction path, not from greenfield package ownership.
Structural reference
Use anomalyco/opentunnel as the structural reference for packages/server.
The important pattern there is:
packages/coreowns services and domain schemaspackages/server/src/definition/*owns pureHttpApicontractspackages/server/src/api/*ownsHttpApiBuilder.group(...)implementations and server-side middleware wiringpackages/server/src/index.tsbecomes the composition root only after the server package really owns runtime hosting
Relevant opentunnel files:
packages/server/src/definition/index.tspackages/server/src/definition/tunnel.tspackages/server/src/api/index.tspackages/server/src/api/tunnel.tspackages/server/src/api/client.tspackages/server/src/index.ts
The intended direction here is the same, but the current opencode package split is earlier in the migration.
That means:
- we should follow the same
definitionandapinaming - we should keep contract and implementation as separate modules from the start
- we should postpone the runtime composition root until
packages/coreexists enough to support it cleanly
Key decision
Start packages/server as a contract and implementation package only.
Do not make it the runtime host yet.
Why:
packages/coredoes not exist yet- the current server host still lives in
packages/opencode - moving host ownership immediately would force a large package and runtime shuffle while Effect service extraction is still in flight
- if
packages/serverimports services frompackages/opencodewhilepackages/opencodeimportspackages/serverto host routes, we create a package cycle immediately
Short version:
- create
packages/server - move pure
HttpApicontracts there - move handler factories there
- keep
packages/opencodeas the temporary Hono host - merge
packages/serverOpenAPI with the legacy Hono OpenAPI during the transition - move server hosting later, after
packages/coreexists enough
Dependency rule
Phase 1 rule:
packages/servermust not import frompackages/opencode
Allowed in phase 1:
packages/opencodeimportspackages/serverpackages/serveraccepts host-provided services, layers, or callbacks as inputspackages/servermay temporarily own transport-local placeholder schemas when a canonical shared schema does not exist yet
Future rule after packages/core exists:
packages/serverimports frompackages/corepackages/cliimports frompackages/serverandpackages/corepackages/opencodeshrinks or disappears as package responsibilities are fully split
HttpApi model
Use Effect v4 HttpApi as the source of truth for migrated HTTP routes.
Important properties from the current effect / effect-smol model:
HttpApi,HttpApiGroup, andHttpApiEndpointare pure contract definitions- handlers are implemented separately with
HttpApiBuilder.group(...) - OpenAPI can be generated from the contract alone
- auth and middleware can later be modeled with
HttpApiMiddleware.Service - SSE and websocket routes are not good first-wave
HttpApitargets
This package split should preserve that separation explicitly.
Default shape for migrated routes:
- contract lives in
packages/server/src/definition/* - implementation lives in
packages/server/src/api/* - host mounting stays outside for now
OpenAPI rule
During the transition there is still one spec artifact.
Default rule:
packages/servergenerates OpenAPI fromHttpApicontractpackages/opencodekeeps generating legacy OpenAPI from Hono routes- the temporary exported server spec is a merged document
packages/sdkcontinues consuming oneopenapi.json
Merge safety rules:
- fail on duplicate
path + method - fail on duplicate
operationId - prefer explicit summary, description, and operation ids on all new
HttpApiendpoints
Practical implication:
- do not make the SDK consume two specs
- do not switch SDK generation to
packages/serveronly until enough of the route surface has moved
Package shape
Minimum viable packages/server:
src/index.tssrc/definition/index.tssrc/definition/api.tssrc/definition/question.tssrc/api/index.tssrc/api/question.tssrc/openapi.tssrc/bridge/hono.tssrc/types.ts
Later additions, once there is enough real contract surface:
src/api/client.ts- runtime composition in
src/index.ts
Suggested initial exports:
apiopenapiquestionApimakeQuestionHandler
Phase 1 responsibilities:
- own pure API contracts
- own handler factories for migrated slices
- own contract-generated OpenAPI
- expose host adapters needed by
packages/opencode
Phase 1 non-goals:
- do not own
listen() - do not own adapter selection
- do not own global server middleware
- do not own websocket or SSE transport
- do not own process bootstrapping for CLI entrypoints
Current source inventory
These files matter for the first phase.
Current host and route composition:
src/server/server.tssrc/server/control/index.tssrc/server/instance/index.tssrc/server/middleware.tssrc/server/adapter.bun.tssrc/server/adapter.node.ts
Current experimental HttpApi slice:
src/server/instance/httpapi/question.tssrc/server/instance/httpapi/index.tssrc/server/instance/experimental.tstest/server/question-httpapi.test.ts
Current OpenAPI flow:
src/server/server.tsviaServer.openapi()src/cli/cmd/generate.tspackages/sdk/js/script/build.ts
Current runtime and service layer:
src/effect/app-runtime.tssrc/effect/run-service.ts
Ownership rules
Move first into packages/server:
- the experimental
questionHttpApislice - future
providerandconfigJSON read slices - any new
HttpApiroute groups - transport-local OpenAPI generation for migrated routes
Keep in packages/opencode for now:
src/server/server.tssrc/server/control/index.tssrc/server/instance/*.tssrc/server/middleware.tssrc/server/adapter.*.tssrc/effect/app-runtime.tssrc/effect/run-service.ts- all Effect services until they move to
packages/core
Placeholder schema rule
packages/core is allowed to lag behind.
Until shared canonical schemas move to packages/core:
- prefer importing existing Effect Schema DTOs from current locations when practical
- if a route only needs a transport-local type and moving the canonical schema would create unrelated churn, allow a temporary server-local placeholder schema
- if a placeholder is introduced, leave a short note so it does not become permanent
The default rule from schema.md still applies:
- Effect Schema owns the type
.zodis compatibility only- avoid parallel hand-written Zod and Effect definitions for the same migrated route shape
Host boundary rule
Until host ownership moves:
- auth stays at the outer Hono app level
- compression stays at the outer Hono app level
- CORS stays at the outer Hono app level
- instance and workspace lookup stay at the current middleware layer
packages/serverhandlers should assume the host already provided the right request context- do not redesign host middleware just to land the package split
This matches the current guidance in http-api.md:
- keep auth outside the first parallel
HttpApislices - keep instance lookup outside the first parallel
HttpApislices - keep the first migrations transport-focused and semantics-preserving
Route selection rules
Good early migration targets:
questionproviderauth read endpointconfigproviders read endpoint- small read-only instance routes
Bad early migration targets:
sessioneventpty- most
globalstreaming or process-heavy routes - anything requiring websocket upgrade handling
- anything that mixes many mutations and streaming in one file
First vertical slice
The first slice for the package split is the existing experimental question group.
Why question first:
- it already exists as an experimental
HttpApislice - it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
Use the first slice to prove:
- package boundary
- contract and implementation split
- host mounting from
packages/opencode - merged OpenAPI output
- test ergonomics for future slices
Do not broaden scope in the first slice.
Incremental migration order
Use small PRs.
Each PR should be easy to review, easy to revert, and should not mix extraction work with unrelated service refactors.
PR 1. Create packages/server
Scope:
- add the new workspace package
- add package manifest and tsconfig
- add empty
src/index.ts,src/definition/api.ts,src/definition/index.ts,src/api/index.ts,src/openapi.ts, and supporting scaffolding
Rules:
- no production behavior changes
- no host server changes yet
- no imports from
packages/opencodeinsidepackages/server - prefer
opentunnel-style naming from the start:definitionfor contracts,apifor implementations
Done means:
packages/servertypechecks- the workspace can import it
- the package boundary is in place for follow-up PRs
PR 2. Move the experimental question contract
Scope:
- extract the pure
HttpApicontract fromsrc/server/instance/httpapi/question.ts - place it in
packages/server/src/definition/question.ts - aggregate it in
packages/server/src/definition/api.ts - generate OpenAPI in
packages/server/src/openapi.ts
Rules:
- contract only in this PR
- no handler movement yet if that keeps the diff simpler
- keep operation ids and docs metadata stable
Done means:
- question contract lives in
packages/server - OpenAPI can be generated from contract alone
- no runtime behavior changes yet
PR 3. Move the experimental question handler factory
Scope:
- extract the question
HttpApiBuilder.group(...)implementation intopackages/server/src/api/question.ts - expose it as a factory that accepts host-provided dependencies or wiring
- add a small Hono bridge in
packages/server/src/bridge/hono.tsif needed
Rules:
packages/servermust still not import frompackages/opencode- handler code should stay thin and service-delegating
- do not redesign the question service itself in this PR
Done means:
packages/servercan produce the experimental question handler- the package still stays cycle-free
PR 4. Mount packages/server question from packages/opencode
Scope:
- replace local experimental question route wiring in
packages/opencode - keep the same mount path:
/experimental/httpapi/question/experimental/httpapi/question/doc
Rules:
- no behavior change
- preserve existing docs path
- preserve current request and response shapes
Done means:
- existing question
HttpApitest still passes - runtime behavior is unchanged
- the current host server is now consuming
packages/server
PR 5. Merge legacy and contract OpenAPI
Scope:
- keep
Server.openapi()as the temporary spec entrypoint - generate legacy Hono spec
- generate
packages/servercontract spec - merge them into one document
- keep
cli/cmd/generate.tsandpackages/sdk/js/script/build.tsconsuming one spec
Rules:
- fail loudly on duplicate
path + method - fail loudly on duplicate
operationId - do not silently overwrite one source with the other
Done means:
- one merged spec is produced
- migrated question paths can come from
packages/server - existing SDK generation path still works
PR 6. Add merged OpenAPI coverage
Scope:
- add one test for merged OpenAPI
- assert both a legacy Hono route and a migrated
HttpApiroute exist
Rules:
- test the merged document, not just the
packages/servercontract spec in isolation - pick one stable legacy route and one stable migrated route
Done means:
- the merged-spec path is covered
- future route migrations have a guardrail
PR 7. Migrate GET /provider/auth
Scope:
- add
GET /provider/authas the nextHttpApislice inpackages/server - mount it in parallel from
packages/opencode
Why this route:
- JSON-only
- simple service delegation
- small response shape
- already listed as the best next
providercandidate inhttp-api.md
Done means:
- route works through the current host
- route appears in merged OpenAPI
- no semantic change to provider auth behavior
PR 8. Migrate GET /config/providers
Scope:
- add
GET /config/providersas aHttpApislice inpackages/server - mount it in parallel from
packages/opencode
Why this route:
- JSON-only
- read-only
- low transport complexity
- already listed as the best next
configcandidate inhttp-api.md
Done means:
- route works unchanged
- route appears in merged OpenAPI
PR 9+. Migrate small read-only instance routes
Candidate order:
GET /pathGET /vcsGET /vcs/diffGET /commandGET /agentGET /skill
Rules:
- one or two endpoints per PR
- prefer read-only routes first
- keep outer middleware unchanged
- keep business logic in the existing service layer
Done means for each PR:
- contract lives in
packages/server - handler lives in
packages/server - route is mounted from the current host
- route appears in merged OpenAPI
- behavior remains unchanged
Later PR. Move host ownership into packages/server
Only start this after there is enough packages/core surface to depend on directly.
Scope:
- move server composition into
packages/server - add embeddable APIs such as
createServer(...),listen(...), orcreateApp(...) - move adapter selection and server startup out of
packages/opencode
Rules:
- do not start this while
packages/serverstill depends onpackages/opencode - do not mix this with route migration PRs
Done means:
packages/servercan be embedded in another Node apppackages/clican depend onpackages/server- host logic no longer lives in
packages/opencode
PR sizing rule
Every migration PR should satisfy all of these:
- one route group or one to two endpoints
- no unrelated service refactor
- no auth redesign
- no middleware redesign
- OpenAPI updated
- at least one route test or spec test added or updated
Done means for a migrated route group
A route group migration is complete only when:
- the
HttpApicontract lives inpackages/server - handler implementation lives in
packages/server - the route is mounted from the current host in
packages/opencode - the route appears in merged OpenAPI
- request and response schemas are Effect Schema-first or clearly temporary placeholders
- existing behavior remains unchanged
- the route has straightforward test coverage
Validation expectations
For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
bun typecheckin the touched package directory or directories- the relevant route test, especially
test/server/question-httpapi.test.ts - merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
Main risks
Package cycle
This is the biggest risk.
Bad state:
packages/serverimports services or runtime frompackages/opencodepackages/opencodeimports route definitions or handlers frompackages/server
Avoid by:
- keeping phase-1
packages/serverfree ofpackages/opencodeimports - using factories and host-provided wiring instead of direct service imports
Spec drift
During the transition there are two route-definition sources.
Avoid by:
- one merged spec
- collision checks
- explicit
operationIds - merged OpenAPI tests
Middleware mismatch
Current auth, compression, CORS, and instance selection are Hono-centered.
Avoid by:
- leaving them where they are during the first wave
- not trying to solve
HttpApiMiddleware.Serviceglobally in the package-split PRs
Core lag
packages/core will not be ready everywhere.
Avoid by:
- allowing small transport-local placeholder schemas where necessary
- keeping those placeholders clearly temporary
- not blocking the server extraction on full schema movement
Scope creep
The first vertical slice is easy to overload.
Avoid by:
- proving the package boundary first
- not mixing package creation, route migration, host redesign, and core extraction in the same change
Non-goals for the first wave
- do not replace all Hono routes at once
- do not migrate SSE or websocket routes first
- do not redesign auth
- do not redesign instance lookup
- do not wait for full
packages/corebefore startingpackages/server - do not change SDK generation to consume multiple specs
Checklist
- create
packages/server - add package-level exports for contract and OpenAPI
- extract
questioncontract intopackages/server - extract
questionhandler factory intopackages/server - mount
questionfrompackages/opencode - merge legacy and contract OpenAPI into one document
- add merged-spec coverage
- migrate
GET /provider/auth - migrate
GET /config/providers - migrate small read-only instance routes one or two at a time
- move host ownership into
packages/serveronly afterpackages/coreis ready enough - split
packages/cliafter server and core boundaries are stable
Rule of thumb
The fastest correct path is:
- establish
packages/serveras the contract-first boundary - keep
packages/opencodeas the temporary host - migrate a few safe JSON routes
- keep one merged OpenAPI document
- move actual host ownership only after
packages/corecan support it cleanly
If a proposed PR would make packages/server import from packages/opencode, stop and restructure the boundary first.