Pass CORS options to HttpApi backend (#25201)
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user