Pass CORS options to HttpApi backend (#25201)

This commit is contained in:
Kit Langton
2026-04-30 21:26:32 -04:00
committed by GitHub
parent 668d77bb4e
commit bc805b3001
5 changed files with 113 additions and 68 deletions
+3 -1
View File
@@ -1,6 +1,8 @@
const opencodeOrigin = /^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/ 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) return true
if (input.startsWith("http://localhost:")) return true if (input.startsWith("http://localhost:")) return true
if (input.startsWith("http://127.0.0.1:")) return true if (input.startsWith("http://127.0.0.1:")) return true
+2 -2
View File
@@ -11,7 +11,7 @@ import { basicAuth } from "hono/basic-auth"
import { cors } from "hono/cors" import { cors } from "hono/cors"
import { compress } from "hono/compress" import { compress } from "hono/compress"
import * as ServerBackend from "./backend" import * as ServerBackend from "./backend"
import { isAllowedCorsOrigin } from "./cors" import { isAllowedCorsOrigin, type CorsOptions } from "./cors"
const log = Log.create({ service: "server" }) 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({ return cors({
maxAge: 86_400, maxAge: 86_400,
origin(input) { origin(input) {
@@ -38,7 +38,7 @@ import { lazy } from "@/util/lazy"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { Worktree } from "@/worktree" import { Worktree } from "@/worktree"
import { Workspace } from "@/control-plane/workspace" 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 { serveUIEffect } from "@/server/routes/ui"
import { InstanceHttpApi, RootHttpApi } from "./api" import { InstanceHttpApi, RootHttpApi } from "./api"
import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { ServerAuthConfig, authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
@@ -77,13 +77,14 @@ const runtime = HttpRouter.middleware()(
), ),
).layer ).layer
const cors = HttpRouter.middleware( const cors = (corsOptions?: CorsOptions) =>
HttpMiddleware.cors({ HttpRouter.middleware(
allowedOrigins: isAllowedCorsOrigin, HttpMiddleware.cors({
maxAge: 86_400, allowedOrigins: (origin) => isAllowedCorsOrigin(origin, corsOptions),
}), maxAge: 86_400,
{ global: true }, }),
) { global: true },
)
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers])) const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(Layer.provide([controlHandlers, globalHandlers]))
const instanceRouterLayer = authorizationRouterMiddleware const instanceRouterLayer = authorizationRouterMiddleware
@@ -130,55 +131,68 @@ const uiRoute = HttpRouter.use((router) =>
}), }),
).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer)))) ).pipe(Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuthConfig.defaultLayer))))
export const routes = Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe( export function createRoutes(corsOptions?: CorsOptions) {
Layer.provide([ return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, uiRoute).pipe(
cors, Layer.provide([
runtime, cors(corsOptions),
Account.defaultLayer, runtime,
Agent.defaultLayer, Account.defaultLayer,
Auth.defaultLayer, Agent.defaultLayer,
Command.defaultLayer, Auth.defaultLayer,
Config.defaultLayer, Command.defaultLayer,
File.defaultLayer, Config.defaultLayer,
Format.defaultLayer, File.defaultLayer,
LSP.defaultLayer, Format.defaultLayer,
Installation.defaultLayer, LSP.defaultLayer,
MCP.defaultLayer, Installation.defaultLayer,
Permission.defaultLayer, MCP.defaultLayer,
Project.defaultLayer, Permission.defaultLayer,
ProviderAuth.defaultLayer, Project.defaultLayer,
Provider.defaultLayer, ProviderAuth.defaultLayer,
Pty.defaultLayer, Provider.defaultLayer,
Question.defaultLayer, Pty.defaultLayer,
Ripgrep.defaultLayer, Question.defaultLayer,
Session.defaultLayer, Ripgrep.defaultLayer,
SessionCompaction.defaultLayer, Session.defaultLayer,
SessionPrompt.defaultLayer, SessionCompaction.defaultLayer,
SessionRevert.defaultLayer, SessionPrompt.defaultLayer,
SessionShare.defaultLayer, SessionRevert.defaultLayer,
SessionRunState.defaultLayer, SessionShare.defaultLayer,
SessionStatus.defaultLayer, SessionRunState.defaultLayer,
SessionSummary.defaultLayer, SessionStatus.defaultLayer,
SyncEvent.defaultLayer, SessionSummary.defaultLayer,
Skill.defaultLayer, SyncEvent.defaultLayer,
Todo.defaultLayer, Skill.defaultLayer,
ToolRegistry.defaultLayer, Todo.defaultLayer,
Vcs.defaultLayer, ToolRegistry.defaultLayer,
Workspace.defaultLayer, Vcs.defaultLayer,
Worktree.defaultLayer, Workspace.defaultLayer,
Bus.layer, Worktree.defaultLayer,
AppFileSystem.defaultLayer, Bus.layer,
FetchHttpClient.layer, AppFileSystem.defaultLayer,
HttpServer.layerServices, FetchHttpClient.layer,
]), HttpServer.layerServices,
Layer.provideMerge(Observability.layer), ]),
) Layer.provideMerge(Observability.layer),
)
}
export const webHandler = lazy(() => export const routes = createRoutes()
const defaultWebHandler = lazy(() =>
HttpRouter.toWebHandler(routes, { HttpRouter.toWebHandler(routes, {
memoMap, memoMap,
middleware: disposeMiddleware, 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" export * as ExperimentalHttpApiServer from "./server"
+15 -13
View File
@@ -18,6 +18,7 @@ import { InstanceMiddleware } from "./routes/instance/middleware"
import { WorkspaceRoutes } from "./routes/control/workspace" import { WorkspaceRoutes } from "./routes/control/workspace"
import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "./routes/instance/httpapi/server"
import * as ServerBackend from "./backend" 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 // @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 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -38,6 +39,13 @@ type ServerApp = {
request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response> request(input: string | URL | Request, init?: RequestInit): Response | Promise<Response>
} }
type ListenOptions = CorsOptions & {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
}
const DefaultHono = lazy(() => const DefaultHono = lazy(() =>
withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })), withBackend({ backend: "hono", reason: "stable" }, createHono({}, { backend: "hono", reason: "stable" })),
) )
@@ -54,14 +62,14 @@ export const Default = () => {
return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono() return selected.backend === "effect-httpapi" ? DefaultHttpApi() : DefaultHono()
} }
function create(opts: { cors?: string[] }) { function create(opts: ListenOptions) {
const selected = select() const selected = select()
return selected.backend === "effect-httpapi" return selected.backend === "effect-httpapi"
? withBackend(selected, createHttpApi()) ? withBackend(selected, createHttpApi(opts))
: withBackend(selected, createHono(opts, selected)) : 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" })) 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 return built
} }
function createHttpApi() { function createHttpApi(corsOptions?: CorsOptions) {
const handler = ExperimentalHttpApiServer.webHandler().handler const handler = ExperimentalHttpApiServer.webHandler(corsOptions).handler
const app: ServerApp = { const app: ServerApp = {
fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context), fetch: (request: Request) => handler(request, ExperimentalHttpApiServer.context),
request(input, init) { request(input, init) {
@@ -89,7 +97,7 @@ function createHttpApi() {
} }
function createHono( function createHono(
opts: { cors?: string[] }, opts: CorsOptions,
selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"), selection: ServerBackend.Selection = ServerBackend.force(select(), "hono"),
) { ) {
const backendAttributes = ServerBackend.attributes(selection) const backendAttributes = ServerBackend.attributes(selection)
@@ -151,13 +159,7 @@ export async function openapi() {
export let url: URL export let url: URL
export async function listen(opts: { export async function listen(opts: ListenOptions): Promise<Listener> {
port: number
hostname: string
mdns?: boolean
mdnsDomain?: string
cors?: string[]
}): Promise<Listener> {
const built = create(opts) const built = create(opts)
const server = await built.runtime.listen(opts) const server = await built.runtime.listen(opts)
@@ -4,6 +4,7 @@ import { describe, expect } from "bun:test"
import { Config, Effect, Layer } from "effect" import { Config, Effect, Layer } from "effect"
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
import * as Socket from "effect/unstable/socket/Socket" 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 { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server" import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { resetDatabase } from "../fixture/db" import { resetDatabase } from "../fixture/db"
@@ -61,4 +62,30 @@ describe("HttpApi CORS", () => {
expect(response.headers["access-control-allow-headers"]).toBe("authorization") 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")
}),
)
}) })