Skip to content

feat(httpapi-listener): workspace-proxy WS forwarding#25594

Closed
kitlangton wants to merge 1 commit into
devfrom
kit/httpapi-listener-proxy-ws
Closed

feat(httpapi-listener): workspace-proxy WS forwarding#25594
kitlangton wants to merge 1 commit into
devfrom
kit/httpapi-listener-proxy-ws

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

Summary

Implements workspace-proxy WebSocket forwarding inside the Bun.serve-based HttpApiListener so it has parity with the Hono path's WorkspaceRouterMiddleware -> ServerProxy.websocket flow.

  • New WsKind variant proxy carrying remoteURL + subprotocols. Resolved inline in fetch(request, server) via Session.Service.get(...) then Workspace.Service.get(...) then getAdapter(...).target(...). Honors Flag.OPENCODE_WORKSPACE_ID, isLocalWorkspaceRoute, and /console skip rules — same shape as WorkspaceRouterMiddleware.
  • websocket.open opens a remote new WebSocket(remoteURL, subprotocols), wires onopen/onmessage/onerror/onclose, and queues frames received before the remote opens (mirrors proxy.ts onOpen/onMessage/onClose exactly).
  • websocket.message dispatches on ws.data.kind. For proxy, sends to remote when OPEN, otherwise pushes onto the queue drained by onopen.
  • websocket.close(ws, code, reason) propagates the code/reason to the remote.
  • Server.listen() is not wired to the new listener (intentional, same constraint as feat(server): native HttpApi listener with Bun.serve + WS upgrade #25547). Hono path remains canonical.
  • Bun-only. TODO comment notes the follow-up node:http + ws adapter.

Smoke test

packages/opencode/test/server/httpapi-listener.test.ts:

  • New workspace-proxy WS forwarding round-trips through a fake remote — spins up a tiny Bun.serve echo server, registers a remote WorkspaceAdapter pointing at it, opens ws://listener/probe?workspace=<id>, sends hello-proxy, asserts the listener delivers echo:hello-proxy end-to-end.
  • Existing PTY smoke + HTTP route tests still pass (3 pass / 0 fail in this file; full test/server/ suite is 172 pass / 0 fail).

Tricky bits

  • Queueing race: pre-open frames go into data.proxyQueue, drained by remote.onopen. Same shape as proxy.ts queue: Msg[].
  • Close-code propagation: remote.onclose -> ws.close(event.code, event.reason) and vice versa via the close handler signature (ws, code, reason).
  • Subprotocols: ProxyUtil.websocketProtocols(request) returns string[]; passed to new WebSocket(url, protocols) only when non-empty so an empty list doesn't accidentally request a subprotocol.
  • Async fetch: relies on Bun.serve allowing async fetch. server.upgrade(...) still runs synchronously after the awaited workspace lookup.

Remaining work to flip Server.listen()

Small. Open items:

  1. Node adapter (node:http + ws) — same dispatch table, just plumbed through the upgrade event.
  2. CORS / OPTIONS preflight parity — already handled by ExperimentalHttpApiServer.webHandler, but worth a sweep when flipping the production path.
  3. Sync-aware errors for HTTP — the Hono path returns 503 broken sync connection via ServerProxy.http when Workspace.isSyncing is false. The HttpApi handler has its own equivalent in httpapi/middleware/proxy, so listener-side flip can rely on it.
  4. Fence sync for HTTP responses goes through the HttpApi handler, so no listener-side work.
  5. Telemetry / logging convergence.

Surprises

  • Workspace lookup wiring is straightforward via AppRuntime.runPromise(Workspace.Service.use(svc => svc.get(WorkspaceID.make(id)))) — no need for a fresh layer or runtime.
  • Bun.serve's async fetch works seamlessly with an awaited workspace resolution before server.upgrade(...).
  • MessageEvent.data from outbound WebSocket shows up as ArrayBuffer (because we set binaryType = "arraybuffer"); the Blob branch is defensive.

Test plan

  • bun run typecheck clean
  • bun run test packages/opencode/test/server/httpapi-listener.test.ts (3 pass)
  • bun run test packages/opencode/test/server/ (172 pass, 0 fail, 2 todo)

Bridge workspace-proxy WebSocket upgrades inside the Bun.serve listener so
the Hono path's WorkspaceRouterMiddleware → ServerProxy.websocket flow has
a native equivalent. The HttpApi handler still owns HTTP; the listener now
also resolves the workspace target inline (?workspace=… or session lookup),
upgrades the client connection, and bridges it to a remote WebSocket with
queueing, subprotocol forwarding, and close-code propagation.

This unblocks flipping Server.listen() over to the new listener but does
not flip it — the Hono path remains canonical. Bun-only; node:http + ws
adapter is a follow-up (TODO inline).
@kitlangton
Copy link
Copy Markdown
Contributor Author

Closing — redundant. Workspace-proxy WS forwarding is already implemented in routes/instance/httpapi/middleware/workspace-routing.ts (calls HttpApiProxy.websocket on upgrade). The reason it didn't appear to work is that adapter.createFetch can't surface request.upgrade — switching to BunHttpServer fixes that without re-implementing the bridge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant