summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/server/cors.ts4
-rw-r--r--packages/opencode/src/server/middleware.ts4
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/server.ts118
-rw-r--r--packages/opencode/src/server/server.ts28
-rw-r--r--packages/opencode/test/server/httpapi-cors.test.ts27
5 files changed, 113 insertions, 68 deletions
diff --git a/packages/opencode/src/server/cors.ts b/packages/opencode/src/server/cors.ts
index 8ae945b75..62a181af3 100644
--- a/packages/opencode/src/server/cors.ts
+++ b/packages/opencode/src/server/cors.ts
@@ -1,6 +1,8 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/
-export function isAllowedCorsOrigin(input: string | undefined, opts?: { cors?: string[] }) {
+export type CorsOptions = { readonly cors?: ReadonlyArray<string> }
+
+export function isAllowedCorsOrigin(input: string | undefined, opts?: CorsOptions) {
if (!input) return true
if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true
diff --git a/packages/opencode/src/server/middleware.ts b/packages/opencode/src/server/middleware.ts
index 433f301ae..d2cc9b538 100644
--- a/packages/opencode/src/server/middleware.ts
+++ b/packages/opencode/src/server/middleware.ts
@@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors"
import { compress } from "hono/compress"
import * as ServerBackend from "./backend"
-import { isAllowedCorsOrigin } from "./cors"
+import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
const log = Log.create({ service: "server" })
@@ -67,7 +67,7 @@ export function LoggerMiddleware(backendAttributes: ServerBackend.Attributes): M
}
}
-export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
+export function CorsMiddleware(opts?: CorsOptions): MiddlewareHandler {
return cors({
maxAge: 86_400,
origin(input) {
diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts
index d453f458a..e6dedfe2c 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/server.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts
@@ -38,7 +38,7 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace"
-import { isAllowedCorsOrigin } from "@/server/cors"
+import { isAllowedCorsOrigin, type CorsOptions } from "@/server/cors"
import { serveUIEffect } from "@/server/routes/ui"
import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
@@ -77,13 +77,14 @@ const runtime = HttpRouter.middleware()(
),
).layer
-const cors = HttpRouter.middleware(
- HttpMiddleware.cors({
- allowedOrigins: isAllowedCorsOrigin,
- maxAge: 86_400,
- }),
- { global: true },
-)
+const cors = (corsOptions?: CorsOptions) =>
+ HttpRouter.middleware(
+ HttpMiddleware.cors({
+ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
+ maxAge: 86_400,
+ }),
+ { global: true },
+ )
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceRouterLayer = authorizationRouterMiddleware
@@ -130,55 +131,68 @@ const uiRoute = HttpRouter.use((router) =>
}),
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
-export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
- Layer.provide([
- cors,
- runtime,
- Account.defaultLayer,
- Agent.defaultLayer,
- Auth.defaultLayer,
- Command.defaultLayer,
- Config.defaultLayer,
- File.defaultLayer,
- Format.defaultLayer,
- LSP.defaultLayer,
- Installation.defaultLayer,
- MCP.defaultLayer,
- Permission.defaultLayer,
- Project.defaultLayer,
- ProviderAuth.defaultLayer,
- Provider.defaultLayer,
- Pty.defaultLayer,
- Question.defaultLayer,
- Ripgrep.defaultLayer,
- Session.defaultLayer,
- SessionCompaction.defaultLayer,
- SessionPrompt.defaultLayer,
- SessionRevert.defaultLayer,
- SessionShare.defaultLayer,
- SessionRunState.defaultLayer,
- SessionStatus.defaultLayer,
- SessionSummary.defaultLayer,
- SyncEvent.defaultLayer,
- Skill.defaultLayer,
- Todo.defaultLayer,
- ToolRegistry.defaultLayer,
- Vcs.defaultLayer,
- Workspace.defaultLayer,
- Worktree.defaultLayer,
- Bus.layer,
- AppFileSystem.defaultLayer,
- FetchHttpClient.layer,
- HttpServer.layerServices,
- ]),
- Layer.provideMerge(Observability.layer),
-)
+export function createRoutes(corsOptions?: CorsOptions) {
+ return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
+ Layer.provide([
+ cors(corsOptions),
+ runtime,
+ Account.defaultLayer,
+ Agent.defaultLayer,
+ Auth.defaultLayer,
+ Command.defaultLayer,
+ Config.defaultLayer,
+ File.defaultLayer,
+ Format.defaultLayer,
+ LSP.defaultLayer,
+ Installation.defaultLayer,
+ MCP.defaultLayer,
+ Permission.defaultLayer,
+ Project.defaultLayer,
+ ProviderAuth.defaultLayer,
+ Provider.defaultLayer,
+ Pty.defaultLayer,
+ Question.defaultLayer,
+ Ripgrep.defaultLayer,
+ Session.defaultLayer,
+ SessionCompaction.defaultLayer,
+ SessionPrompt.defaultLayer,
+ SessionRevert.defaultLayer,
+ SessionShare.defaultLayer,
+ SessionRunState.defaultLayer,
+ SessionStatus.defaultLayer,
+ SessionSummary.defaultLayer,
+ SyncEvent.defaultLayer,
+ Skill.defaultLayer,
+ Todo.defaultLayer,
+ ToolRegistry.defaultLayer,
+ Vcs.defaultLayer,
+ Workspace.defaultLayer,
+ Worktree.defaultLayer,
+ Bus.layer,
+ AppFileSystem.defaultLayer,
+ FetchHttpClient.layer,
+ HttpServer.layerServices,
+ ]),
+ Layer.provideMerge(Observability.layer),
+ )
+}
+
+export const routes = createRoutes()
-export const webHandler = lazy(() =>
+const defaultWebHandler = lazy(() =>
HttpRouter.toWebHandler(routes, {
memoMap,
middleware: disposeMiddleware,
}),
)
+export function webHandler(corsOptions?: CorsOptions) {
+ if (!corsOptions?.cors?.length) return defaultWebHandler()
+ return HttpRouter.toWebHandler(createRoutes(corsOptions), {
+ // Server-level CORS options are dynamic; don't reuse the default route layer memoized without them.
+ memoMap: Layer.makeMemoMapUnsafe(),
+ middleware: disposeMiddleware,
+ })
+}
+
export * as ExperimentalHttpApiServer from "./server"
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index e4aeda798..a1e821fb7 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -18,6 +18,7 @@ import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import * as ServerBackend from "./backend"
+import type { CorsOptions } from "./cors"
// @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
@@ -38,6 +39,13 @@ type ServerApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
}
+type ListenOptions = CorsOptions & {
+ port: number
+ hostname: string
+ mdns?: boolean
+ mdnsDomain?: string
+}
+
const DefaultHono = lazy(() =>
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
)
@@ -54,14 +62,14 @@ export const Default = () => {
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
}
-function create(opts: { cors?: string[] }) {
+function create(opts: ListenOptions) {
const selected = select()
return selected.backend === "effect-httpapi"
- ? withBackend(selected, createHttpApi())
+ ? withBackend(selected, createHttpApi(opts))
: withBackend(selected, createHono(opts, selected))
}
-export function Legacy(opts: { cors?: string[] } = {}) {
+export function Legacy(opts: CorsOptions = {}) {
return withBackend({ backend: "hono", reason: "explicit" }, createHono(opts, { backend: "hono", reason: "explicit" }))
}
@@ -74,8 +82,8 @@ function withBackend<T extends { app: ServerApp; runtime: unknown }>(selection:
return built
}
-function createHttpApi() {
- const handler = ExperimentalHttpApiServer.webHandler().handler
+function createHttpApi(corsOptions?: CorsOptions) {
+ const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
const app: ServerApp = {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input, init) {
@@ -89,7 +97,7 @@ function createHttpApi() {
}
function createHono(
- opts: { cors?: string[] },
+ opts: CorsOptions,
selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"),
) {
const backendAttributes = ServerBackend.attributes(selection)
@@ -151,13 +159,7 @@ export async function openapi() {
export let url: URL
-export async function listen(opts: {
- port: number
- hostname: string
- mdns?: boolean
- mdnsDomain?: string
- cors?: string[]
-}): Promise<Listener> {
+export async function listen(opts: ListenOptions): Promise<Listener> {
const built = create(opts)
const server = await built.runtime.listen(opts)
diff --git a/packages/opencode/test/server/httpapi-cors.test.ts b/packages/opencode/test/server/httpapi-cors.test.ts
index 3330cfdd1..2e5520caf 100644
--- a/packages/opencode/test/server/httpapi-cors.test.ts
+++ b/packages/opencode/test/server/httpapi-cors.test.ts
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket"
+import { Server } from "../../src/server/server"
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db"
@@ -61,4 +62,30 @@ describe("HttpApi CORS", () => {
expect(response.headers["access-control-allow-headers"]).toBe("authorization")
}),
)
+
+ it.live("uses custom CORS origins passed to the server", () =>
+ Effect.gen(function* () {
+ const listener = yield* Effect.acquireRelease(
+ Effect.promise(() =>
+ Server.listen({ hostname: "127.0.0.1", port: 0, cors: ["https://custom.example"] }),
+ ),
+ (listener) => Effect.promise(() => listener.stop(true)),
+ )
+
+ const response = yield* Effect.promise(() =>
+ fetch(new URL(InstancePaths.path, listener.url), {
+ method: "OPTIONS",
+ headers: {
+ origin: "https://custom.example",
+ "access-control-request-method": "GET",
+ "access-control-request-headers": "authorization",
+ },
+ }),
+ )
+
+ expect(response.status).toBe(204)
+ expect(response.headers.get("access-control-allow-origin")).toBe("https://custom.example")
+ expect(response.headers.get("access-control-allow-headers")).toBe("authorization")
+ }),
+ )
})