refactor(server): centralize server options

This commit is contained in:
Dax Raad 2026-07-20 20:32:54 -04:00
commit ace77a5cf7
4 changed files with 74 additions and 67 deletions

View file

@ -60,30 +60,31 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
: randomBytes(32).toString("base64url") : randomBytes(32).toString("base64url")
if (!password) return yield* Effect.fail(new Error("Missing server password")) if (!password) return yield* Effect.fail(new Error("Missing server password"))
const instanceID = randomUUID() const instanceID = randomUUID()
const server = yield* start({ const server = yield* start(
hostname, {
port: Option.fromNullishOr(port), hostname,
password, port,
instanceID, password,
database: { database: {
path: process.env.OPENCODE_DB, 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: { serviceOptions === undefined
url: process.env.OPENCODE_MODELS_URL, ? undefined
file: process.env.OPENCODE_MODELS_PATH, : {
fetch: !["1", "true"].includes(process.env.OPENCODE_DISABLE_MODELS_FETCH?.toLowerCase() ?? ""), instanceID,
}, onListen: (address, shutdown) =>
service: Effect.gen(function* () {
serviceOptions === undefined if (!config.password) yield* ServiceConfig.password(password)
? undefined return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
: { }),
onListen: (address, shutdown) => },
Effect.gen(function* () { ).pipe(
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
}).pipe(
Effect.provide(Logger.layer([], { mergeWithExisting: false })), Effect.provide(Logger.layer([], { mergeWithExisting: false })),
Effect.catch((error) => { Effect.catch((error) => {
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error) if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)

View file

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

View file

@ -1,14 +1,13 @@
export * as ServerProcess from "./process" export * as ServerProcess from "./process"
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node" import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
import { Database } from "@opencode-ai/core/database/database"
import { InstallationVersion } from "@opencode-ai/core/installation/version" 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 { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { ServiceStatus } from "@opencode-ai/protocol/groups/health" import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect" import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import { randomUUID } from "node:crypto"
import { createServer } from "node:http" import { createServer } from "node:http"
import { ServerAuth } from "./auth" import { ServerAuth } from "./auth"
import { authorizedRequest } from "./middleware/authorization" import { authorizedRequest } from "./middleware/authorization"
@ -16,20 +15,14 @@ import { withoutParentSpan } from "./request-tracing"
import { createRoutes } from "./routes" import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info" import { ServerInfo } from "./server-info"
import { Status } from "./service-status" import { Status } from "./service-status"
import type { ServerOptions } from "./options"
export type Options<E = never, R = never> = { export interface Lifecycle<E = never, R = never> {
readonly hostname: string
readonly port: Option.Option<number>
readonly password: string
readonly instanceID: string readonly instanceID: string
readonly database?: Database.Options readonly onListen: (
readonly models?: ModelsDev.Options address: HttpServer.Address,
readonly service?: { shutdown: Effect.Effect<void>,
readonly onListen: ( ) => Effect.Effect<Effect.Effect<void>, E, R>
address: HttpServer.Address,
shutdown: Effect.Effect<void>,
) => Effect.Effect<Effect.Effect<void>, E, R>
}
} }
type App = Effect.Effect< type App = Effect.Effect<
@ -38,21 +31,27 @@ type App = Effect.Effect<
HttpServerRequest.HttpServerRequest | Scope.Scope HttpServerRequest.HttpServerRequest | Scope.Scope
> >
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options: Options<E, R>) { export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
if (!options.password) return yield* Effect.fail(new Error("Missing server password")) options: ServerOptions,
lifecycle?: Lifecycle<E, R>,
) {
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<void>() const shutdown = yield* Deferred.make<void>()
const status = yield* Status.make({ const status = yield* Status.make({
instanceID: options.instanceID, instanceID: lifecycle?.instanceID ?? randomUUID(),
managed: options.service !== undefined, managed: lifecycle !== undefined,
}) })
const bound = yield* listen(options) const bound = yield* listen({ hostname, port })
const application = yield* Ref.make(Option.none<App>()) const application = yield* Ref.make(Option.none<App>())
// Request fibers may continue inbound trace context, but must not inherit the server startup parent. // Request fibers may continue inbound trace context, but must not inherit the server startup parent.
yield* bound.http yield* bound.http
.serve(dispatch(options.password, status, application, shutdown), HttpMiddleware.logger) .serve(dispatch(password, status, application, shutdown), HttpMiddleware.logger)
.pipe(withoutParentSpan) .pipe(withoutParentSpan)
if (options.service) if (lifecycle)
yield* options.service.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe( yield* lifecycle.onListen(bound.http.address, Deferred.succeed(shutdown, undefined).pipe(Effect.asVoid)).pipe(
Effect.flatMap((cleanup) => Effect.flatMap((cleanup) =>
Effect.addFinalizer(() => Scope.close(bound.scope, Exit.void).pipe(Effect.andThen(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* <E, R>(options:
const boot = Effect.gen(function* () { const boot = Effect.gen(function* () {
const context = yield* Layer.buildWithScope( const context = yield* Layer.buildWithScope(
createRoutes({ createRoutes(
password: options.password, {
serviceURLs: () => { ...options,
password,
},
() => {
const address = bound.server.address() const address = bound.server.address()
if (address === null || typeof address === "string") return [] if (address === null || typeof address === "string") return []
const host = address.family === "IPv6" ? `[${address.address}]` : address.address 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, ).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
models: options.models,
}).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
applicationScope, applicationScope,
) )
if (options.service) { if (lifecycle) {
yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe( yield* installRestartContinuity(Context.get(context, SessionRestart.Service)).pipe(
Effect.provideService(Scope.Scope, applicationScope), Effect.provideService(Scope.Scope, applicationScope),
) )
@ -93,7 +93,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(options:
return { address: bound.http.address, shutdown: Deferred.await(shutdown) } return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
}).pipe( }).pipe(
Effect.catchCause((cause) => { 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( return status.fail.pipe(
Effect.andThen( Effect.andThen(
Scope.close(applicationScope, Exit.failCause(cause)).pipe( Scope.close(applicationScope, Exit.failCause(cause)).pipe(
@ -107,7 +107,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(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))) return yield* Effect.raceFirst(boot, Deferred.await(shutdown).pipe(Effect.andThen(Effect.interrupt)))
}) })

View file

@ -31,6 +31,7 @@ import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location" import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location" import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info" import { ServerInfo } from "./server-info"
import type { ServerOptions } from "./options"
const applicationServices = LayerNode.group([ const applicationServices = LayerNode.group([
Database.node, Database.node,
@ -52,29 +53,24 @@ const applicationServices = LayerNode.group([
SessionRestart.node, SessionRestart.node,
]) ])
export interface Options { export function createRoutes(options: ServerOptions = {}, serviceURLs: () => ReadonlyArray<string> = () => []) {
readonly password?: string
readonly serviceURLs?: () => ReadonlyArray<string>
readonly database?: Database.Options
readonly models?: ModelsDev.Options
}
export function createRoutes(options: Options = {}) {
return makeRoutes( return makeRoutes(
options.password options.password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(options.password) }) ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(options.password) })
: ServerAuth.Config.layer, : ServerAuth.Config.layer,
options, options,
serviceURLs,
) )
} }
export function createEmbeddedRoutes(options: Pick<Options, "database" | "models"> = {}) { export function createEmbeddedRoutes(options: ServerOptions = {}) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options) return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), options, () => [])
} }
function makeRoutes<AuthError, AuthServices>( function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>, auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
options: Pick<Options, "database" | "models" | "serviceURLs">, options: ServerOptions,
serviceURLs: () => ReadonlyArray<string>,
) { ) {
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
@ -98,7 +94,7 @@ function makeRoutes<AuthError, AuthServices>(
const services = Layer.succeedContext(context) const services = Layer.succeedContext(context)
const requestServices = Layer.merge( const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)), 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( return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))), Layer.provide(handlers.pipe(Layer.provide(services))),