workspace-proxy.test.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. import { NodeHttpServer, NodeServices } from "@effect/platform-node"
  2. import Http from "node:http"
  3. import { describe, expect } from "bun:test"
  4. import { Context, Effect, Layer, Queue } from "effect"
  5. import { FetchHttpClient, HttpClient, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
  6. import * as Socket from "effect/unstable/socket/Socket"
  7. import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy"
  8. import { testEffect } from "../lib/effect"
  9. function serverUrl() {
  10. return HttpServer.HttpServer.use((server) => Effect.succeed(HttpServer.formatAddress(server.address)))
  11. }
  12. const testServerLayer = Layer.mergeAll(
  13. NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }),
  14. NodeServices.layer,
  15. FetchHttpClient.layer,
  16. Socket.layerWebSocketConstructorGlobal,
  17. )
  18. const it = testEffect(testServerLayer)
  19. type TestHandler<E, R> = (
  20. request: HttpServerRequest.HttpServerRequest,
  21. ) => Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>
  22. function listenServer<E, R>(handler: TestHandler<E, R>) {
  23. return Effect.gen(function* () {
  24. yield* HttpServer.serveEffect()(HttpServerRequest.HttpServerRequest.use(handler))
  25. return yield* serverUrl()
  26. })
  27. }
  28. function listenTestServer<E, R>(handler: TestHandler<E, R>) {
  29. return Effect.gen(function* () {
  30. // Build into the current test scope so the listener stays alive until the
  31. // test finishes. Using Effect.provide here would release it immediately.
  32. const context = yield* Layer.build(NodeHttpServer.layer(Http.createServer, { host: "127.0.0.1", port: 0 }))
  33. const server = Context.get(context, HttpServer.HttpServer)
  34. yield* server.serve(HttpServerRequest.HttpServerRequest.use(handler))
  35. return HttpServer.formatAddress(server.address)
  36. })
  37. }
  38. function echoWebSocket(request: HttpServerRequest.HttpServerRequest) {
  39. return Effect.gen(function* () {
  40. const socket = yield* Effect.orDie(request.upgrade)
  41. const write = yield* socket.writer
  42. // The upstream announces the negotiated protocol, then echoes every
  43. // received frame. The assertions use those messages to prove proxy flow.
  44. yield* socket
  45. .runRaw((message) => write(`echo:${String(message)}`), {
  46. onOpen: write(`protocol:${request.headers["sec-websocket-protocol"] ?? "none"}`).pipe(
  47. Effect.catch(() => Effect.void),
  48. ),
  49. })
  50. .pipe(Effect.catch(() => Effect.void))
  51. return HttpServerResponse.empty()
  52. })
  53. }
  54. describe("HttpApi workspace proxy", () => {
  55. it.live("proxies HTTP request and returns streamed response with status and headers", () =>
  56. Effect.gen(function* () {
  57. const url = yield* listenServer(
  58. Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
  59. const body = yield* req.text
  60. return yield* HttpServerResponse.json(
  61. { path: req.url, method: req.method, body },
  62. {
  63. status: 201,
  64. headers: {
  65. "content-encoding": "identity",
  66. "content-length": "999",
  67. "x-remote": "yes",
  68. },
  69. },
  70. )
  71. }),
  72. )
  73. const request = HttpServerRequest.fromWeb(
  74. new Request("http://localhost/session/abc", { method: "POST", body: "request-body" }),
  75. )
  76. const httpClient = yield* HttpClient.HttpClient
  77. const response = yield* HttpApiProxy.http(
  78. httpClient,
  79. `${url}/session/abc?keep=yes`,
  80. { "x-extra": "injected" },
  81. request,
  82. )
  83. expect(response.status).toBe(201)
  84. const client = HttpServerResponse.toClientResponse(response)
  85. expect(yield* client.json).toEqual({
  86. path: "/session/abc?keep=yes",
  87. method: "POST",
  88. body: "request-body",
  89. })
  90. expect(response.headers["x-remote"]).toBe("yes")
  91. expect(response.headers["content-encoding"]).toBeUndefined()
  92. expect(response.headers["content-length"]).toBeUndefined()
  93. }),
  94. )
  95. it.live("returns 500 when remote is unreachable", () =>
  96. Effect.gen(function* () {
  97. const request = HttpServerRequest.fromWeb(new Request("http://localhost/anything"))
  98. const httpClient = yield* HttpClient.HttpClient
  99. const response = yield* HttpApiProxy.http(httpClient, "http://127.0.0.1:1/unreachable", undefined, request)
  100. expect(response.status).toBe(500)
  101. }),
  102. )
  103. it.live("proxies bodyless Web mutation requests as an empty body", () =>
  104. Effect.gen(function* () {
  105. const url = yield* listenServer(
  106. Effect.fnUntraced(function* (req: HttpServerRequest.HttpServerRequest) {
  107. return yield* HttpServerResponse.json({ method: req.method, body: yield* req.text })
  108. }),
  109. )
  110. const request = HttpServerRequest.fromWeb(new Request("http://localhost/session/abc/abort", { method: "POST" }))
  111. const httpClient = yield* HttpClient.HttpClient
  112. const response = yield* HttpApiProxy.http(httpClient, `${url}/session/abc/abort`, undefined, request)
  113. expect(response.status).toBe(200)
  114. expect(yield* HttpServerResponse.toClientResponse(response).json).toEqual({ method: "POST", body: "" })
  115. }),
  116. )
  117. it.live("strips opencode-internal headers and merges extra headers", () =>
  118. Effect.gen(function* () {
  119. let forwarded: Record<string, string> = {}
  120. const url = yield* listenServer((req) =>
  121. Effect.sync(() => {
  122. forwarded = req.headers
  123. return HttpServerResponse.empty()
  124. }),
  125. )
  126. const request = HttpServerRequest.fromWeb(
  127. new Request("http://localhost/test", {
  128. headers: {
  129. "x-opencode-directory": "/secret/path",
  130. "x-opencode-workspace": "ws_123",
  131. "x-custom": "preserved",
  132. },
  133. }),
  134. )
  135. const httpClient = yield* HttpClient.HttpClient
  136. yield* HttpApiProxy.http(httpClient, `${url}/test`, { "x-injected": "extra" }, request)
  137. expect(forwarded["x-opencode-directory"]).toBeUndefined()
  138. expect(forwarded["x-opencode-workspace"]).toBeUndefined()
  139. expect(forwarded["x-custom"]).toBe("preserved")
  140. expect(forwarded["x-injected"]).toBe("extra")
  141. }),
  142. )
  143. it.live("proxies websocket messages and protocols", () =>
  144. Effect.gen(function* () {
  145. const upstreamUrl = yield* listenTestServer(echoWebSocket)
  146. // Client -> proxy listener -> HttpApiProxy.websocket -> upstream listener.
  147. // The client never connects to upstream directly.
  148. const proxyUrl = yield* listenServer((request) => HttpApiProxy.websocket(request, `${upstreamUrl}/echo`))
  149. const socket = yield* Socket.makeWebSocket(`${proxyUrl.replace(/^http/, "ws")}/proxy`, {
  150. closeCodeIsError: () => false,
  151. protocols: "chat",
  152. })
  153. const messages = yield* Queue.unbounded<string>()
  154. yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped)
  155. const write = yield* socket.writer
  156. expect(yield* Queue.take(messages)).toBe("protocol:chat")
  157. yield* write("hello")
  158. expect(yield* Queue.take(messages)).toBe("echo:hello")
  159. }),
  160. )
  161. })