2026-05-15 02:05:12 +00:00
import "./init-projectors"
2026-05-15 00:00:52 +00:00
import { NodeHttpServer } from "@effect/platform-node"
2026-07-01 21:12:00 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
2026-05-04 17:06:29 +00:00
import { ConfigProvider , Context , Effect , Exit , Layer , Scope } from "effect"
2026-05-03 18:23:29 +00:00
import { HttpRouter , HttpServer } from "effect/unstable/http"
2026-05-03 13:06:23 +00:00
import { OpenApi } from "effect/unstable/httpapi"
2026-05-15 00:00:52 +00:00
import { createServer } from "node:http"
2026-04-17 06:06:20 +00:00
import { MDNS } from "./mdns"
2026-05-15 00:00:52 +00:00
import { HttpApiApp } from "./routes/instance/httpapi/server"
2026-05-03 18:23:29 +00:00
import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle"
import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker"
2026-05-03 13:06:23 +00:00
import { PublicApi } from "./routes/instance/httpapi/public"
2026-06-14 10:46:39 +00:00
import type { CorsOptions } from "@opencode-ai/server/cors"
2026-05-15 00:00:52 +00:00
import { lazy } from "@/util/lazy"
2025-06-09 18:01:11 +00:00
2025-11-20 14:42:24 +00:00
// @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
globalThis . AI_SDK_LOG_WARNINGS = false
2026-04-16 23:51:01 +00:00
export type Listener = {
hostname : string
port : number
url : URL
stop : ( close? : boolean ) = > Promise < void >
}
2025-12-30 03:05:08 +00:00
2026-04-28 15:02:35 +00:00
type ServerApp = {
fetch ( request : Request ) : Response | Promise < Response >
request ( input : string | URL | Request , init? : RequestInit ) : Response | Promise < Response >
}
2026-05-01 01:26:32 +00:00
type ListenOptions = CorsOptions & {
port : number
hostname : string
mdns? : boolean
mdnsDomain? : string
}
2026-05-15 00:00:52 +00:00
type ListenerState = {
scope : Scope.Scope
server : Context.Service.Shape < typeof HttpServer.HttpServer >
http : ListenerServer
websockets : WebSocketTracker.Interface
}
type EffectListener = Omit < Listener , " stop " > & {
stop : ( close? : boolean ) = > Effect . Effect < void >
}
interface ListenerServer {
readonly closeAll : Effect.Effect < void >
}
class ListenerServerService extends Context . Service < ListenerServerService , ListenerServer > ( ) (
"@opencode/ListenerServer" ,
) { }
2026-05-01 01:26:32 +00:00
2026-05-15 00:00:52 +00:00
export const Default = lazy ( ( ) = > {
const handler = HttpApiApp . webHandler ( ) . handler
2026-04-28 15:02:35 +00:00
const app : ServerApp = {
2026-05-15 00:00:52 +00:00
fetch : ( request : Request ) = > handler ( request , HttpApiApp . context ) ,
2026-04-28 15:02:35 +00:00
request ( input , init ) {
return app . fetch ( input instanceof Request ? input : new Request ( new URL ( input , "http://localhost" ) , init ) )
} ,
}
2026-05-09 13:10:42 +00:00
return { app }
2026-05-15 00:00:52 +00:00
} )
2026-04-23 21:32:02 +00:00
2026-04-16 23:51:01 +00:00
export async function openapi() {
2026-05-03 13:06:23 +00:00
return OpenApi . fromApi ( PublicApi )
}
2026-06-14 17:28:48 +00:00
export let url : URL | undefined
2025-12-26 20:24:44 +00:00
2026-05-01 01:26:32 +00:00
export async function listen ( opts : ListenOptions ) : Promise < Listener > {
2026-05-15 00:00:52 +00:00
const listener = await Effect . runPromise ( listenEffect ( opts ) )
return {
hostname : listener.hostname ,
port : listener.port ,
url : listener.url ,
stop : ( close? : boolean ) = > Effect . runPromiseExit ( listener . stop ( close ) ) . then ( ( ) = > undefined ) ,
2026-05-03 18:23:29 +00:00
}
2026-05-15 00:00:52 +00:00
}
const listenEffect : ( opts : ListenOptions ) = > Effect . Effect < EffectListener , unknown > = Effect . fn ( "Server.listen" ) (
function * ( opts : ListenOptions ) {
const state = yield * startWithPortFallback ( opts )
const address = yield * tcpAddress ( state )
const listenerUrl = makeURL ( opts . hostname , address . port )
const unpublishMdns = yield * setupMdns ( opts , address . port , state . scope )
2026-06-14 17:28:48 +00:00
url = listenerUrl
2026-05-15 00:00:52 +00:00
return {
hostname : opts.hostname ,
port : address.port ,
url : listenerUrl ,
2026-06-14 17:28:48 +00:00
stop : yield * makeStop ( state , unpublishMdns , listenerUrl ) ,
2026-05-15 00:00:52 +00:00
}
} ,
)
function listenerLayer ( opts : ListenOptions , port : number ) {
return HttpRouter . serve ( HttpApiApp . createRoutes ( opts ) , {
middleware : disposeMiddleware ,
disableLogger : true ,
disableListenLog : true ,
} ) . pipe (
2026-07-01 21:12:00 +00:00
Layer . provideMerge ( AppNodeBuilder . build ( WebSocketTracker . node ) ) ,
2026-05-15 00:00:52 +00:00
Layer . provideMerge ( serverLayer ( { port , hostname : opts.hostname } ) ) ,
// Install a fresh `ConfigProvider` per listener so `Config.string(...)`
// reads reflect the current `process.env`. Effect's default
// `ConfigProvider` snapshots `process.env` on first read and caches the
// result on a module-singleton Reference; without overriding it here,
// every later `Server.listen()` keeps observing that initial snapshot.
Layer . provide ( ConfigProvider . layer ( ConfigProvider . fromEnv ( ) ) ) ,
)
}
function startWithPortFallback ( opts : ListenOptions ) {
if ( opts . port !== 0 ) return startListener ( opts , opts . port )
// Match the legacy listener port-resolution behavior: explicit `0` prefers
2026-05-03 18:23:29 +00:00
// 4096 first, then any free port.
2026-05-15 00:00:52 +00:00
return startListener ( opts , 4096 ) . pipe ( Effect . catch ( ( ) = > startListener ( opts , 0 ) ) )
}
2026-05-03 18:23:29 +00:00
2026-05-15 00:00:52 +00:00
function startListener ( opts : ListenOptions , port : number ) {
const scope = Scope . makeUnsafe ( )
return Layer . buildWithMemoMap ( listenerLayer ( opts , port ) , Layer . makeMemoMapUnsafe ( ) , scope ) . pipe (
Effect . provide ( HttpApiApp . context ) ,
Effect . onError ( ( ) = > Scope . close ( scope , Exit . void ) . pipe ( Effect . ignore ) ) ,
Effect . map (
( ctx ) : ListenerState = > ( {
scope ,
server : Context.get ( ctx , HttpServer . HttpServer ) ,
http : Context.get ( ctx , ListenerServerService ) ,
websockets : Context.get ( ctx , WebSocketTracker . Service ) ,
} ) ,
) ,
)
}
2026-05-09 13:10:42 +00:00
2026-05-15 00:00:52 +00:00
function tcpAddress ( state : ListenerState ) {
return Effect . gen ( function * ( ) {
if ( state . server . address . _tag === "TcpAddress" ) return state . server . address
yield * Scope . close ( state . scope , Exit . void ) . pipe ( Effect . ignore )
return yield * Effect . die ( new Error ( ` Unexpected HttpServer address tag: ${ state . server . address . _tag } ` ) )
} )
}
function makeURL ( hostname : string , port : number ) {
const result = new URL ( "http://localhost" )
result . hostname = hostname
result . port = String ( port )
return result
}
function setupMdns ( opts : ListenOptions , port : number , scope : Scope.Scope ) {
return Effect . gen ( function * ( ) {
const publish =
opts . mdns && port && opts . hostname !== "127.0.0.1" && opts . hostname !== "localhost" && opts . hostname !== "::1"
if ( publish ) {
const unpublish = yield * Effect . cached ( Effect . sync ( ( ) = > MDNS . unpublish ( ) ) )
yield * Effect . sync ( ( ) = > MDNS . publish ( port , opts . mdnsDomain ) )
yield * Scope . addFinalizer ( scope , unpublish )
return unpublish
}
2026-06-08 19:41:56 +00:00
if ( opts . mdns ) {
yield * Effect . logWarning ( "mDNS enabled but hostname is loopback; skipping mDNS publish" )
}
2026-05-15 00:00:52 +00:00
return Effect . void
} )
}
2026-06-14 17:28:48 +00:00
function makeStop ( state : ListenerState , unpublishMdns : Effect.Effect < void > , listenerUrl : URL ) {
2026-05-15 00:00:52 +00:00
return Effect . gen ( function * ( ) {
const forceCloseOnce = yield * Effect . cached ( forceClose ( state ) . pipe ( Effect . ignore ) )
2026-06-14 17:28:48 +00:00
const closeScopeOnce = yield * Effect . cached (
Scope . close ( state . scope , Exit . void ) . pipe (
Effect . ignore ,
Effect . ensuring (
Effect . sync ( ( ) = > {
if ( url === listenerUrl ) url = undefined
} ) ,
) ,
) ,
)
2026-05-15 00:00:52 +00:00
return ( close? : boolean ) = >
2026-05-03 18:23:29 +00:00
Effect . gen ( function * ( ) {
2026-05-15 00:00:52 +00:00
yield * unpublishMdns
if ( close ) yield * forceCloseOnce
yield * closeScopeOnce
} )
} )
}
2026-05-03 18:23:29 +00:00
2026-05-15 00:00:52 +00:00
function forceClose ( state : ListenerState ) {
return Effect . all ( [ state . http . closeAll , state . websockets . closeAll ] , { concurrency : "unbounded" , discard : true } )
}
function serverLayer ( opts : { port : number ; hostname : string } ) {
const server = createServer ( )
const serverRef = { closeStarted : false , forceStop : false }
const close = server . close . bind ( server )
// Keep shutdown owned by NodeHttpServer, but honor listener.stop(true) by
// force-closing active HTTP sockets when its finalizer calls server.close().
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Node's overloads don't preserve a monkey-patched method assignment.
server . close = ( ( callback? : Parameters < typeof server.close > [ 0 ] ) = > {
serverRef . closeStarted = true
const result = close ( callback )
if ( serverRef . forceStop ) server . closeAllConnections ( )
return result
} ) as typeof server . close
return Layer . mergeAll (
NodeHttpServer . layer ( ( ) = > server , { port : opts.port , host : opts.hostname , gracefulShutdownTimeout : "1 second" } ) ,
Layer . succeed ( ListenerServerService ) (
ListenerServerService . of ( {
closeAll : Effect.sync ( ( ) = > {
serverRef . forceStop = true
if ( serverRef . closeStarted ) server . closeAllConnections ( )
} ) ,
} ) ,
) ,
)
2025-05-18 01:31:42 +00:00
}
2026-04-16 23:51:01 +00:00
export * as Server from "./server"