|
|
@@ -5,7 +5,10 @@ import { lazy } from "@/util/lazy"
|
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
|
import { WorkspaceID } from "@/control-plane/schema"
|
|
|
+import { Context, Effect, Exit, Layer, Scope } from "effect"
|
|
|
+import { HttpRouter, HttpServer } from "effect/unstable/http"
|
|
|
import { OpenApi } from "effect/unstable/httpapi"
|
|
|
+import * as HttpApiServer from "#httpapi-server"
|
|
|
import { MDNS } from "./mdns"
|
|
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
|
|
import { FenceMiddleware } from "./fence"
|
|
|
@@ -18,6 +21,8 @@ import { WorkspaceRouterMiddleware } from "./workspace"
|
|
|
import { InstanceMiddleware } from "./routes/instance/middleware"
|
|
|
import { WorkspaceRoutes } from "./routes/control/workspace"
|
|
|
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
|
|
|
+import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
|
|
|
+import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
|
|
|
import { PublicApi } from "./routes/instance/httpapi/public"
|
|
|
import * as ServerBackend from "./backend"
|
|
|
import type { CorsOptions } from "./cors"
|
|
|
@@ -182,37 +187,147 @@ export async function openapiHono() {
|
|
|
export let url: URL
|
|
|
|
|
|
export async function listen(opts: ListenOptions): Promise<Listener> {
|
|
|
- const built = create(opts)
|
|
|
- const server = await built.runtime.listen(opts)
|
|
|
+ const selected = select()
|
|
|
+ const inner: Listener =
|
|
|
+ selected.backend === "effect-httpapi" ? await listenHttpApi(opts, selected) : await listenLegacy(opts)
|
|
|
|
|
|
- const next = new URL("http://localhost")
|
|
|
- next.hostname = opts.hostname
|
|
|
- next.port = String(server.port)
|
|
|
+ const next = new URL(inner.url)
|
|
|
url = next
|
|
|
|
|
|
const mdns =
|
|
|
opts.mdns &&
|
|
|
- server.port &&
|
|
|
+ inner.port &&
|
|
|
opts.hostname !== "127.0.0.1" &&
|
|
|
opts.hostname !== "localhost" &&
|
|
|
opts.hostname !== "::1"
|
|
|
if (mdns) {
|
|
|
- MDNS.publish(server.port, opts.mdnsDomain)
|
|
|
+ MDNS.publish(inner.port, opts.mdnsDomain)
|
|
|
} else if (opts.mdns) {
|
|
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
|
|
}
|
|
|
|
|
|
let closing: Promise<void> | undefined
|
|
|
+ let mdnsUnpublished = false
|
|
|
+ const unpublish = () => {
|
|
|
+ if (!mdns || mdnsUnpublished) return
|
|
|
+ mdnsUnpublished = true
|
|
|
+ MDNS.unpublish()
|
|
|
+ }
|
|
|
return {
|
|
|
- hostname: opts.hostname,
|
|
|
- port: server.port,
|
|
|
+ hostname: inner.hostname,
|
|
|
+ port: inner.port,
|
|
|
url: next,
|
|
|
stop(close?: boolean) {
|
|
|
- closing ??= (async () => {
|
|
|
- if (mdns) MDNS.unpublish()
|
|
|
- await server.stop(close)
|
|
|
- })()
|
|
|
- return closing
|
|
|
+ unpublish()
|
|
|
+ // Always forward stop(true), even if a graceful stop was requested
|
|
|
+ // first, so native listeners can escalate shutdown in-place.
|
|
|
+ const next = inner.stop(close)
|
|
|
+ closing ??= next
|
|
|
+ return close ? next.then(() => closing!) : closing
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function listenLegacy(opts: ListenOptions): Promise<Listener> {
|
|
|
+ const built = create(opts)
|
|
|
+ const server = await built.runtime.listen(opts)
|
|
|
+ const innerUrl = new URL("http://localhost")
|
|
|
+ innerUrl.hostname = opts.hostname
|
|
|
+ innerUrl.port = String(server.port)
|
|
|
+ return {
|
|
|
+ hostname: opts.hostname,
|
|
|
+ port: server.port,
|
|
|
+ url: innerUrl,
|
|
|
+ stop: (close?: boolean) => server.stop(close),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Run the effect-httpapi backend on a native Effect HTTP server. This
|
|
|
+ * lets HttpApi routes that call `request.upgrade` (PTY connect, the
|
|
|
+ * workspace-routing proxy WS bridge) work end-to-end; the legacy Hono
|
|
|
+ * adapter path can't surface `request.upgrade` because its fetch handler has
|
|
|
+ * no reference to the platform server instance for websocket upgrades.
|
|
|
+ */
|
|
|
+async function listenHttpApi(opts: ListenOptions, selection: ServerBackend.Selection): Promise<Listener> {
|
|
|
+ log.info("server backend selected", {
|
|
|
+ ...ServerBackend.attributes(selection),
|
|
|
+ "opencode.server.runtime": HttpApiServer.name,
|
|
|
+ })
|
|
|
+
|
|
|
+ const buildLayer = (port: number) =>
|
|
|
+ HttpRouter.serve(ExperimentalHttpApiServer.createRoutes(opts), {
|
|
|
+ middleware: disposeMiddleware,
|
|
|
+ disableLogger: true,
|
|
|
+ disableListenLog: true,
|
|
|
+ }).pipe(
|
|
|
+ Layer.provideMerge(WebSocketTracker.layer),
|
|
|
+ Layer.provideMerge(HttpApiServer.layer({ port, hostname: opts.hostname })),
|
|
|
+ )
|
|
|
+
|
|
|
+ const start = async (port: number) => {
|
|
|
+ const scope = Scope.makeUnsafe()
|
|
|
+ try {
|
|
|
+ // Effect's `HttpMiddleware` interface returns `Effect<…, any, any>` by
|
|
|
+ // design, which leaks `R = any` through `HttpRouter.serve`. The actual
|
|
|
+ // requirements at this point are fully satisfied by `createRoutes` and the
|
|
|
+ // platform HTTP server layer; cast away the `any` to satisfy `runPromise`.
|
|
|
+ const layer = buildLayer(port) as Layer.Layer<
|
|
|
+ HttpServer.HttpServer | WebSocketTracker.Service | HttpApiServer.Service,
|
|
|
+ unknown,
|
|
|
+ never
|
|
|
+ >
|
|
|
+ const ctx = await Effect.runPromise(Layer.buildWithMemoMap(layer, Layer.makeMemoMapUnsafe(), scope))
|
|
|
+ return { scope, ctx }
|
|
|
+ } catch (err) {
|
|
|
+ await Effect.runPromise(Scope.close(scope, Exit.void)).catch(() => undefined)
|
|
|
+ throw err
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Match the legacy adapter port-resolution behavior: explicit `0` prefers
|
|
|
+ // 4096 first, then any free port.
|
|
|
+ let resolved: Awaited<ReturnType<typeof start>> | undefined
|
|
|
+ if (opts.port === 0) {
|
|
|
+ resolved = await start(4096).catch(() => undefined)
|
|
|
+ if (!resolved) resolved = await start(0)
|
|
|
+ } else {
|
|
|
+ resolved = await start(opts.port)
|
|
|
+ }
|
|
|
+ if (!resolved) throw new Error(`Failed to start server on port ${opts.port}`)
|
|
|
+
|
|
|
+ const server = Context.get(resolved.ctx, HttpServer.HttpServer)
|
|
|
+ if (server.address._tag !== "TcpAddress") {
|
|
|
+ await Effect.runPromise(Scope.close(resolved.scope, Exit.void))
|
|
|
+ throw new Error(`Unexpected HttpServer address tag: ${server.address._tag}`)
|
|
|
+ }
|
|
|
+ const port = server.address.port
|
|
|
+
|
|
|
+ const innerUrl = new URL("http://localhost")
|
|
|
+ innerUrl.hostname = opts.hostname
|
|
|
+ innerUrl.port = String(port)
|
|
|
+ let forceStopPromise: Promise<void> | undefined
|
|
|
+ let stopPromise: Promise<void> | undefined
|
|
|
+ const forceStop = () => {
|
|
|
+ forceStopPromise ??= Effect.runPromiseExit(
|
|
|
+ Effect.gen(function* () {
|
|
|
+ yield* Context.get(resolved!.ctx, HttpApiServer.Service).closeAll
|
|
|
+ yield* Context.get(resolved!.ctx, WebSocketTracker.Service).closeAll
|
|
|
+ }),
|
|
|
+ ).then(() => undefined)
|
|
|
+ return forceStopPromise
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ hostname: opts.hostname,
|
|
|
+ port,
|
|
|
+ url: innerUrl,
|
|
|
+ stop: (close?: boolean) => {
|
|
|
+ const requested = close ? forceStop() : Promise.resolve()
|
|
|
+ // The first call starts scope shutdown. A later stop(true) cannot undo
|
|
|
+ // that, but it still runs forceStop() before awaiting the original close.
|
|
|
+ stopPromise ??= requested.then(() => Effect.runPromiseExit(Scope.close(resolved!.scope, Exit.void))).then(() => undefined)
|
|
|
+ return requested.then(() => stopPromise!)
|
|
|
},
|
|
|
}
|
|
|
}
|