Skip to content

Commit 944de67

Browse files
Apply PR #24853: Default dev and beta builds to HttpApi
2 parents db03d0d + 7198c8f commit 944de67

13 files changed

Lines changed: 726 additions & 81 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Flag } from "@opencode-ai/core/flag/flag"
2+
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
3+
4+
export type Backend = "effect-httpapi" | "hono"
5+
6+
export type Selection = {
7+
backend: Backend
8+
reason: "env" | "channel" | "stable" | "explicit"
9+
}
10+
11+
export type Attributes = ReturnType<typeof attributes>
12+
13+
const channelDefaultsToHttpApi = () =>
14+
InstallationChannel === "local" ||
15+
InstallationChannel === "dev" ||
16+
InstallationChannel === "beta" ||
17+
InstallationVersion.includes("-dev") ||
18+
InstallationVersion.includes("-beta")
19+
20+
export function select(): Selection {
21+
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return { backend: "effect-httpapi", reason: "env" }
22+
if (channelDefaultsToHttpApi()) return { backend: "effect-httpapi", reason: "channel" }
23+
return { backend: "hono", reason: "stable" }
24+
}
25+
26+
export function attributes(selection: Selection): Record<string, string> {
27+
return {
28+
"opencode.server.backend": selection.backend,
29+
"opencode.server.backend.reason": selection.reason,
30+
"opencode.installation.channel": InstallationChannel,
31+
"opencode.installation.version": InstallationVersion,
32+
}
33+
}
34+
35+
export function force(selection: Selection, backend: Backend): Selection {
36+
return {
37+
backend,
38+
reason: selection.backend === backend ? selection.reason : "explicit",
39+
}
40+
}

packages/opencode/src/server/middleware.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Flag } from "@opencode-ai/core/flag/flag"
1010
import { basicAuth } from "hono/basic-auth"
1111
import { cors } from "hono/cors"
1212
import { compress } from "hono/compress"
13+
import * as ServerBackend from "./backend"
1314

1415
const log = Log.create({ service: "server" })
1516

@@ -49,20 +50,20 @@ export const AuthMiddleware: MiddlewareHandler = (c, next) => {
4950
return basicAuth({ username, password })(c, next)
5051
}
5152

52-
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
53-
const skip = c.req.path === "/log"
54-
if (!skip) {
55-
log.info("request", {
53+
export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): MiddlewareHandler {
54+
return async (c, next) => {
55+
const skip = c.req.path === "/log"
56+
if (skip) return next()
57+
const attributes = {
5658
method: c.req.method,
5759
path: c.req.path,
58-
})
60+
...backendAttributes,
61+
}
62+
log.info("request", attributes)
63+
const timer = log.time("request", attributes)
64+
await next()
65+
timer.stop()
5966
}
60-
const timer = log.time("request", {
61-
method: c.req.method,
62-
path: c.req.path,
63-
})
64-
await next()
65-
if (!skip) timer.stop()
6667
}
6768

6869
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {

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

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { Effect } from "effect"
33
import { HttpEffect, HttpMiddleware, HttpServerRequest } from "effect/unstable/http"
44

55
const disposeAfterResponse = new WeakMap<object, InstanceContext>()
6-
const reloadAfterResponse = new WeakMap<object, InstanceContext & { next: Parameters<typeof Instance.reload>[0] }>()
76

87
export const markInstanceForDisposal = (ctx: InstanceContext) =>
98
HttpEffect.appendPreResponseHandler((request, response) =>
@@ -14,27 +13,17 @@ export const markInstanceForDisposal = (ctx: InstanceContext) =>
1413
)
1514

1615
export const markInstanceForReload = (ctx: InstanceContext, next: Parameters<typeof Instance.reload>[0]) =>
17-
HttpEffect.appendPreResponseHandler((request, response) =>
18-
Effect.sync(() => {
19-
reloadAfterResponse.set(request.source, { ...ctx, next })
20-
return response
21-
}),
16+
HttpEffect.appendPreResponseHandler((_request, response) =>
17+
Effect.as(Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.reload(next)))), response),
2218
)
2319

2420
export const disposeMiddleware: HttpMiddleware.HttpMiddleware = (effect) =>
2521
Effect.gen(function* () {
2622
const response = yield* effect
2723
const request = yield* HttpServerRequest.HttpServerRequest
28-
const reload = reloadAfterResponse.get(request.source)
29-
if (reload) {
30-
reloadAfterResponse.delete(request.source)
31-
yield* Effect.promise(() => Instance.restore(reload, () => Instance.reload(reload.next)))
32-
return response
33-
}
34-
3524
const ctx = disposeAfterResponse.get(request.source)
3625
if (!ctx) return response
3726
disposeAfterResponse.delete(request.source)
38-
yield* Effect.promise(() => Instance.restore(ctx, () => Instance.dispose()))
27+
yield* Effect.uninterruptible(Effect.promise(() => Instance.restore(ctx, () => Instance.dispose())))
3928
return response
4029
})

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import { V2Api, v2Handlers } from "./v2"
5656
import { WorkspaceApi, workspaceHandlers } from "./workspace"
5757
import { disposeMiddleware } from "./lifecycle"
5858
import { memoMap } from "@opencode-ai/core/effect/memo-map"
59+
import * as ServerBackend from "@/server/backend"
5960

6061
const Query = Schema.Struct({
6162
directory: Schema.optional(Schema.String),
@@ -78,13 +79,21 @@ function decode(input: string) {
7879
}
7980
}
8081

82+
function currentDirectory() {
83+
try {
84+
return Instance.directory
85+
} catch {
86+
return process.cwd()
87+
}
88+
}
89+
8190
const instance = HttpRouter.middleware()(
8291
Effect.gen(function* () {
8392
return (effect) =>
8493
Effect.gen(function* () {
8594
const query = yield* HttpServerRequest.schemaSearchParams(Query)
8695
const headers = yield* HttpServerRequest.schemaHeaders(Headers)
87-
const raw = query.directory || headers["x-opencode-directory"] || process.cwd()
96+
const raw = query.directory || headers["x-opencode-directory"] || currentDirectory()
8897
const workspace = query.workspace || undefined
8998
const ctx = yield* Effect.promise(() =>
9099
Instance.provide({
@@ -100,6 +109,18 @@ const instance = HttpRouter.middleware()(
100109
}),
101110
).layer
102111

112+
const runtime = HttpRouter.middleware()(
113+
Effect.succeed((effect) =>
114+
Effect.gen(function* () {
115+
const selected = ServerBackend.select()
116+
yield* Effect.annotateCurrentSpan(
117+
ServerBackend.attributes(ServerBackend.force(selected, "effect-httpapi")),
118+
)
119+
return yield* effect
120+
}),
121+
),
122+
).layer
123+
103124
const controlRoutes = HttpApiBuilder.layer(ControlApi).pipe(Layer.provide(controlHandlers))
104125
const globalRoutes = HttpApiBuilder.layer(GlobalApi).pipe(Layer.provide(globalHandlers))
105126
const instanceApiRoutes = Layer.mergeAll(
@@ -127,6 +148,7 @@ const instanceRoutes = Layer.mergeAll(eventRoute, ptyConnectRoute, instanceApiRo
127148

128149
export const routes = Layer.mergeAll(controlRoutes, globalRoutes, instanceRoutes)
129150
.pipe(
151+
Layer.provide(runtime),
130152
Layer.provide(Account.defaultLayer),
131153
Layer.provide(Agent.defaultLayer),
132154
Layer.provide(Auth.defaultLayer),

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

Lines changed: 48 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { SessionSummary } from "@/session/summary"
1919
import { Todo } from "@/session/todo"
2020
import { MessageID, PartID, SessionID } from "@/session/schema"
2121
import { Snapshot } from "@/snapshot"
22+
import { NotFoundError } from "@/storage/storage"
2223
import * as Log from "@opencode-ai/core/util/log"
2324
import { NamedError } from "@opencode-ai/core/util/error"
2425
import { Effect, Schema, SchemaGetter, Struct } from "effect"
@@ -96,6 +97,14 @@ const PermissionResponsePayload = Schema.Struct({
9697
response: Permission.Reply,
9798
}).annotate({ identifier: "SessionPermissionResponseInput" })
9899

100+
const mapNotFound = <A, E, R>(self: Effect.Effect<A, E, R>) =>
101+
self.pipe(
102+
Effect.catchIf(NotFoundError.isInstance, () => Effect.fail(new HttpApiError.NotFound({}))),
103+
Effect.catchDefect((error) =>
104+
NotFoundError.isInstance(error) ? Effect.fail(new HttpApiError.NotFound({})) : Effect.die(error),
105+
),
106+
)
107+
99108
export const SessionPaths = {
100109
list: root,
101110
status: `${root}/status`,
@@ -151,6 +160,7 @@ export const SessionApi = HttpApi.make("session")
151160
HttpApiEndpoint.get("get", SessionPaths.get, {
152161
params: { sessionID: SessionID },
153162
success: Session.Info,
163+
error: HttpApiError.NotFound,
154164
}).annotateMerge(
155165
OpenApi.annotations({
156166
identifier: "session.get",
@@ -193,7 +203,7 @@ export const SessionApi = HttpApi.make("session")
193203
params: { sessionID: SessionID },
194204
query: MessagesQuery,
195205
success: Schema.Array(MessageV2.WithParts),
196-
error: HttpApiError.BadRequest,
206+
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
197207
}).annotateMerge(
198208
OpenApi.annotations({
199209
identifier: "session.messages",
@@ -204,6 +214,7 @@ export const SessionApi = HttpApi.make("session")
204214
HttpApiEndpoint.get("message", SessionPaths.message, {
205215
params: { sessionID: SessionID, messageID: MessageID },
206216
success: MessageV2.WithParts,
217+
error: HttpApiError.NotFound,
207218
}).annotateMerge(
208219
OpenApi.annotations({
209220
identifier: "session.message",
@@ -462,7 +473,7 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
462473
})
463474

464475
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
465-
return yield* session.get(ctx.params.sessionID)
476+
return yield* mapNotFound(session.get(ctx.params.sessionID))
466477
})
467478

468479
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
@@ -484,44 +495,47 @@ export const sessionHandlers = HttpApiBuilder.group(SessionApi, "session", (hand
484495
params: { sessionID: SessionID }
485496
query: typeof MessagesQuery.Type
486497
}) {
487-
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
488-
if (ctx.query.before) {
489-
const before = ctx.query.before
490-
yield* Effect.try({
491-
try: () => MessageV2.cursor.decode(before),
492-
catch: () => new HttpApiError.BadRequest({}),
493-
})
494-
}
495-
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
496-
yield* session.get(ctx.params.sessionID)
497-
return yield* session.messages({ sessionID: ctx.params.sessionID })
498-
}
498+
return yield* mapNotFound(Effect.gen(function* () {
499+
if (ctx.query.before && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
500+
if (ctx.query.before) {
501+
const before = ctx.query.before
502+
yield* Effect.try({
503+
try: () => MessageV2.cursor.decode(before),
504+
catch: () => new HttpApiError.BadRequest({}),
505+
})
506+
}
507+
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
508+
yield* session.get(ctx.params.sessionID)
509+
return yield* session.messages({ sessionID: ctx.params.sessionID })
510+
}
499511

500-
const page = MessageV2.page({
501-
sessionID: ctx.params.sessionID,
502-
limit: ctx.query.limit,
503-
before: ctx.query.before,
504-
})
505-
if (!page.cursor) return page.items
506-
507-
const request = yield* HttpServerRequest.HttpServerRequest
508-
const url = new URL(request.url, "http://localhost")
509-
url.searchParams.set("limit", ctx.query.limit.toString())
510-
url.searchParams.set("before", page.cursor)
511-
return HttpServerResponse.jsonUnsafe(page.items, {
512-
headers: {
513-
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
514-
Link: `<${url.toString()}>; rel="next"`,
515-
"X-Next-Cursor": page.cursor,
516-
},
517-
})
512+
yield* session.get(ctx.params.sessionID)
513+
const page = MessageV2.page({
514+
sessionID: ctx.params.sessionID,
515+
limit: ctx.query.limit,
516+
before: ctx.query.before,
517+
})
518+
if (!page.cursor) return page.items
519+
520+
const request = yield* HttpServerRequest.HttpServerRequest
521+
const url = new URL(request.url, "http://localhost")
522+
url.searchParams.set("limit", ctx.query.limit.toString())
523+
url.searchParams.set("before", page.cursor)
524+
return HttpServerResponse.jsonUnsafe(page.items, {
525+
headers: {
526+
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
527+
Link: `<${url.toString()}>; rel="next"`,
528+
"X-Next-Cursor": page.cursor,
529+
},
530+
})
531+
}))
518532
})
519533

520534
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
521535
params: { sessionID: SessionID; messageID: MessageID }
522536
}) {
523-
return yield* Effect.sync(() =>
524-
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
537+
return yield* mapNotFound(
538+
Effect.sync(() => MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID })),
525539
)
526540
})
527541

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,7 @@ export const tuiHandlers = HttpApiBuilder.group(TuiApi, "tui", (handlers) =>
254254
const selectSession = Effect.fn("TuiHttpApi.selectSession")(function* (ctx: {
255255
payload: typeof TuiEvent.SessionSelect.properties.Type
256256
}) {
257+
if (!ctx.payload.sessionID.startsWith("ses")) return yield* new HttpApiError.BadRequest({})
257258
const row = yield* Effect.sync(() =>
258259
Database.use((db) =>
259260
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, ctx.payload.sessionID)).get(),

packages/opencode/src/server/server.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { WorkspaceRouterMiddleware } from "./workspace"
1717
import { InstanceMiddleware } from "./routes/instance/middleware"
1818
import { WorkspaceRoutes } from "./routes/control/workspace"
1919
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
20+
import * as ServerBackend from "./backend"
2021

2122
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
2223
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -37,13 +38,38 @@ type ServerApp = {
3738
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
3839
}
3940

40-
const DefaultHono = lazy(() => createHono({}))
41-
const DefaultHttpApi = lazy(() => createHttpApi())
42-
export const Default = () => (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ? DefaultHttpApi() : DefaultHono())
41+
const DefaultHono = lazy(() => withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })))
42+
const DefaultHttpApi = lazy(() => createDefaultHttpApi())
43+
44+
function select() {
45+
return ServerBackend.select()
46+
}
47+
48+
export const backend = select
49+
50+
export const Default = () => {
51+
const selected = select()
52+
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
53+
}
4354

4455
function create(opts: { cors?: string[] }) {
45-
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) return createHttpApi()
46-
return createHono(opts)
56+
const selected = select()
57+
return selected.backend === "effect-httpapi"
58+
? withBackend(selected, createHttpApi())
59+
: withBackend(selected, createHono(opts, selected))
60+
}
61+
62+
export function Legacy(opts: { cors?: string[] } = {}) {
63+
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
64+
}
65+
66+
function createDefaultHttpApi() {
67+
return withBackend(select(), createHttpApi())
68+
}
69+
70+
function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection: ServerBackend.Selection, built: T) {
71+
log.info("server backend selected", ServerBackend.attributes(selection))
72+
return built
4773
}
4874

4975
function createHttpApi() {
@@ -60,11 +86,12 @@ function createHttpApi() {
6086
}
6187
}
6288

63-
function createHono(opts: { cors?: string[] }) {
89+
function createHono(opts: { cors?: string[] }, selection: ServerBackend.Selection = ServerBackend.force(select(), "hono")) {
90+
const backendAttributes = ServerBackend.attributes(selection)
6491
const app = new Hono()
6592
.onError(ErrorMiddleware)
6693
.use(AuthMiddleware)
67-
.use(LoggerMiddleware)
94+
.use(LoggerMiddleware(backendAttributes))
6895
.use(CompressionMiddleware)
6996
.use(CorsMiddleware(opts))
7097
.route("/global", GlobalRoutes())

0 commit comments

Comments
 (0)