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:
Kit Langton
2026-05-27 08:45:11 -04:00
committed by GitHub
parent 9941e70d29
commit 76d814e747
18 changed files with 340 additions and 195 deletions

View File

@@ -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.