Explorar el Código

Pass CORS options to HttpApi backend (#25201)

Kit Langton hace 3 meses
padre
commit
bc805b3001

+ 3 - 1
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

+ 2 - 2
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) {

+ 66 - 52
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"

+ 15 - 13
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)
 

+ 27 - 0
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")
+    }),
+  )
 })