From c858986b08446663c0b66af0f513aab27b52fede Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 21:37:06 -0400 Subject: [PATCH] feat(server): web-standard fetch handler entry (#41896) --- packages/server/src/fetch.ts | 54 +++++++++++++++++++++ packages/server/test/fetch.test.ts | 76 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 packages/server/src/fetch.ts create mode 100644 packages/server/test/fetch.test.ts diff --git a/packages/server/src/fetch.ts b/packages/server/src/fetch.ts new file mode 100644 index 00000000000..678d695426b --- /dev/null +++ b/packages/server/src/fetch.ts @@ -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` — 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), + ) +}) + diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts new file mode 100644 index 00000000000..d7dd84f1b7c --- /dev/null +++ b/packages/server/test/fetch.test.ts @@ -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)["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), +)