Skip to content

Commit fd11bd7

Browse files
committed
refactor(server): unify instance httpapi middleware routing
1 parent 0ba1081 commit fd11bd7

16 files changed

Lines changed: 356 additions & 252 deletions

File tree

packages/opencode/src/server/routes/instance/httpapi/AGENTS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,20 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
1414

1515
For SSE endpoints, stay in `HttpApiBuilder.group(...)` and return `HttpServerResponse.stream(...)` from the handler. Annotate the endpoint success schema with `HttpApiSchema.asText({ contentType: "text/event-stream" })` so OpenAPI documents the stream content type.
1616

17-
Use raw `HttpRouter.use(...)` only for routes that do not fit the request/response HttpApi model, such as WebSocket upgrade routes or catch-all fallback routes. Yield stable services at route-layer construction and close over them in `router.add(...)` callbacks.
17+
Use `HttpApiBuilder.group(...)` with `handleRaw(...)` for declared endpoints that need the raw request or response, including WebSocket upgrade routes. This keeps endpoint middleware, routing context, and OpenAPI metadata on one typed route tree.
1818

1919
```ts
20-
export const rawRoute = HttpRouter.use((router) =>
20+
export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handlers) =>
2121
Effect.gen(function* () {
2222
const pty = yield* Pty.Service
2323

24-
yield* router.add("GET", PtyPaths.connect, (request) => connectPty(request, pty))
24+
return handlers.handleRaw("connect", (ctx) => connectPty(ctx.request, pty))
2525
}),
2626
)
2727
```
2828

29+
Use raw `HttpRouter.use(...)` only for routes outside the declared API surface, such as a catch-all UI fallback.
30+
2931
Avoid `Effect.provide(SomeLayer)` inside request handlers or raw route callbacks. Stable layers should be provided once at the application/layer boundary, not rebuilt or scoped per request.
3032

3133
Avoid `HttpRouter.provideRequest(...)` unless the dependency is intentionally request-level. Prefer `HttpRouter.use(...)` for stable app services.
@@ -34,4 +36,4 @@ Use `Effect.provideService(...)` in middleware only for request-derived context,
3436

3537
Public JSON errors should be explicit `Schema.ErrorClass` contracts declared on each endpoint. Use built-in `HttpApiError.*` classes only when their empty/tagged body is the intended wire shape; for SDK-visible errors with messages, define an API error schema such as `ApiNotFoundError` and fail with that exact declared error. Keep domain and storage services free of HttpApi types, and translate expected domain errors at the handler boundary.
3638

37-
When adding middleware, compose it at the layer boundary and keep the route tree explicit in `server.ts`. Shared router middleware such as auth, workspace routing, and instance context should stay visible where routes are assembled.
39+
When adding middleware, declare endpoint-contract middleware on the owning `HttpApiGroup` and provide its implementation layer at the assembly boundary in `server.ts`. Keep router middleware for truly raw fallback routes or global transport policy.

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { McpApi } from "./groups/mcp"
1313
import { PermissionApi } from "./groups/permission"
1414
import { ProjectApi } from "./groups/project"
1515
import { ProviderApi } from "./groups/provider"
16-
import { PtyApi, PtyConnectApi } from "./groups/pty"
16+
import { PtyApi } from "./groups/pty"
1717
import { QuestionApi } from "./groups/question"
1818
import { SessionApi } from "./groups/session"
1919
import { SyncApi } from "./groups/sync"
@@ -55,7 +55,6 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
5555
.addHttpApi(RootHttpApi)
5656
.addHttpApi(EventApi)
5757
.addHttpApi(InstanceHttpApi)
58-
.addHttpApi(PtyConnectApi)
5958
.annotate(HttpApi.AdditionalSchemas, [EventSchema, ...SyncEventSchemas])
6059

6160
export type RootHttpApiType = typeof RootHttpApi

packages/opencode/src/server/routes/instance/httpapi/groups/event.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Schema } from "effect"
22
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
3-
import { WorkspaceRoutingQuery } from "../middleware/workspace-routing"
3+
import { Authorization } from "../middleware/authorization"
4+
import { InstanceContextMiddleware } from "../middleware/instance-context"
5+
import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing"
46

57
export const EventPaths = {
68
event: "/event",
@@ -20,5 +22,8 @@ export const EventApi = HttpApi.make("event").add(
2022
}),
2123
),
2224
)
25+
.middleware(InstanceContextMiddleware)
26+
.middleware(WorkspaceRoutingMiddleware)
27+
.middleware(Authorization)
2328
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
2429
)

packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Pty } from "@/pty"
22
import { PtyTicket } from "@/pty/ticket"
33
import { PtyID } from "@/pty/schema"
4+
import { PTY_CONNECT_TICKET_QUERY } from "@/server/shared/pty-ticket"
45
import { Schema } from "effect"
56
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
6-
import { Authorization } from "../middleware/authorization"
7+
import { Authorization, PtyConnectAuthorization } from "../middleware/authorization"
78
import { InstanceContextMiddleware } from "../middleware/instance-context"
89
import {
910
WorkspaceRoutingMiddleware,
@@ -15,9 +16,10 @@ import { described } from "./metadata"
1516

1617
const root = "/pty"
1718
export const Params = Schema.Struct({ ptyID: PtyID })
18-
export const CursorQuery = Schema.Struct({
19+
export const ConnectQuery = Schema.Struct({
1920
...WorkspaceRoutingQueryFields,
2021
cursor: Schema.optional(Schema.String),
22+
[PTY_CONNECT_TICKET_QUERY]: Schema.optional(Schema.String),
2123
})
2224
export const ShellItem = Schema.Struct({
2325
path: Schema.String,
@@ -127,30 +129,32 @@ export const PtyApi = HttpApi.make("pty")
127129
.middleware(WorkspaceRoutingMiddleware)
128130
.middleware(Authorization),
129131
)
132+
.add(
133+
HttpApiGroup.make("pty-connect")
134+
.add(
135+
HttpApiEndpoint.get("connect", PtyPaths.connect, {
136+
params: Params,
137+
query: ConnectQuery,
138+
success: described(Schema.Boolean, "Connected session"),
139+
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
140+
}).annotateMerge(
141+
OpenApi.annotations({
142+
identifier: "pty.connect",
143+
summary: "Connect to PTY session",
144+
description:
145+
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
146+
}),
147+
),
148+
)
149+
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." }))
150+
.middleware(InstanceContextMiddleware)
151+
.middleware(WorkspaceRoutingMiddleware)
152+
.middleware(PtyConnectAuthorization),
153+
)
130154
.annotateMerge(
131155
OpenApi.annotations({
132156
title: "opencode experimental HttpApi",
133157
version: "0.0.1",
134158
description: "Experimental HttpApi surface for selected instance routes.",
135159
}),
136160
)
137-
138-
export const PtyConnectApi = HttpApi.make("pty-connect").add(
139-
HttpApiGroup.make("pty-connect")
140-
.add(
141-
HttpApiEndpoint.get("connect", PtyPaths.connect, {
142-
params: Params,
143-
query: WorkspaceRoutingQuery,
144-
success: described(Schema.Boolean, "Connected session"),
145-
error: [HttpApiError.Forbidden, HttpApiError.NotFound],
146-
}).annotateMerge(
147-
OpenApi.annotations({
148-
identifier: "pty.connect",
149-
summary: "Connect to PTY session",
150-
description:
151-
"Establish a WebSocket connection to interact with a pseudo-terminal (PTY) session in real-time.",
152-
}),
153-
),
154-
)
155-
.annotateMerge(OpenApi.annotations({ title: "pty", description: "PTY websocket route." })),
156-
)

packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts

Lines changed: 21 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,14 @@ import { handlePtyInput } from "@/pty/input"
55
import { Shell } from "@/shell/shell"
66
import { EffectBridge } from "@/effect/bridge"
77
import { CorsConfig, isAllowedRequestOrigin, type CorsOptions } from "@/server/cors"
8-
import {
9-
PTY_CONNECT_TICKET_QUERY,
10-
PTY_CONNECT_TOKEN_HEADER,
11-
PTY_CONNECT_TOKEN_HEADER_VALUE,
12-
} from "@/server/shared/pty-ticket"
8+
import { PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE } from "@/server/shared/pty-ticket"
139
import { Effect } from "effect"
14-
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
10+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
1511
import { HttpApiBuilder } from "effect/unstable/httpapi"
1612
import * as Socket from "effect/unstable/socket/Socket"
1713
import { InstanceHttpApi } from "../api"
1814
import * as ApiError from "../errors"
19-
import { CursorQuery, Params, PtyPaths } from "../groups/pty"
15+
import { ConnectQuery } from "../groups/pty"
2016
import { WebSocketTracker } from "../websocket-tracker"
2117

2218
function validOrigin(request: HttpServerRequest.HttpServerRequest, opts: CorsOptions | undefined) {
@@ -121,37 +117,37 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler
121117
}),
122118
)
123119

124-
export const ptyConnectRoute = HttpRouter.use((router) =>
120+
export const ptyConnectHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty-connect", (handlers) =>
125121
Effect.gen(function* () {
126122
const pty = yield* Pty.Service
127123
const tickets = yield* PtyTicket.Service
128124
const cors = yield* CorsConfig
129-
yield* router.add(
130-
"GET",
131-
PtyPaths.connect,
132-
Effect.gen(function* () {
133-
const params = yield* HttpRouter.schemaPathParams(Params)
134-
const exists = yield* pty.get(params.ptyID).pipe(
125+
126+
return handlers.handleRaw(
127+
"connect",
128+
Effect.fn("PtyHttpApi.connect")(function* (ctx: {
129+
params: { ptyID: PtyID }
130+
query: typeof ConnectQuery.Type
131+
request: HttpServerRequest.HttpServerRequest
132+
}) {
133+
const exists = yield* pty.get(ctx.params.ptyID).pipe(
135134
Effect.as(true),
136135
Effect.catchTag("Pty.NotFoundError", () => Effect.succeed(false)),
137136
)
138137
if (!exists) return HttpServerResponse.empty({ status: 404 })
139138

140-
const query = yield* HttpServerRequest.schemaSearchParams(CursorQuery)
141-
const request = yield* HttpServerRequest.HttpServerRequest
142-
const ticket = new URL(request.url, "http://localhost").searchParams.get(PTY_CONNECT_TICKET_QUERY)
143-
if (ticket) {
144-
const valid = validOrigin(request, cors)
145-
? yield* tickets.consume({ ticket, ptyID: params.ptyID, ...(yield* PtyTicket.scope) })
139+
if (ctx.query.ticket) {
140+
const valid = validOrigin(ctx.request, cors)
141+
? yield* tickets.consume({ ticket: ctx.query.ticket, ptyID: ctx.params.ptyID, ...(yield* PtyTicket.scope) })
146142
: false
147143
if (!valid) return HttpServerResponse.empty({ status: 403 })
148144
}
149-
const parsedCursor = query.cursor === undefined ? undefined : Number(query.cursor)
145+
const parsedCursor = ctx.query.cursor === undefined ? undefined : Number(ctx.query.cursor)
150146
const cursor =
151147
parsedCursor !== undefined && Number.isSafeInteger(parsedCursor) && parsedCursor >= -1
152148
? parsedCursor
153149
: undefined
154-
const socket = yield* Effect.orDie(request.upgrade)
150+
const socket = yield* Effect.orDie(ctx.request.upgrade)
155151
const write = yield* socket.writer
156152
const closeAccepted = (event: Socket.CloseEvent) =>
157153
socket
@@ -186,20 +182,16 @@ export const ptyConnectRoute = HttpRouter.use((router) =>
186182
},
187183
}
188184
const handler = yield* pty
189-
.connect(params.ptyID, adapter, cursor)
185+
.connect(ctx.params.ptyID, adapter, cursor)
190186
.pipe(
191187
Effect.catchTag("Pty.NotFoundError", () =>
192188
closeAccepted(new Socket.CloseEvent(4404, "session not found")).pipe(Effect.as(undefined)),
193189
),
194190
)
195191
if (!handler) return HttpServerResponse.empty()
196192

197-
// No `pending[]`-style early-frame buffer (the legacy handler had one).
198-
// `request.upgrade` returns a Socket without running the WS handshake; the
199-
// handshake fires inside `socket.runRaw` below, AFTER `pty.connect` resolves
200-
// and the message callback is registered. The client therefore can't fire
201-
// `open` and start sending until the listener is already wired. Don't move
202-
// `runRaw` ahead of `pty.connect` without re-introducing a buffer.
193+
// The handshake runs inside `socket.runRaw`, after the input callback is
194+
// registered, so the client cannot send frames before PTY input is wired.
203195
yield* socket
204196
.runRaw((message) => handlePtyInput(handler, message))
205197
.pipe(

packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>(
2727
},
2828
) {}
2929

30+
export class PtyConnectAuthorization extends HttpApiMiddleware.Service<PtyConnectAuthorization>()(
31+
"@opencode/ExperimentalHttpApiPtyConnectAuthorization",
32+
{
33+
error: HttpApiError.UnauthorizedNoContent,
34+
},
35+
) {}
36+
3037
function emptyCredential() {
3138
return {
3239
username: "",
@@ -105,7 +112,6 @@ export const authorizationRouterMiddleware = HttpRouter.middleware()(
105112
const request = yield* HttpServerRequest.HttpServerRequest
106113
const url = new URL(request.url, "http://localhost")
107114
if (isPublicUIPath(request.method, url.pathname)) return yield* effect
108-
if (hasPtyConnectTicketURL(url)) return yield* effect
109115
return yield* credentialFromURL(url, request).pipe(
110116
Effect.flatMap((credential) => validateRawCredential(effect, credential, config)),
111117
)
@@ -129,6 +135,24 @@ export const authorizationLayer = Layer.effect(
129135
}),
130136
)
131137

138+
export const ptyConnectAuthorizationLayer = Layer.effect(
139+
PtyConnectAuthorization,
140+
Effect.gen(function* () {
141+
const config = yield* ServerAuth.Config
142+
if (!ServerAuth.required(config)) return PtyConnectAuthorization.of((effect) => effect)
143+
return PtyConnectAuthorization.of((effect) =>
144+
Effect.gen(function* () {
145+
const request = yield* HttpServerRequest.HttpServerRequest
146+
const url = new URL(request.url, "http://localhost")
147+
if (hasPtyConnectTicketURL(url)) return yield* effect
148+
return yield* credentialFromURL(url, request).pipe(
149+
Effect.flatMap((credential) => validateCredential(effect, credential, config)),
150+
)
151+
}),
152+
)
153+
}),
154+
)
155+
132156
export const v2AuthorizationLayer = Layer.effect(
133157
V2Authorization,
134158
Effect.gen(function* () {

packages/opencode/src/server/routes/instance/httpapi/middleware/instance-context.ts

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
22
import { InstanceStore } from "@/project/instance-store"
33
import { Effect, Layer } from "effect"
4-
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
4+
import { HttpServerResponse } from "effect/unstable/http"
55
import { HttpApiMiddleware } from "effect/unstable/httpapi"
66
import { WorkspaceRouteContext } from "./workspace-routing"
77

@@ -41,10 +41,3 @@ export const instanceContextLayer = Layer.effect(
4141
return InstanceContextMiddleware.of((effect) => provideInstanceContext(effect, store))
4242
}),
4343
)
44-
45-
export const instanceRouterMiddleware = HttpRouter.middleware()(
46-
Effect.gen(function* () {
47-
const store = yield* InstanceStore.Service
48-
return (effect) => provideInstanceContext(effect, store)
49-
}),
50-
)

packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL }
99
import { NotFoundError } from "@/storage/storage"
1010
import { Flag } from "@opencode-ai/core/flag/flag"
1111
import { Context, Data, Effect, Layer, Option, Schema } from "effect"
12-
import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
12+
import { HttpClient, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
1313
import { HttpApiMiddleware } from "effect/unstable/httpapi"
1414
import * as Socket from "effect/unstable/socket/Socket"
1515
import { InvalidRequestError } from "../errors"
@@ -219,7 +219,10 @@ function routeHttpApiWorkspace<E>(
219219
const sessionID = getWorkspaceRouteSessionID(requestURL(request))
220220
const session = sessionID
221221
? yield* Session.Service.use((svc) => svc.get(sessionID)).pipe(
222-
Effect.catchIf(NotFoundError.isInstance, () => Effect.succeed(undefined)),
222+
Effect.catchIf(
223+
(error): error is NotFoundError => NotFoundError.isInstance(error),
224+
() => Effect.succeed(undefined),
225+
),
223226
Effect.catchDefect(() => Effect.succeed(undefined)),
224227
)
225228
: undefined
@@ -242,20 +245,3 @@ export const workspaceRoutingLayer = Layer.effect(
242245
)
243246
}),
244247
)
245-
246-
export const workspaceRouterMiddleware = HttpRouter.middleware<{ provides: WorkspaceRouteContext }>()(
247-
Effect.gen(function* () {
248-
const makeWebSocket = yield* Socket.WebSocketConstructor
249-
const workspace = yield* Workspace.Service
250-
const client = yield* HttpClient.HttpClient
251-
return (effect) =>
252-
Effect.gen(function* () {
253-
const request = yield* HttpServerRequest.HttpServerRequest
254-
const plan = yield* planRequest(request)
255-
return yield* routeWorkspace(client, effect, plan)
256-
}).pipe(
257-
Effect.provideService(Socket.WebSocketConstructor, makeWebSocket),
258-
Effect.provideService(Workspace.Service, workspace),
259-
)
260-
}),
261-
)

0 commit comments

Comments
 (0)