Selaa lähdekoodia

feat(server): web-standard fetch handler entry (#41896)

Kit Langton 5 päivää sitten
vanhempi
sitoutus
c858986b08
2 muutettua tiedostoa jossa 130 lisäystä ja 0 poistoa
  1. 54 0
      packages/server/src/fetch.ts
  2. 76 0
      packages/server/test/fetch.test.ts

+ 54 - 0
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<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 - 0
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<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),
+)