refactor(server): unify instance httpapi middleware routing
Unify declared instance HTTP API endpoints under typed middleware routing, including event streaming and PTY WebSocket connect handling.\n\nPreserve PTY connect compatibility by checking missing PTYs before parsing optional cursor and ticket query fields, with regression coverage.
This commit is contained in:
@@ -4,8 +4,8 @@ Use these patterns for server and HttpApi middleware tests in this directory.
|
||||
|
||||
- Prefer focused middleware tests with tiny fake routes over full API route trees when testing routing, context, proxying, or middleware policy.
|
||||
- Use `testEffect(...)` with `NodeHttpServer.layerTest` for the primary in-test server and make relative `HttpClient` requests against it.
|
||||
- Use `HttpRouter.add(...)` probe routes that expose the context under test, such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Compose middleware in the same order as production when testing interactions, for example `instanceRouterMiddleware.combine(workspaceRouterMiddleware)`.
|
||||
- Use tiny `HttpApiBuilder` probe groups that declare the typed middleware under test and expose context such as `WorkspaceRouteContext`, `InstanceRef`, or `WorkspaceRef`.
|
||||
- Declare middleware in the same order as production when testing interactions, for example `InstanceContextMiddleware` followed by `WorkspaceRoutingMiddleware`.
|
||||
- For secondary upstream servers, build Effect `NodeHttpServer.layer(...)` into the current test scope with `Layer.build(...)` so the listener stays alive until the test scope exits.
|
||||
- Avoid `Bun.serve` when testing Effect HTTP middleware. Keep the test in the Effect HTTP stack unless the production path being tested is Bun-specific.
|
||||
- For WebSocket paths, use `Socket.makeWebSocket(...)` from the test client and assert protocol forwarding or frame relay when relevant.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import { Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -12,9 +13,17 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { disposeMiddleware, markInstanceForDisposal } from "../../src/server/routes/instance/httpapi/lifecycle"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { withFixedWorkspaceID } from "../fixture/flag"
|
||||
@@ -47,9 +56,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -80,20 +90,57 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
const probeInstanceContext = Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return yield* HttpServerResponse.json({
|
||||
return {
|
||||
directory: instance?.directory,
|
||||
worktree: instance?.worktree,
|
||||
projectID: instance?.project.id,
|
||||
workspaceID,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const serveProbe = (probePath: HttpRouter.PathInput = "/probe") =>
|
||||
HttpRouter.add("GET", probePath, probeInstanceContext).pipe(
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.optional(Schema.String),
|
||||
worktree: Schema.optional(Schema.String),
|
||||
projectID: Schema.optional(Schema.String),
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("instance-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.post("dispose", "/dispose-probe", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.Boolean,
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => probeInstanceContext)
|
||||
.handle("session", () => probeInstanceContext)
|
||||
.handle(
|
||||
"dispose",
|
||||
Effect.fn("InstanceContextProbe.dispose")(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return false
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return true
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const probeRoutes = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
)
|
||||
|
||||
const serveProbe = () => probeRoutes.pipe(HttpRouter.serve, Layer.build)
|
||||
|
||||
const waitDisposedEvent = waitGlobalBusEvent({
|
||||
message: "timed out waiting for instance disposal",
|
||||
@@ -101,19 +148,9 @@ const waitDisposedEvent = waitGlobalBusEvent({
|
||||
}).pipe(Effect.map((event) => ({ directory: event.directory, workspace: event.workspace })))
|
||||
|
||||
const serveDisposeProbe = () =>
|
||||
HttpRouter.serve(
|
||||
HttpRouter.add(
|
||||
"POST",
|
||||
"/dispose-probe",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
if (!instance) return HttpServerResponse.empty({ status: 500 })
|
||||
yield* markInstanceForDisposal(instance)
|
||||
return yield* HttpServerResponse.json(true)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer)),
|
||||
{ middleware: disposeMiddleware, disableListenLog: true, disableLogger: true },
|
||||
).pipe(Layer.build)
|
||||
HttpRouter.serve(probeRoutes, { middleware: disposeMiddleware, disableListenLog: true, disableLogger: true }).pipe(
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi instance context middleware", () => {
|
||||
it.live("provides instance context from the routed directory", () =>
|
||||
@@ -129,6 +166,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
directory: dir,
|
||||
worktree: dir,
|
||||
projectID: project.project.id,
|
||||
workspaceID: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -156,7 +194,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
type: "instance-context-workspace-ref",
|
||||
directory: workspaceDir,
|
||||
})
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
@@ -269,7 +307,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
// is true. Combined with the env override, the route must stay Local with
|
||||
// the configured workspace id (not divert to the requested workspace's
|
||||
// local directory).
|
||||
yield* serveProbe("/session")
|
||||
yield* serveProbe()
|
||||
|
||||
const response = yield* HttpClientRequest.get(`/session?workspace=${workspace.id}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HttpRouter } from "effect/unstable/http"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { PtyID } from "../../src/pty/schema"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
@@ -35,7 +36,7 @@ function app(input: { password?: string; username?: string }) {
|
||||
}
|
||||
|
||||
function basic(username: string, password: string) {
|
||||
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
||||
return ServerAuth.header({ username, password }) ?? ""
|
||||
}
|
||||
|
||||
async function cancelBody(response: Response) {
|
||||
@@ -47,8 +48,8 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("HttpApi raw route authorization", () => {
|
||||
test("requires configured auth before opening the raw instance event stream", async () => {
|
||||
describe("HttpApi instance route authorization", () => {
|
||||
test("requires configured auth before opening the instance event stream", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
@@ -64,7 +65,7 @@ describe("HttpApi raw route authorization", () => {
|
||||
expect(authed.status).toBe(200)
|
||||
})
|
||||
|
||||
test("requires configured auth before resolving the raw PTY websocket route", async () => {
|
||||
test("requires configured auth before resolving the PTY websocket route", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const server = app({ password: "secret" })
|
||||
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Scope } from "effect"
|
||||
import { Deferred, Effect, Layer, Schema, Scope } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
@@ -20,8 +21,16 @@ import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { instanceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import { workspaceRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { Session } from "../../src/session/session"
|
||||
import {
|
||||
InstanceContextMiddleware,
|
||||
instanceContextLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/instance-context"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
@@ -52,9 +61,10 @@ const it = testEffect(
|
||||
),
|
||||
)
|
||||
|
||||
const instanceContextTestLayer = instanceRouterMiddleware
|
||||
.combine(workspaceRouterMiddleware)
|
||||
.layer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const instanceContextTestLayer = Layer.mergeAll(
|
||||
instanceContextLayer,
|
||||
workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
|
||||
)
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
name: "Local Test",
|
||||
@@ -87,6 +97,46 @@ const captureInstance = Effect.gen(function* () {
|
||||
return { directory: instance?.directory, workspaceID } satisfies Capture
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("handler-context-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.post("fork", "/fork-probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.post("streamWithout", "/stream-probe-without", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
HttpApiEndpoint.post("streamWith", "/stream-probe-with", {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "application/json" })),
|
||||
}),
|
||||
)
|
||||
.middleware(InstanceContextMiddleware)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const serveProbes = (input: {
|
||||
fork?: Effect.Effect<boolean, never, Scope.Scope>
|
||||
streamWithout?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
streamWith?: Effect.Effect<HttpServerResponse.HttpServerResponse>
|
||||
}) =>
|
||||
HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(
|
||||
HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("fork", () => input.fork ?? Effect.succeed(false))
|
||||
.handleRaw(
|
||||
"streamWithout",
|
||||
() => input.streamWithout ?? Effect.succeed(HttpServerResponse.empty({ status: 404 })),
|
||||
)
|
||||
.handleRaw("streamWith", () => input.streamWith ?? Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
),
|
||||
),
|
||||
Layer.provide(instanceContextTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi handler context inheritance", () => {
|
||||
// Mirrors handlers/session.ts:281 promptAsync. The forked fiber inherits
|
||||
// the request's Context — including InstanceRef and WorkspaceRef provided
|
||||
@@ -96,22 +146,20 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const { dir, workspace } = yield* setupWorkspace("local-fork")
|
||||
const capture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/fork-probe",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
fork: Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(capture, yield* captureInstance)
|
||||
}).pipe(Effect.forkIn(scope, { startImmediately: true }))
|
||||
return HttpServerResponse.empty({ status: 204 })
|
||||
return true
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const response = yield* HttpClient.post(
|
||||
`/fork-probe?directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`,
|
||||
)
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.status).toBe(200)
|
||||
|
||||
const observed = yield* Deferred.await(capture).pipe(Effect.timeout("2 seconds"))
|
||||
expect(observed.directory).toBe(dir)
|
||||
@@ -129,10 +177,8 @@ describe("HttpApi handler context inheritance", () => {
|
||||
const withoutCapture = yield* Deferred.make<Capture>()
|
||||
const withCapture = yield* Deferred.make<Capture>()
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-without",
|
||||
Effect.gen(function* () {
|
||||
yield* serveProbes({
|
||||
streamWithout: Effect.gen(function* () {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromEffect(
|
||||
Effect.gen(function* () {
|
||||
@@ -143,12 +189,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
|
||||
yield* HttpRouter.add(
|
||||
"POST",
|
||||
"/stream-probe-with",
|
||||
Effect.gen(function* () {
|
||||
streamWith: Effect.gen(function* () {
|
||||
const instance = yield* InstanceRef
|
||||
const workspaceID = yield* WorkspaceRef
|
||||
return HttpServerResponse.stream(
|
||||
@@ -161,7 +202,7 @@ describe("HttpApi handler context inheritance", () => {
|
||||
{ contentType: "application/json" },
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(instanceContextTestLayer), HttpRouter.serve, Layer.build)
|
||||
})
|
||||
|
||||
const queryString = `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent(workspace.id)}`
|
||||
const responseWithout = yield* HttpClient.post(`/stream-probe-without?${queryString}`)
|
||||
|
||||
@@ -147,6 +147,14 @@ describe("pty HttpApi bridge", () => {
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns 404 for missing PTY websocket before decoding cursor query", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await app().request(`${PtyPaths.connect.replace(":ptyID", PtyID.ascending())}?cursor=a&cursor=b`, {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
test("returns typed not found errors for missing PTY HTTP resources", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
|
||||
@@ -9,6 +9,7 @@ type OpenApiResponse = {
|
||||
readonly content?: Record<string, { readonly schema?: OpenApiSchema }>
|
||||
}
|
||||
type OpenApiOperation = {
|
||||
readonly parameters?: ReadonlyArray<{ readonly name: string; readonly in: string }>
|
||||
readonly responses?: Record<string, OpenApiResponse>
|
||||
readonly security?: unknown
|
||||
}
|
||||
@@ -207,6 +208,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
||||
expect(componentName(responseRef(spec.paths["/pty/{ptyID}/connect-token"]?.post?.responses?.["403"]) ?? "")).toBe(
|
||||
"PtyForbiddenError",
|
||||
)
|
||||
expect(
|
||||
spec.paths["/pty/{ptyID}/connect"]?.get?.parameters
|
||||
?.filter((parameter) => parameter.in === "query")
|
||||
.map((parameter) => parameter.name),
|
||||
).toEqual(["directory", "workspace", "cursor", "ticket"])
|
||||
})
|
||||
|
||||
test("documents project not-found errors", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Queue, Ref } from "effect"
|
||||
import { Context, Effect, Layer, Queue, Ref, Schema } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"
|
||||
import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -20,10 +21,13 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import {
|
||||
WorkspaceRoutingMiddleware,
|
||||
WorkspaceRoutingQuery,
|
||||
WorkspaceRouteContext,
|
||||
workspaceRouterMiddleware,
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { Database } from "../../src/storage/db"
|
||||
@@ -66,7 +70,7 @@ type TestHandler<E, R> = (
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
|
||||
|
||||
const workspaceRoutingTestLayer = workspaceRouterMiddleware.layer.pipe(
|
||||
const workspaceRoutingTestLayer = workspaceRoutingLayer.pipe(
|
||||
Layer.provide([Socket.layerWebSocketConstructorGlobal, FetchHttpClient.layer]),
|
||||
)
|
||||
|
||||
@@ -203,16 +207,45 @@ const echoWebSocket = (request: HttpServerRequest.HttpServerRequest) =>
|
||||
return HttpServerResponse.empty()
|
||||
})
|
||||
|
||||
const serveRouteContextProbe = HttpRouter.add(
|
||||
"GET",
|
||||
"/probe",
|
||||
Effect.gen(function* () {
|
||||
// The fake route exposes the context installed by the middleware, so tests
|
||||
// can assert routing decisions without pulling in the production API tree.
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
const ProbeResult = Schema.Struct({
|
||||
directory: Schema.String,
|
||||
workspaceID: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const ProbeApi = HttpApi.make("workspace-routing-probe").add(
|
||||
HttpApiGroup.make("probe")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/probe", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.patch("patch", "/probe", { query: WorkspaceRoutingQuery, success: Schema.Boolean }),
|
||||
HttpApiEndpoint.get("session", "/session", { query: WorkspaceRoutingQuery, success: ProbeResult }),
|
||||
HttpApiEndpoint.get("workspace", WorkspacePaths.list, {
|
||||
query: WorkspaceRoutingQuery,
|
||||
success: ProbeResult,
|
||||
}),
|
||||
)
|
||||
.middleware(WorkspaceRoutingMiddleware),
|
||||
)
|
||||
|
||||
const routeContextResponse = Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return { directory: route.directory, workspaceID: route.workspaceID }
|
||||
})
|
||||
|
||||
const probeHandlers = HttpApiBuilder.group(ProbeApi, "probe", (handlers) =>
|
||||
handlers
|
||||
.handle("get", () => routeContextResponse)
|
||||
.handle("patch", () => Effect.succeed(false))
|
||||
.handle("session", () => routeContextResponse)
|
||||
.handle("workspace", () => routeContextResponse),
|
||||
)
|
||||
|
||||
const serveProbe = HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
|
||||
describe("HttpApi workspace routing middleware", () => {
|
||||
it.live("proxies remote workspace HTTP requests through the selected workspace target", () =>
|
||||
@@ -250,11 +283,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The local /probe handler should not run. Selecting a remote workspace
|
||||
// should make the middleware call HttpApiProxy.http instead.
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClientRequest.patch(`/probe?workspace=${workspace.id}&keep=yes`).pipe(
|
||||
HttpClientRequest.setHeaders({
|
||||
@@ -325,9 +354,11 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
startWorkspaceSyncing: () => Effect.die("unused"),
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("PATCH", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
yield* HttpApiBuilder.layer(ProbeApi).pipe(
|
||||
Layer.provide(probeHandlers),
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
Layer.provide(Layer.succeed(Workspace.Service, workspace)),
|
||||
Layer.provide(Layer.mock(Session.Service)({})),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
@@ -351,11 +382,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
url: "http://127.0.0.1:1/base",
|
||||
})
|
||||
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -378,11 +405,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// The client connects to the local test server. The middleware should
|
||||
// detect the WebSocket upgrade and proxy it to the remote /base/probe.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const socket = yield* Socket.makeWebSocket(
|
||||
`${(yield* serverUrl).replace(/^http/, "ws")}/probe?workspace=${workspace.id}`,
|
||||
@@ -406,11 +429,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* HttpRouter.add("GET", "/probe", HttpServerResponse.text("route called")).pipe(
|
||||
Layer.provide(workspaceRoutingTestLayer),
|
||||
HttpRouter.serve,
|
||||
Layer.build,
|
||||
)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/probe?workspace=${workspaceID}`)
|
||||
|
||||
@@ -433,14 +452,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
// GET /session is a control-plane route: it lists sessions for the main
|
||||
// process and should not be redirected into the selected workspace target.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
"/session",
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`/session?workspace=${workspace.id}`)
|
||||
|
||||
@@ -463,14 +475,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
// Workspace CRUD/status routes manage the control plane itself. Selecting
|
||||
// a workspace should preserve the selected id for handlers, but must not
|
||||
// swap the route context to the workspace target directory.
|
||||
yield* HttpRouter.add(
|
||||
"GET",
|
||||
WorkspacePaths.list,
|
||||
Effect.gen(function* () {
|
||||
const route = yield* WorkspaceRouteContext
|
||||
return yield* HttpServerResponse.json({ directory: route.directory, workspaceID: route.workspaceID })
|
||||
}),
|
||||
).pipe(Layer.provide(workspaceRoutingTestLayer), HttpRouter.serve, Layer.build)
|
||||
yield* serveProbe
|
||||
|
||||
const response = yield* HttpClient.get(`${WorkspacePaths.list}?workspace=${workspace.id}`)
|
||||
|
||||
@@ -484,7 +489,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
const dir = yield* tmpdirScoped()
|
||||
const queryDir = path.join(dir, "query-target")
|
||||
const headerDir = path.join(dir, "header-target")
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// Without a selected workspace, the middleware falls back to request
|
||||
// directory hints before using the process cwd.
|
||||
@@ -495,9 +500,9 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
)
|
||||
|
||||
expect(queryResponse.status).toBe(200)
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir })
|
||||
expect(yield* queryResponse.json).toEqual({ directory: queryDir, workspaceID: null })
|
||||
expect(headerResponse.status).toBe(200)
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir })
|
||||
expect(yield* headerResponse.json).toEqual({ directory: headerDir, workspaceID: null })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -513,7 +518,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
directory: workspaceDir,
|
||||
})
|
||||
|
||||
yield* serveRouteContextProbe
|
||||
yield* serveProbe
|
||||
|
||||
// /probe is not a control-plane route, so selecting a local workspace
|
||||
// should swap the route context to the workspace target directory.
|
||||
|
||||
@@ -9,6 +9,7 @@ import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -344,6 +345,7 @@ describe("workspace HttpApi", () => {
|
||||
proxied.push(request)
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/base/global/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/event") return eventStreamResponse()
|
||||
if (url.pathname === "/base/sync/history") return Response.json([])
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
@@ -413,6 +415,18 @@ describe("workspace HttpApi", () => {
|
||||
])
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-directory")
|
||||
expect(forwarded[0]?.headers).not.toHaveProperty("x-opencode-workspace")
|
||||
|
||||
const eventURL = new URL(`http://localhost${EventPaths.event}`)
|
||||
eventURL.searchParams.set("workspace", workspace.id)
|
||||
const eventResponse = yield* request(eventURL.toString(), dir)
|
||||
expect(eventResponse.status).toBe(200)
|
||||
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
|
||||
if (!eventResponse.body) throw new Error("missing proxied event response body")
|
||||
const eventReader = eventResponse.body.getReader()
|
||||
const event = yield* Effect.promise(() => eventReader.read())
|
||||
yield* Effect.promise(() => eventReader.cancel())
|
||||
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
|
||||
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
|
||||
Reference in New Issue
Block a user