feat: add server link sharing

This commit is contained in:
Dax Raad 2026-07-08 18:41:08 -04:00
commit 7698a5e6ac
25 changed files with 1586 additions and 1194 deletions

View file

@ -13,6 +13,7 @@ import { EventHandler } from "./handlers/event"
import { AgentHandler } from "./handlers/agent"
import { PluginHandler } from "./handlers/plugin"
import { HealthHandler } from "./handlers/health"
import { ServerHandler } from "./handlers/server"
import { DebugHandler } from "./handlers/debug"
import { PtyHandler } from "./handlers/pty"
import { ShellHandler } from "./handlers/shell"
@ -28,6 +29,7 @@ import { VcsHandler } from "./handlers/vcs"
export const handlers = Layer.mergeAll(
HealthHandler,
ServerHandler,
DebugHandler,
LocationHandler,
AgentHandler,

View file

@ -0,0 +1,13 @@
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { ServerInfo } from "../server-info"
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
handlers.handle("server.get", () =>
Effect.gen(function* () {
const info = yield* ServerInfo.Service
return { urls: info.urls() }
}),
),
)

View file

@ -8,6 +8,7 @@ import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { createRoutes } from "./routes"
import { ServerInfo } from "./server-info"
export type Options = {
readonly hostname: string
@ -48,7 +49,12 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) {
const server = createServer()
return Layer.build(
createRoutes(password).pipe(
createRoutes(password, () => {
const address = 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}`, hostname)
}).pipe(
Layer.flatMap((context) =>
HttpServer.serve(Context.get(context, HttpRouter.HttpRouter).asHttpEffect(), HttpMiddleware.logger),
),

View file

@ -30,6 +30,7 @@ import { PtyEnvironment } from "./pty-environment"
import { layer } from "./location"
import { formLocationLayer } from "./middleware/form-location"
import { sessionLocationLayer } from "./middleware/session-location"
import { ServerInfo } from "./server-info"
const applicationServices = LayerNode.group([
Database.node,
@ -50,11 +51,12 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node,
])
export function createRoutes(password?: string) {
export function createRoutes(password?: string, serviceURLs: () => ReadonlyArray<string> = () => []) {
return makeRoutes(
password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer,
serviceURLs,
)
}
@ -62,7 +64,10 @@ export function createEmbeddedRoutes() {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }))
}
function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>) {
function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
serviceURLs: () => ReadonlyArray<string> = () => [],
) {
const pluginRuntimeCell = PluginRuntime.makeCell()
const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node],
@ -87,7 +92,10 @@ function makeRoutes<AuthError, AuthServices>(auth: Layer.Layer<ServerAuth.Config
return serviceLayer.pipe(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context))
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service)(context)),
ServerInfo.layer(serviceURLs),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
Layer.provide(formLocationLayer),

View file

@ -0,0 +1,32 @@
import { Context, Layer } from "effect"
import { networkInterfaces } from "node:os"
export class Service extends Context.Service<Service, { readonly urls: () => ReadonlyArray<string> }>()(
"@opencode-ai/server/ServerInfo",
) {}
export function layer(urls: () => ReadonlyArray<string>) {
return Layer.succeed(Service, Service.of({ urls }))
}
export function connectionURLs(value: string, requestedHostname?: string) {
const url = new URL(value)
const hostname = requestedHostname ?? url.hostname
const family = hostname === "0.0.0.0" ? "IPv4" : hostname === "::" || hostname === "[::]" ? "IPv6" : undefined
if (family === undefined) return [value]
return [
...new Set(
Object.values(networkInterfaces())
.flatMap((entries) => entries ?? [])
.filter((entry) => !entry.internal && entry.family === family)
.map((entry) => {
const result = new URL(value)
result.hostname = family === "IPv6" ? `[${entry.address}]` : entry.address
return result.toString().replace(/\/$/, "")
}),
),
]
}
export * as ServerInfo from "./server-info"