76d814e747
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
258 lines
9.7 KiB
TypeScript
258 lines
9.7 KiB
TypeScript
import { Config as EffectConfig, Context, Effect, Layer } from "effect"
|
|
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
|
|
import {
|
|
FetchHttpClient,
|
|
HttpClient,
|
|
HttpMiddleware,
|
|
HttpRouter,
|
|
HttpServer,
|
|
HttpServerResponse,
|
|
} from "effect/unstable/http"
|
|
import * as Socket from "effect/unstable/socket/Socket"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { Account } from "@/account/account"
|
|
import { Agent } from "@/agent/agent"
|
|
import { Auth } from "@/auth"
|
|
import { Bus } from "@/bus"
|
|
import { Config } from "@/config/config"
|
|
import { Command } from "@/command"
|
|
import * as Observability from "@opencode-ai/core/effect/observability"
|
|
import { File } from "@/file"
|
|
import { FileWatcher } from "@/file/watcher"
|
|
import { Ripgrep } from "@/file/ripgrep"
|
|
import { Format } from "@/format"
|
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
|
import { LSP } from "@/lsp/lsp"
|
|
import { MCP } from "@/mcp"
|
|
import { Permission } from "@/permission"
|
|
import { Installation } from "@/installation"
|
|
import { InstanceLayer } from "@/project/instance-layer"
|
|
import { Plugin } from "@/plugin"
|
|
import { Project } from "@/project/project"
|
|
import { ProviderAuth } from "@/provider/auth"
|
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
|
import { Provider } from "@/provider/provider"
|
|
import { Pty } from "@/pty"
|
|
import { PtyTicket } from "@/pty/ticket"
|
|
import { Question } from "@/question"
|
|
import { Session } from "@/session/session"
|
|
import { SessionCompaction } from "@/session/compaction"
|
|
import { SessionPrompt } from "@/session/prompt"
|
|
import { SessionRevert } from "@/session/revert"
|
|
import { SessionRunState } from "@/session/run-state"
|
|
import { SessionStatus } from "@/session/status"
|
|
import { SessionSummary } from "@/session/summary"
|
|
import { Todo } from "@/session/todo"
|
|
import { SessionShare } from "@/share/session"
|
|
import { ShareNext } from "@/share/share-next"
|
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
|
import { Skill } from "@/skill"
|
|
import { Snapshot } from "@/snapshot"
|
|
import { SyncEvent } from "@/sync"
|
|
import { ToolRegistry } from "@/tool/registry"
|
|
import { lazy } from "@/util/lazy"
|
|
import { Vcs } from "@/project/vcs"
|
|
import { Worktree } from "@/worktree"
|
|
import { Workspace } from "@/control-plane/workspace"
|
|
import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
|
|
import { serveUIEffect } from "@/server/shared/ui"
|
|
import { ServerAuth } from "@/server/auth"
|
|
import { InstanceHttpApi, RootHttpApi } from "./api"
|
|
import { PublicApi } from "./public"
|
|
import {
|
|
authorizationLayer,
|
|
authorizationRouterMiddleware,
|
|
ptyConnectAuthorizationLayer,
|
|
v2AuthorizationLayer,
|
|
} from "./middleware/authorization"
|
|
import { EventApi } from "./groups/event"
|
|
import { PtyConnectApi } from "./groups/pty"
|
|
import { eventHandlers } from "./handlers/event"
|
|
import { configHandlers } from "./handlers/config"
|
|
import { controlHandlers } from "./handlers/control"
|
|
import { experimentalHandlers } from "./handlers/experimental"
|
|
import { fileHandlers } from "./handlers/file"
|
|
import { globalHandlers } from "./handlers/global"
|
|
import { instanceHandlers } from "./handlers/instance"
|
|
import { mcpHandlers } from "./handlers/mcp"
|
|
import { permissionHandlers } from "./handlers/permission"
|
|
import { projectHandlers } from "./handlers/project"
|
|
import { providerHandlers } from "./handlers/provider"
|
|
import { ptyConnectHandlers, ptyHandlers } from "./handlers/pty"
|
|
import { questionHandlers } from "./handlers/question"
|
|
import { sessionHandlers } from "./handlers/session"
|
|
import { syncHandlers } from "./handlers/sync"
|
|
import { tuiHandlers } from "./handlers/tui"
|
|
import { v2Handlers } from "./handlers/v2"
|
|
import { workspaceHandlers } from "./handlers/workspace"
|
|
import { instanceContextLayer } from "./middleware/instance-context"
|
|
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
|
import { disposeMiddleware } from "./lifecycle"
|
|
import { memoMap } from "@opencode-ai/core/effect/memo-map"
|
|
import { compressionLayer } from "./middleware/compression"
|
|
import { corsVaryFix } from "./middleware/cors-vary"
|
|
import { errorLayer } from "./middleware/error"
|
|
import { fenceLayer } from "./middleware/fence"
|
|
import { schemaErrorLayer } from "./middleware/schema-error"
|
|
|
|
export const context = Context.makeUnsafe<unknown>(new Map())
|
|
|
|
const cors = (corsOptions?: CorsOptions) =>
|
|
HttpRouter.middleware(
|
|
HttpMiddleware.cors({
|
|
allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
|
|
maxAge: 86_400,
|
|
}),
|
|
{ global: true },
|
|
)
|
|
|
|
// Route tree:
|
|
// - rootApiRoutes: typed /global/* and control routes; auth is declared by RootHttpApi.
|
|
// - eventApiRoutes: typed SSE route with instance routing context and its existing API contract.
|
|
// - ptyConnectApiRoutes: typed WebSocket upgrade route with ticket-aware auth.
|
|
// - instanceApiRoutes: remaining typed instance routes.
|
|
// - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
|
|
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
|
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
|
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
|
const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
|
|
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
|
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
|
Layer.provide([controlHandlers, globalHandlers]),
|
|
Layer.provide(schemaErrorLayer),
|
|
Layer.provide(httpApiAuthLayer),
|
|
)
|
|
const eventApiRoutes = HttpApiBuilder.layer(EventApi).pipe(
|
|
Layer.provide(eventHandlers),
|
|
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
|
)
|
|
const ptyConnectApiRoutes = HttpApiBuilder.layer(PtyConnectApi).pipe(
|
|
Layer.provide(ptyConnectHandlers),
|
|
Layer.provide([ptyConnectHttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer]),
|
|
)
|
|
const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
|
Layer.provide([
|
|
configHandlers,
|
|
experimentalHandlers,
|
|
fileHandlers,
|
|
instanceHandlers,
|
|
mcpHandlers,
|
|
projectHandlers,
|
|
ptyHandlers,
|
|
questionHandlers,
|
|
permissionHandlers,
|
|
providerHandlers,
|
|
sessionHandlers,
|
|
syncHandlers,
|
|
v2Handlers,
|
|
tuiHandlers,
|
|
workspaceHandlers,
|
|
]),
|
|
)
|
|
|
|
const instanceRoutes = instanceApiRoutes.pipe(
|
|
Layer.provide([httpApiAuthLayer, v2HttpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
|
)
|
|
|
|
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
|
// processes that never serve it (CLI, scripts) don't pay at module load.
|
|
// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
|
|
// the response also caches the serialized body — every /doc request reuses
|
|
// the same Uint8Array instead of re-stringifying the spec.
|
|
const docResponse = lazy(() => HttpServerResponse.jsonUnsafe(OpenApi.fromApi(PublicApi)))
|
|
|
|
const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effect.succeed(docResponse()))).pipe(
|
|
Layer.provide(authOnlyRouterLayer),
|
|
)
|
|
|
|
const uiRoute = HttpRouter.use((router) =>
|
|
Effect.gen(function* () {
|
|
const fs = yield* AppFileSystem.Service
|
|
const client = yield* HttpClient.HttpClient
|
|
const flags = yield* RuntimeFlags.Service
|
|
yield* router.add("*", "/*", (request) =>
|
|
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
|
)
|
|
}),
|
|
).pipe(Layer.provide(authOnlyRouterLayer))
|
|
|
|
type RouteRequirements =
|
|
| HttpRouter.HttpRouter
|
|
| HttpRouter.Request<"Error", unknown>
|
|
| HttpRouter.Request<"GlobalError", unknown>
|
|
| HttpRouter.Request<"Requires", unknown>
|
|
| HttpRouter.Request<"GlobalRequires", never>
|
|
|
|
export function createRoutes(
|
|
corsOptions?: CorsOptions,
|
|
): Layer.Layer<never, EffectConfig.ConfigError, RouteRequirements> {
|
|
return Layer.mergeAll(rootApiRoutes, eventApiRoutes, ptyConnectApiRoutes, instanceRoutes, docRoute, uiRoute).pipe(
|
|
Layer.provide([
|
|
errorLayer,
|
|
compressionLayer,
|
|
corsVaryFix,
|
|
fenceLayer,
|
|
cors(corsOptions),
|
|
Account.defaultLayer,
|
|
Agent.defaultLayer,
|
|
Auth.defaultLayer,
|
|
Command.defaultLayer,
|
|
Config.defaultLayer,
|
|
File.defaultLayer,
|
|
FileWatcher.defaultLayer,
|
|
Format.defaultLayer,
|
|
LSP.defaultLayer,
|
|
Installation.defaultLayer,
|
|
MCP.defaultLayer,
|
|
ModelsDev.defaultLayer,
|
|
Permission.defaultLayer,
|
|
Plugin.defaultLayer,
|
|
Project.defaultLayer,
|
|
ProviderAuth.defaultLayer,
|
|
Provider.defaultLayer,
|
|
Pty.defaultLayer,
|
|
PtyTicket.defaultLayer,
|
|
Question.defaultLayer,
|
|
Ripgrep.defaultLayer,
|
|
RuntimeFlags.defaultLayer,
|
|
Session.defaultLayer,
|
|
SessionCompaction.defaultLayer,
|
|
SessionPrompt.defaultLayer,
|
|
SessionRevert.defaultLayer,
|
|
SessionShare.defaultLayer,
|
|
SessionRunState.defaultLayer,
|
|
SessionStatus.defaultLayer,
|
|
SessionSummary.defaultLayer,
|
|
ShareNext.defaultLayer,
|
|
Snapshot.defaultLayer,
|
|
SyncEvent.defaultLayer,
|
|
EventV2Bridge.defaultLayer,
|
|
Skill.defaultLayer,
|
|
Todo.defaultLayer,
|
|
ToolRegistry.defaultLayer,
|
|
Vcs.defaultLayer,
|
|
Workspace.defaultLayer,
|
|
Worktree.appLayer,
|
|
Bus.layer,
|
|
AppFileSystem.defaultLayer,
|
|
FetchHttpClient.layer,
|
|
HttpServer.layerServices,
|
|
]),
|
|
Layer.provide(Layer.succeed(CorsConfig)(corsOptions)),
|
|
Layer.provide(InstanceLayer.layer),
|
|
Layer.provide(Observability.layer),
|
|
)
|
|
}
|
|
|
|
export const routes = createRoutes()
|
|
|
|
export const webHandler = lazy(() =>
|
|
HttpRouter.toWebHandler(routes, {
|
|
disableLogger: true,
|
|
memoMap,
|
|
middleware: disposeMiddleware,
|
|
}),
|
|
)
|
|
|
|
export * as HttpApiApp from "./server"
|