Skip to content

Commit a49a49e

Browse files
Apply PR #24853: Default dev and beta builds to HttpApi
2 parents 4f56ac7 + a822e99 commit a49a49e

15 files changed

Lines changed: 838 additions & 181 deletions
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/event.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Effect, Schema } from "effect"
44
import * as Stream from "effect/Stream"
55
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
66
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
7+
import * as Sse from "effect/unstable/encoding/Sse"
78

89
const log = Log.create({ service: "server" })
910

@@ -27,8 +28,13 @@ export const EventApi = HttpApi.make("event").add(
2728
.annotateMerge(OpenApi.annotations({ title: "event", description: "Instance event stream route." })),
2829
)
2930

30-
function eventData(data: unknown) {
31-
return `data: ${JSON.stringify(data)}\n\n`
31+
function eventData(data: unknown): Sse.Event {
32+
return {
33+
_tag: "Event",
34+
event: "message",
35+
id: undefined,
36+
data: JSON.stringify(data),
37+
}
3238
}
3339

3440
export const eventRoute = HttpRouter.add(
@@ -47,6 +53,7 @@ export const eventRoute = HttpRouter.add(
4753
Stream.make({ type: "server.connected", properties: {} }).pipe(
4854
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
4955
Stream.map(eventData),
56+
Stream.pipeThroughChannel(Sse.encode()),
5057
Stream.encodeText,
5158
Stream.ensuring(Effect.sync(() => log.info("event disconnected"))),
5259
),

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

Lines changed: 37 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import { Installation } from "@/installation"
44
import { Instance } from "@/project/instance"
55
import { InstallationVersion } from "@opencode-ai/core/installation/version"
66
import * as Log from "@opencode-ai/core/util/log"
7-
import { Effect, Schema } from "effect"
7+
import { Effect, Queue, Schema } from "effect"
8+
import * as Stream from "effect/Stream"
89
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
910
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
11+
import * as Sse from "effect/unstable/encoding/Sse"
1012

1113
const log = Log.create({ service: "server" })
1214

@@ -108,8 +110,13 @@ export const GlobalApi = HttpApi.make("global").add(
108110
.annotateMerge(OpenApi.annotations({ title: "global", description: "Global server routes." })),
109111
)
110112

111-
function eventData(data: unknown) {
112-
return `data: ${JSON.stringify(data)}\n\n`
113+
function eventData(data: unknown): Sse.Event {
114+
return {
115+
_tag: "Event",
116+
event: "message",
117+
id: undefined,
118+
data: JSON.stringify(data),
119+
}
113120
}
114121

115122
function parseBody(body: string) {
@@ -121,49 +128,35 @@ function parseBody(body: string) {
121128
}
122129

123130
function eventResponse() {
124-
const encoder = new TextEncoder()
125-
let heartbeat: ReturnType<typeof setInterval> | undefined
126-
let unsubscribe = () => {}
127-
let done = false
128-
129-
const cleanup = () => {
130-
if (done) return
131-
done = true
132-
if (heartbeat) clearInterval(heartbeat)
133-
unsubscribe()
134-
log.info("global event disconnected")
135-
}
136-
137131
log.info("global event connected")
138-
return HttpServerResponse.raw(
139-
new Response(
140-
new ReadableStream<Uint8Array>({
141-
start(controller) {
142-
const write = (data: unknown) => {
143-
if (done) return
144-
try {
145-
controller.enqueue(encoder.encode(eventData(data)))
146-
} catch {
147-
cleanup()
148-
}
149-
}
150-
const handler = (event: GlobalBusEvent) => write(event)
151-
unsubscribe = () => GlobalBus.off("event", handler)
152-
GlobalBus.on("event", handler)
153-
write({ payload: { type: "server.connected", properties: {} } })
154-
heartbeat = setInterval(() => write({ payload: { type: "server.heartbeat", properties: {} } }), 10_000)
155-
},
156-
cancel: cleanup,
157-
}),
158-
{
159-
headers: {
160-
"Cache-Control": "no-cache, no-transform",
161-
"Content-Type": "text/event-stream",
162-
"X-Accel-Buffering": "no",
163-
"X-Content-Type-Options": "nosniff",
164-
},
165-
},
132+
const events = Stream.callback<GlobalBusEvent>((queue) => {
133+
const handler = (event: GlobalBusEvent) => Queue.offerUnsafe(queue, event)
134+
return Effect.acquireRelease(
135+
Effect.sync(() => GlobalBus.on("event", handler)),
136+
() => Effect.sync(() => GlobalBus.off("event", handler)),
137+
)
138+
})
139+
const heartbeat = Stream.tick("10 seconds").pipe(
140+
Stream.drop(1),
141+
Stream.map(() => ({ payload: { type: "server.heartbeat", properties: {} } })),
142+
)
143+
144+
return HttpServerResponse.stream(
145+
Stream.make({ payload: { type: "server.connected", properties: {} } }).pipe(
146+
Stream.concat(events.pipe(Stream.merge(heartbeat, { haltStrategy: "left" }))),
147+
Stream.map(eventData),
148+
Stream.pipeThroughChannel(Sse.encode()),
149+
Stream.encodeText,
150+
Stream.ensuring(Effect.sync(() => log.info("global event disconnected"))),
166151
),
152+
{
153+
contentType: "text/event-stream",
154+
headers: {
155+
"Cache-Control": "no-cache, no-transform",
156+
"X-Accel-Buffering": "no",
157+
"X-Content-Type-Options": "nosniff",
158+
},
159+
},
167160
)
168161
}
169162

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
})

0 commit comments

Comments
 (0)