Skip to content

Commit b0eae5e

Browse files
authored
feat: bridge permission and provider auth routes behind OPENCODE_EXPERIMENTAL_HTTPAPI (#22736)
1 parent 702f741 commit b0eae5e

6 files changed

Lines changed: 79 additions & 36 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,46 @@ Why `question` first:
121121

122122
Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers.
123123

124-
### 4. Build in parallel, do not bridge into Hono
124+
### 4. Bridge into Hono behind a feature flag
125125

126-
The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`.
126+
The `HttpApi` routes are bridged into the Hono server via `HttpRouter.toWebHandler` with a shared `memoMap`. This means:
127127

128-
The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
128+
- one process, one port — no separate server
129+
- the Effect handler shares layer instances with `AppRuntime` (same `Question.Service`, etc.)
130+
- Effect middleware handles auth and instance lookup independently from Hono middleware
131+
- Hono's `.all()` catch-all intercepts matching paths before the Hono route handlers
129132

130-
The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes.
133+
The bridge is gated behind `OPENCODE_EXPERIMENTAL_HTTPAPI` (or `OPENCODE_EXPERIMENTAL`). When the flag is off (default), all requests go through the original Hono handlers unchanged.
131134

132-
### 5. Migrate JSON route groups gradually
135+
```ts
136+
// in instance/index.ts
137+
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
138+
const handler = ExperimentalHttpApiServer.webHandler().handler
139+
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
140+
}
141+
```
133142

134-
If the parallel slice works well, migrate additional JSON route groups one at a time. Leave streaming-style endpoints on Hono until there is a clear reason to move them.
143+
The Hono route handlers are always registered (after the bridge) so `hono-openapi` generates the OpenAPI spec entries that feed SDK codegen. When the flag is on, these handlers are dead code — the `.all()` bridge matches first.
144+
145+
### 5. Observability
146+
147+
The `webHandler` provides `Observability.layer` via `Layer.provideMerge`. Since the `memoMap` is shared with `AppRuntime`, the tracing provider is deduplicated — no extra initialization cost.
148+
149+
This gives:
150+
151+
- **spans**: `Effect.fn("QuestionHttpApi.list")` etc. appear in traces alongside service-layer spans
152+
- **HTTP logs**: `HttpMiddleware.logger` emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` annotations, flowing to motel via `OtlpLogger`
153+
154+
### 6. Migrate JSON route groups gradually
155+
156+
As each route group is ported to `HttpApi`:
157+
158+
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
159+
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
160+
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
161+
4. verify SDK output is unchanged
162+
163+
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
135164

136165
## Schema rule for HttpApi work
137166

@@ -302,36 +331,43 @@ The first slice is successful if:
302331
- OpenAPI is generated from the `HttpApi` contract
303332
- the tests are straightforward enough that the next slice feels mechanical
304333

305-
## Learnings from the question slice
334+
## Learnings
306335

307-
The first parallel `question` spike gave us a concrete pattern to reuse.
336+
### Schema
308337

309338
- `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`.
310339
- scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes.
311340
- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects.
312341
- internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes.
313-
- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged.
314-
- compare generated OpenAPI semantically at the route and schema level.
342+
- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes.
343+
344+
### Integration
345+
346+
- `HttpRouter.toWebHandler` with the shared `memoMap` from `run-service.ts` cleanly bridges Effect routes into Hono — one process, one port, shared layer instances.
347+
- `Observability.layer` must be explicitly provided via `Layer.provideMerge` in the routes layer for OTEL spans and HTTP logs to flow. The `memoMap` deduplicates it with `AppRuntime` — no extra cost.
348+
- `HttpMiddleware.logger` (enabled by default when `disableLogger` is not set) emits structured `Effect.log` entries with `http.method`, `http.url`, `http.status` — these flow through `OtlpLogger` to motel.
349+
- Hono OpenAPI stubs must remain registered for SDK codegen until the SDK pipeline reads from the Effect OpenAPI spec instead.
350+
- the `OPENCODE_EXPERIMENTAL_HTTPAPI` flag gates the bridge at the Hono router level — default off, no behavior change unless opted in.
315351

316352
## Route inventory
317353

318354
Status legend:
319355

320-
- `done` - parallel `HttpApi` slice exists
356+
- `bridged` - Effect HttpApi slice exists and is bridged into Hono behind the flag
357+
- `done` - Effect HttpApi slice exists but not yet bridged
321358
- `next` - good near-term candidate
322359
- `later` - possible, but not first wave
323360
- `defer` - not a good early `HttpApi` target
324361

325362
Current instance route inventory:
326363

327-
- `question` - `done`
328-
endpoints in slice: `GET /question`, `POST /question/:requestID/reply`
329-
- `permission` - `done`
330-
endpoints in slice: `GET /permission`, `POST /permission/:requestID/reply`
331-
- `provider` - `next`
332-
best next endpoint: `GET /provider/auth`
333-
later endpoint: `GET /provider`
334-
defer first-wave OAuth mutations
364+
- `question` - `bridged`
365+
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
366+
- `permission` - `bridged`
367+
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
368+
- `provider` - `bridged` (partial)
369+
bridged endpoint: `GET /provider/auth`
370+
not yet ported: `GET /provider`, OAuth mutations
335371
- `config` - `next`
336372
best next endpoint: `GET /config/providers`
337373
later endpoint: `GET /config`
@@ -371,7 +407,13 @@ Recommended near-term sequence after the first spike:
371407
- [x] keep the underlying service calls identical to the current handlers
372408
- [x] compare generated OpenAPI against the current Hono/OpenAPI setup
373409
- [x] document how auth, instance lookup, and error mapping would compose in the new stack
374-
- [ ] decide after the spike whether `HttpApi` should stay parallel, replace only some groups, or become the long-term default
410+
- [x] bridge Effect routes into Hono via `toWebHandler` with shared `memoMap`
411+
- [x] gate behind `OPENCODE_EXPERIMENTAL_HTTPAPI` flag
412+
- [x] verify OTEL spans and HTTP logs flow to motel
413+
- [x] bridge question, permission, and provider auth routes
414+
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
415+
- [ ] port `config` read endpoints
416+
- [ ] decide when to remove the flag and make Effect routes the default
375417

376418
## Rule of thumb
377419

packages/opencode/src/server/instance/httpapi/permission.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { PermissionID } from "@/permission/schema"
33
import { Effect, Layer, Schema } from "effect"
44
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
55

6-
const root = "/experimental/httpapi/permission"
6+
const root = "/permission"
77

88
export const PermissionApi = HttpApi.make("permission")
99
.add(
@@ -45,7 +45,7 @@ export const PermissionApi = HttpApi.make("permission")
4545
}),
4646
)
4747

48-
export const PermissionLive = Layer.unwrap(
48+
export const permissionHandlers = Layer.unwrap(
4949
Effect.gen(function* () {
5050
const svc = yield* Permission.Service
5151

packages/opencode/src/server/instance/httpapi/provider.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { ProviderAuth } from "@/provider/auth"
22
import { Effect, Layer } from "effect"
33
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
44

5-
const root = "/experimental/httpapi/provider"
5+
const root = "/provider"
66

77
export const ProviderApi = HttpApi.make("provider")
88
.add(
@@ -33,7 +33,7 @@ export const ProviderApi = HttpApi.make("provider")
3333
}),
3434
)
3535

36-
export const ProviderLive = Layer.unwrap(
36+
export const providerHandlers = Layer.unwrap(
3737
Effect.gen(function* () {
3838
const svc = yield* ProviderAuth.Service
3939

packages/opencode/src/server/instance/httpapi/question.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const QuestionApi = HttpApi.make("question")
5555
}),
5656
)
5757

58-
export const QuestionLive = Layer.unwrap(
58+
export const questionHandlers = Layer.unwrap(
5959
Effect.gen(function* () {
6060
const svc = yield* Question.Service
6161

packages/opencode/src/server/instance/httpapi/server.ts

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { InstanceBootstrap } from "@/project/bootstrap"
1010
import { Instance } from "@/project/instance"
1111
import { lazy } from "@/util/lazy"
1212
import { Filesystem } from "@/util/filesystem"
13-
import { PermissionApi, PermissionLive } from "./permission"
14-
import { ProviderApi, ProviderLive } from "./provider"
15-
import { QuestionApi, QuestionLive } from "./question"
13+
import { PermissionApi, permissionHandlers } from "./permission"
14+
import { ProviderApi, providerHandlers } from "./provider"
15+
import { QuestionApi, questionHandlers } from "./question"
1616

1717
const Query = Schema.Struct({
1818
directory: Schema.optional(Schema.String),
@@ -111,13 +111,9 @@ export namespace ExperimentalHttpApiServer {
111111
const ProviderSecured = ProviderApi.middleware(Authorization)
112112

113113
export const routes = Layer.mergeAll(
114-
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(QuestionLive)),
115-
HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
116-
Layer.provide(PermissionLive),
117-
),
118-
HttpApiBuilder.layer(ProviderSecured, { openapiPath: "/experimental/httpapi/provider/doc" }).pipe(
119-
Layer.provide(ProviderLive),
120-
),
114+
HttpApiBuilder.layer(QuestionSecured).pipe(Layer.provide(questionHandlers)),
115+
HttpApiBuilder.layer(PermissionSecured).pipe(Layer.provide(permissionHandlers)),
116+
HttpApiBuilder.layer(ProviderSecured).pipe(Layer.provide(providerHandlers)),
121117
).pipe(
122118
Layer.provide(auth),
123119
Layer.provide(normalize),

packages/opencode/src/server/instance/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
4141

4242
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
4343
const handler = ExperimentalHttpApiServer.webHandler().handler
44-
app.all("/question", (c) => handler(c.req.raw)).all("/question/*", (c) => handler(c.req.raw))
44+
app
45+
.all("/question", (c) => handler(c.req.raw))
46+
.all("/question/*", (c) => handler(c.req.raw))
47+
.all("/permission", (c) => handler(c.req.raw))
48+
.all("/permission/*", (c) => handler(c.req.raw))
49+
.all("/provider/auth", (c) => handler(c.req.raw))
4550
}
4651

4752
return app

0 commit comments

Comments
 (0)