From ace77a5cf7c657be6ebd83695478e889a27276bf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 20 Jul 2026 20:32:54 -0400 Subject: [PATCH] refactor(server): centralize server options --- packages/cli/src/server-process.ts | 47 +++++++++++----------- packages/server/src/options.ts | 10 +++++ packages/server/src/process.ts | 64 +++++++++++++++--------------- packages/server/src/routes.ts | 20 ++++------ 4 files changed, 74 insertions(+), 67 deletions(-) create mode 100644 packages/server/src/options.ts diff --git a/packages/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 0443aab736..b35e74e460 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -60,30 +60,31 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { : randomBytes(32).toString("base64url") if (!password) return yield* Effect.fail(new Error("Missing server password")) const instanceID = randomUUID() - const server = yield* start({ - hostname, - port: Option.fromNullishOr(port), - password, - instanceID, - database: { - path: process.env.OPENCODE_DB, + const server = yield* start( + { + hostname, + port, + password, + database: { + path: process.env.OPENCODE_DB, + }, + models: { + url: process.env.OPENCODE_MODELS_URL, + file: process.env.OPENCODE_MODELS_PATH, + fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""), + }, }, - models: { - url: process.env.OPENCODE_MODELS_URL, - file: process.env.OPENCODE_MODELS_PATH, - fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""), - }, - service: - serviceOptions === undefined - ? undefined - : { - onListen: (address, shutdown) => - Effect.gen(function* () { - if (!config.password) yield* ServiceConfig.password(password) - return yield* register(address, password, instanceID, serviceOptions.file, shutdown) - }), - }, - }).pipe( + serviceOptions === undefined + ? undefined + : { + instanceID, + onListen: (address, shutdown) => + Effect.gen(function* () { + if (!config.password) yield* ServiceConfig.password(password) + return yield* register(address, password, instanceID, serviceOptions.file, shutdown) + }), + }, + ).pipe( Effect.provide(Logger.layer([], { mergeWithExisting: false })), Effect.catch((error) => { if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error) diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts new file mode 100644 index 0000000000..d3d3ecdbc1 --- /dev/null +++ b/packages/server/src/options.ts @@ -0,0 +1,10 @@ +import type { Database } from "@opencode-ai/core/database/database" +import type { ModelsDev } from "@opencode-ai/core/models-dev" + +export interface ServerOptions { + readonly hostname?: string + readonly port?: number + readonly password?: string + readonly database?: Database.Options + readonly models?: ModelsDev.Options +} diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 8baa065aa2..2189f2a824 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -1,14 +1,13 @@ export * as ServerProcess from "./process" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" -import { Database } from "@opencode-ai/core/database/database" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { ModelsDev } from "@opencode-ai/core/models-dev" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { randomUUID } from "node:crypto" import { createServer } from "node:http" import { ServerAuth } from "./auth" import { authorizedRequest } from "./middleware/authorization" @@ -16,20 +15,14 @@ import { withoutParentSpan } from "./request-tracing" import { createRoutes } from "./routes" import { ServerInfo } from "./server-info" import { Status } from "./service-status" +import type { ServerOptions } from "./options" -export type Options = { - readonly hostname: string - readonly port: Option.Option - readonly password: string +export interface Lifecycle { readonly instanceID: string - readonly database?: Database.Options - readonly models?: ModelsDev.Options - readonly service?: { - readonly onListen: ( - address: HttpServer.Address, - shutdown: Effect.Effect, - ) => Effect.Effect, E, R> - } + readonly onListen: ( + address: HttpServer.Address, + shutdown: Effect.Effect, + ) => Effect.Effect, E, R> } type App = Effect.Effect< @@ -38,21 +31,27 @@ type App = Effect.Effect< HttpServerRequest.HttpServerRequest | Scope.Scope > -export const start = Effect.fn("ServerProcess.start")(function* (options: Options) { - if (!options.password) return yield* Effect.fail(new Error("Missing server password")) +export const start = Effect.fn("ServerProcess.start")(function* ( + options: ServerOptions, + lifecycle?: Lifecycle, +) { + const password = options.password + if (!password) return yield* Effect.fail(new Error("Missing server password")) + const hostname = options.hostname ?? "127.0.0.1" + const port = Option.fromNullishOr(options.port) const shutdown = yield* Deferred.make() const status = yield* Status.make({ - instanceID: options.instanceID, - managed: options.service !== undefined, + instanceID: lifecycle?.instanceID ?? randomUUID(), + managed: lifecycle !== undefined, }) - const bound = yield* listen(options) + const bound = yield* listen({ hostname, port }) const application = yield* Ref.make(Option.none()) // Request fibers may continue inbound trace context, but must not inherit the server startup parent. yield* bound.http - .serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger) + .serve(dispatch(password, status, application, shutdown), HttpMiddleware.logger) .pipe(withoutParentSpan) - if (options.service) - yield* options.service.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe( + if (lifecycle) + yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe( Effect.flatMap((cleanup) => Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(cleanup))), ), @@ -70,20 +69,21 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: const boot = Effect.gen(function* () { const context = yield* Layer.buildWithScope( - createRoutes({ - password: options.password, - serviceURLs: () => { + createRoutes( + { + ...options, + password, + }, + () => { const address = bound.server.address() if (address === null || typeof address === "string") return [] const host = address.family === "IPv6" ? `[${address.address}]` : address.address - return ServerInfo.connectionURLs(`http://${host}:${address.port}`, options.hostname) + return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname) }, - database: options.database, - models: options.models, - }).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), + ).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), applicationScope, ) - if (options.service) { + if (lifecycle) { yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe( Effect.provideService(Scope.Scope, applicationScope), ) @@ -93,7 +93,7 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: return { address: bound.http.address, shutdown: Deferred.await(shutdown) } }).pipe( Effect.catchCause((cause) => { - if (!options.service || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) + if (!lifecycle || Cause.hasInterruptsOnly(cause)) return Effect.failCause(cause) return status.fail.pipe( Effect.andThen( Scope.close(applicationScope, Exit.failCause(cause)).pipe( @@ -107,7 +107,7 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: ) }), ) - if (!options.service) return yield* boot + if (!lifecycle) return yield* boot return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt))) }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 380c70f700..eb1cf607b7 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -31,6 +31,7 @@ import { layer } from "./location" import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" import { ServerInfo } from "./server-info" +import type { ServerOptions } from "./options" const applicationServices = LayerNode.group([ Database.node, @@ -52,29 +53,24 @@ const applicationServices = LayerNode.group([ SessionRestart.node, ]) -export interface Options { - readonly password?: string - readonly serviceURLs?: () => ReadonlyArray - readonly database?: Database.Options - readonly models?: ModelsDev.Options -} - -export function createRoutes(options: Options = {}) { +export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray = () => []) { return makeRoutes( options.password ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(options.password) }) : ServerAuth.Config.layer, options, + serviceURLs, ) } -export function createEmbeddedRoutes(options: Pick = {}) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options) +export function createEmbeddedRoutes(options: ServerOptions = {}) { + return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options, () => []) } function makeRoutes( auth: Layer.Layer, - options: Pick, + options: ServerOptions, + serviceURLs: () => ReadonlyArray, ) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ @@ -98,7 +94,7 @@ function makeRoutes( const services = Layer.succeedContext(context) const requestServices = Layer.merge( Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), - ServerInfo.layer(options.serviceURLs ?? (() => [])), + ServerInfo.layer(serviceURLs), ) return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe( Layer.provide(handlers.pipe(Layer.provide(services))),