httpapi-compression.test.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. import { afterEach, describe, expect, test } from "bun:test"
  2. import { gunzipSync, inflateSync } from "node:zlib"
  3. import { Server } from "../../src/server/server"
  4. import { resetDatabase } from "../fixture/db"
  5. import { disposeAllInstances, tmpdir } from "../fixture/fixture"
  6. afterEach(async () => {
  7. await disposeAllInstances()
  8. await resetDatabase()
  9. })
  10. function app() {
  11. return Server.Default().app
  12. }
  13. // /config echoes the config back. Padding the config pushes the response body
  14. // well past the 1024 B threshold so we can observe compression behavior.
  15. function fatConfig() {
  16. const instructions: string[] = []
  17. for (let i = 0; i < 50; i++) {
  18. instructions.push(`padding-instruction-${i}-${"x".repeat(40)}`)
  19. }
  20. return {
  21. formatter: false,
  22. lsp: false,
  23. username: "compression-test-user",
  24. instructions,
  25. }
  26. }
  27. describe("HttpApi compression", () => {
  28. describe("encodes responses", () => {
  29. test("gzips JSON when Accept-Encoding includes gzip and body exceeds threshold", async () => {
  30. await using tmp = await tmpdir({ config: fatConfig() })
  31. const response = await app().request("/config", {
  32. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
  33. })
  34. expect(response.status).toBe(200)
  35. expect(response.headers.get("content-encoding")).toBe("gzip")
  36. const compressed = new Uint8Array(await response.arrayBuffer())
  37. const decompressed = gunzipSync(compressed)
  38. const json = JSON.parse(new TextDecoder().decode(decompressed))
  39. expect(json).toMatchObject({ username: "compression-test-user" })
  40. expect(compressed.byteLength).toBeLessThan(decompressed.byteLength)
  41. })
  42. test("uses deflate when only deflate is acceptable", async () => {
  43. await using tmp = await tmpdir({ config: fatConfig() })
  44. const response = await app().request("/config", {
  45. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "deflate" },
  46. })
  47. expect(response.status).toBe(200)
  48. expect(response.headers.get("content-encoding")).toBe("deflate")
  49. const compressed = new Uint8Array(await response.arrayBuffer())
  50. const decompressed = inflateSync(compressed)
  51. const json = JSON.parse(new TextDecoder().decode(decompressed))
  52. expect(json).toMatchObject({ username: "compression-test-user" })
  53. })
  54. test("prefers gzip when both gzip and deflate are acceptable", async () => {
  55. await using tmp = await tmpdir({ config: fatConfig() })
  56. const response = await app().request("/config", {
  57. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip, deflate" },
  58. })
  59. expect(response.headers.get("content-encoding")).toBe("gzip")
  60. })
  61. test("does not include the original Content-Length when compressed", async () => {
  62. await using tmp = await tmpdir({ config: fatConfig() })
  63. const response = await app().request("/config", {
  64. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
  65. })
  66. const compressed = new Uint8Array(await response.arrayBuffer())
  67. const declared = response.headers.get("content-length")
  68. // Either absent (transfer-encoding chunked) or matches the compressed length.
  69. if (declared !== null) expect(Number(declared)).toBe(compressed.byteLength)
  70. })
  71. })
  72. describe("skips", () => {
  73. test("when no Accept-Encoding header is present", async () => {
  74. await using tmp = await tmpdir({ config: fatConfig() })
  75. const response = await app().request("/config", {
  76. headers: { "x-opencode-directory": tmp.path },
  77. })
  78. expect(response.headers.get("content-encoding")).toBeNull()
  79. })
  80. test("when Accept-Encoding only allows unsupported encodings", async () => {
  81. await using tmp = await tmpdir({ config: fatConfig() })
  82. const response = await app().request("/config", {
  83. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "br" },
  84. })
  85. expect(response.headers.get("content-encoding")).toBeNull()
  86. })
  87. test("when the response body is below the 1024-byte threshold", async () => {
  88. // A bare config produces a tiny response (~few hundred bytes).
  89. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  90. const response = await app().request("/config", {
  91. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
  92. })
  93. expect(response.status).toBe(200)
  94. const body = new Uint8Array(await response.arrayBuffer())
  95. expect(body.byteLength).toBeLessThan(1024)
  96. expect(response.headers.get("content-encoding")).toBeNull()
  97. })
  98. test("HEAD requests", async () => {
  99. await using tmp = await tmpdir({ config: fatConfig() })
  100. const response = await app().request("/config", {
  101. method: "HEAD",
  102. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
  103. })
  104. expect(response.headers.get("content-encoding")).toBeNull()
  105. })
  106. })
  107. describe("streaming exclusions", () => {
  108. test("/event SSE is not compressed", async () => {
  109. await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
  110. const controller = new AbortController()
  111. const response = await app().request("/event", {
  112. headers: { "x-opencode-directory": tmp.path, "accept-encoding": "gzip" },
  113. signal: controller.signal,
  114. })
  115. try {
  116. expect(response.status).toBe(200)
  117. expect(response.headers.get("content-encoding")).toBeNull()
  118. } finally {
  119. controller.abort()
  120. await response.body?.cancel().catch(() => {})
  121. }
  122. })
  123. test("/global/event SSE is not compressed", async () => {
  124. const controller = new AbortController()
  125. const response = await app().request("/global/event", {
  126. headers: { "accept-encoding": "gzip" },
  127. signal: controller.signal,
  128. })
  129. try {
  130. expect(response.status).toBe(200)
  131. expect(response.headers.get("content-encoding")).toBeNull()
  132. } finally {
  133. controller.abort()
  134. await response.body?.cancel().catch(() => {})
  135. }
  136. })
  137. })
  138. })