mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 02:13:23 +00:00
feat(server): web-standard fetch handler entry (#41896)
This commit is contained in:
parent
f6611a1f56
commit
c858986b08
2 changed files with 130 additions and 0 deletions
54
packages/server/src/fetch.ts
Normal file
54
packages/server/src/fetch.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
export * as ServerFetch from "./fetch"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { HttpEffect, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
import { createRoutes } from "./routes"
|
||||
import type { ServerOptions } from "./options"
|
||||
|
||||
export interface BootOptions {
|
||||
/**
|
||||
* Resumes execution-journaled Sessions once the application layer boots. Pair with
|
||||
* `SessionExecution.configured({ suspendOnStart: true })` on runtimes that can die without
|
||||
* teardown, so turns orphaned by a hard death replay on the next boot.
|
||||
*/
|
||||
readonly resumeSuspendedSessions?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a web-standard fetch handler — `(request: Request) => Promise<Response>` — serving the
|
||||
* same HttpApi routes as the Node server process without binding a port, owning a listener, or
|
||||
* installing signal handlers. This is the entry for runtimes that hand requests to the embedder
|
||||
* instead of letting it listen: workerd (Workers and Durable Objects), Deno.serve, Bun.serve, or
|
||||
* a test harness.
|
||||
*
|
||||
* The application layer builds EAGERLY, inside the caller's `Scope`, before the handler is
|
||||
* returned. Do not convert this to a lazy first-request build: on workerd, a first request that
|
||||
* aborts mid-build interrupts the layer construction and wedges every subsequent request
|
||||
* (Effect-TS/effect#6319 class). The embedder owns the lifecycle — closing the scope releases
|
||||
* the application layer.
|
||||
*
|
||||
* Auth follows `createRoutes` semantics: `options.password` enforces Basic auth; omitting it
|
||||
* serves unauthenticated, so an embedder without a password must front the handler with its own
|
||||
* access control.
|
||||
*/
|
||||
export const make = Effect.fn("ServerFetch.make")(function* (
|
||||
options: ServerOptions = {},
|
||||
boot: BootOptions = {},
|
||||
) {
|
||||
const context = yield* Layer.build(
|
||||
createRoutes(options, () => []).pipe(Layer.provide(HttpServer.layerServices)),
|
||||
)
|
||||
// Forked so the returned handler is never delayed; resumed drains are already
|
||||
// logged and durably recorded by the execution layer.
|
||||
if (boot.resumeSuspendedSessions)
|
||||
yield* Effect.forkDetach(Context.get(context, SessionRestart.Service).resumeSuspendedSessions)
|
||||
return Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: isAllowedCorsOrigin, maxAge: 86_400 }),
|
||||
HttpEffect.toWebHandlerWith(context),
|
||||
)
|
||||
})
|
||||
|
||||
76
packages/server/test/fetch.test.ts
Normal file
76
packages/server/test/fetch.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerFetch } from "../src/fetch"
|
||||
|
||||
const options = {
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
fs: { filewatcher: false },
|
||||
} as const
|
||||
|
||||
it.live("serves the HttpApi and enforces Basic auth like the Node server", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make({ ...options, password: "secret" })
|
||||
|
||||
const denied = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
|
||||
expect(denied.status).toBe(401)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/health", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const body: unknown = yield* Effect.promise(() => response.json())
|
||||
if (typeof body !== "object" || body === null) throw new Error("Expected a health response object")
|
||||
expect((body as Record<string, unknown>)["healthy"]).toBe(true)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
|
||||
const response = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const preflight = yield* Effect.promise(() =>
|
||||
handler(
|
||||
new Request("http://opencode.local/api/health", {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
origin: "http://localhost:3000",
|
||||
"access-control-request-method": "GET",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(preflight.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
|
||||
// Pins the eager-boot guarantee: the application layer is built before the handler returns, so
|
||||
// an aborted first request cannot interrupt layer construction and wedge every later request
|
||||
// (the Effect-TS/effect#6319 failure class that lazy first-request builds are prone to).
|
||||
it.live("stays serviceable when the first request aborts", () =>
|
||||
Effect.gen(function* () {
|
||||
const handler = yield* ServerFetch.make(options)
|
||||
|
||||
const aborted = yield* Effect.promise(() => {
|
||||
const controller = new AbortController()
|
||||
const first = handler(new Request("http://opencode.local/api/health", { signal: controller.signal }))
|
||||
controller.abort()
|
||||
return first.then(
|
||||
() => "resolved" as const,
|
||||
() => "rejected" as const,
|
||||
)
|
||||
})
|
||||
expect(["resolved", "rejected"]).toContain(aborted)
|
||||
|
||||
const second = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/health")))
|
||||
expect(second.status).toBe(200)
|
||||
}).pipe(Effect.scoped),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue