| author | |
| committer | |
| log | 8829e2f9b960eb31dc62ddab256d5da82c5077a6 |
| tree | 188bb6979c17fd526729b48c96b772b941c1e134 |
| parent | 2deea7abb0f9e4df71ca10da06a8d7268a4b9d42 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 156 insertions(+), 2 deletions(-)
apps/server/src/http.test.ts+53-2| ... | ... | @@ -1,6 +1,11 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as NodeZlib from "node:zlib"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Stream from "effect/Stream"; | |
| 5 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 6 | import { describe } from "vite-plus/test"; | |
| 2 | 7 | |
| 3 | import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 8 | import { compressHttpResponse, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 4 | 9 | |
| 5 | 10 | describe("http dev routing", () => { |
| 6 | 11 | it("treats localhost and loopback addresses as local", () => { |
| ... | ... | @@ -25,3 +30,49 @@ describe("http dev routing", () => { |
| 25 | 30 | ); |
| 26 | 31 | }); |
| 27 | 32 | }); |
| 33 | ||
| 34 | describe("http compression", () => { | |
| 35 | it.effect("gzips large JSON responses when the client accepts it", () => | |
| 36 | Effect.gen(function* () { | |
| 37 | const body = `{"value":"${"compressible".repeat(1_000)}"}`; | |
| 38 | const response = compressHttpResponse( | |
| 39 | HttpServerResponse.text(body, { contentType: "application/json" }), | |
| 40 | "br, gzip, deflate", | |
| 41 | ); | |
| 42 | ||
| 43 | expect(response.headers["content-encoding"]).toBe("gzip"); | |
| 44 | expect(response.headers["content-length"]).toBeUndefined(); | |
| 45 | expect(response.headers.vary).toBe("Accept-Encoding"); | |
| 46 | expect(response.body._tag).toBe("Stream"); | |
| 47 | if (response.body._tag === "Stream") { | |
| 48 | const chunks = yield* Stream.runCollect(Stream.orDie(response.body.stream)); | |
| 49 | expect(NodeZlib.gunzipSync(Buffer.concat(Array.from(chunks))).toString()).toBe(body); | |
| 50 | } | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it("keeps the original body when gzip is declined", () => { | |
| 55 | const response = compressHttpResponse( | |
| 56 | HttpServerResponse.text("x".repeat(2_000), { contentType: "application/json" }), | |
| 57 | "gzip;q=0, *;q=1", | |
| 58 | ); | |
| 59 | ||
| 60 | expect(response.headers["content-encoding"]).toBeUndefined(); | |
| 61 | expect(response.headers["content-length"]).toBe("2000"); | |
| 62 | expect(response.headers.vary).toBe("Accept-Encoding"); | |
| 63 | }); | |
| 64 | ||
| 65 | it("preserves existing Vary semantics", () => { | |
| 66 | const makeResponse = (vary: string) => | |
| 67 | compressHttpResponse( | |
| 68 | HttpServerResponse.text("x".repeat(2_000), { | |
| 69 | contentType: "application/json", | |
| 70 | headers: { vary }, | |
| 71 | }), | |
| 72 | undefined, | |
| 73 | ); | |
| 74 | ||
| 75 | expect(makeResponse("*").headers.vary).toBe("*"); | |
| 76 | expect(makeResponse("Origin, accept-encoding").headers.vary).toBe("Origin, accept-encoding"); | |
| 77 | }); | |
| 78 | }); |
apps/server/src/http.ts+72| ... | ... | @@ -12,8 +12,10 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | 14 | import * as Path from "effect/Path"; |
| 15 | import * as Stream from "effect/Stream"; | |
| 15 | 16 | import { cast } from "effect/Function"; |
| 16 | 17 | import { |
| 18 | Headers, | |
| 17 | 19 | HttpBody, |
| 18 | 20 | HttpClient, |
| 19 | 21 | HttpClientResponse, |
| ... | ... | @@ -42,6 +44,76 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht |
| 42 | 44 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 43 | 45 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 44 | 46 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; |
| 47 | const GZIP_MIN_BYTES = 1024; | |
| 48 | ||
| 49 | function acceptsGzip(value: string | undefined): boolean { | |
| 50 | if (!value) return false; | |
| 51 | ||
| 52 | const accepted = new Map( | |
| 53 | value.split(",").map((entry) => { | |
| 54 | const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";"); | |
| 55 | const quality = parameters | |
| 56 | .map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]) | |
| 57 | .find((parameter) => parameter !== undefined); | |
| 58 | return [coding, quality === undefined ? 1 : Number(quality)] as const; | |
| 59 | }), | |
| 60 | ); | |
| 61 | return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0; | |
| 62 | } | |
| 63 | ||
| 64 | function varyByAcceptEncoding(value: string | undefined): string { | |
| 65 | if (!value) return "Accept-Encoding"; | |
| 66 | const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase())); | |
| 67 | return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`; | |
| 68 | } | |
| 69 | ||
| 70 | export function compressHttpResponse( | |
| 71 | response: HttpServerResponse.HttpServerResponse, | |
| 72 | acceptEncoding: string | undefined, | |
| 73 | ): HttpServerResponse.HttpServerResponse { | |
| 74 | const body = response.body; | |
| 75 | if ( | |
| 76 | body._tag !== "Uint8Array" || | |
| 77 | body.contentLength < GZIP_MIN_BYTES || | |
| 78 | !body.contentType.startsWith("application/json") || | |
| 79 | response.headers["content-encoding"] | |
| 80 | ) { | |
| 81 | return response; | |
| 82 | } | |
| 83 | ||
| 84 | const variedResponse = HttpServerResponse.setHeader( | |
| 85 | response, | |
| 86 | "vary", | |
| 87 | varyByAcceptEncoding(response.headers.vary), | |
| 88 | ); | |
| 89 | if (!acceptsGzip(acceptEncoding)) return variedResponse; | |
| 90 | ||
| 91 | const compressedBody = Stream.fromReadableStream({ | |
| 92 | evaluate: () => new Response(body.body).body!.pipeThrough(new CompressionStream("gzip")), | |
| 93 | onError: (cause) => cause, | |
| 94 | }); | |
| 95 | const headers = Headers.set( | |
| 96 | Headers.remove(variedResponse.headers, "content-length"), | |
| 97 | "content-encoding", | |
| 98 | "gzip", | |
| 99 | ); | |
| 100 | return HttpServerResponse.stream(compressedBody, { | |
| 101 | status: response.status, | |
| 102 | statusText: response.statusText, | |
| 103 | headers, | |
| 104 | cookies: response.cookies, | |
| 105 | contentType: body.contentType, | |
| 106 | }); | |
| 107 | } | |
| 108 | ||
| 109 | export const httpCompressionLayer = HttpRouter.middleware( | |
| 110 | (httpEffect) => | |
| 111 | Effect.gen(function* () { | |
| 112 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 113 | return compressHttpResponse(yield* httpEffect, request.headers["accept-encoding"]); | |
| 114 | }), | |
| 115 | { global: true }, | |
| 116 | ); | |
| 45 | 117 | |
| 46 | 118 | export const browserApiCorsLayer = Layer.unwrap( |
| 47 | 119 | Effect.gen(function* () { |
apps/server/src/server.test.ts+29| ... | ... | @@ -1287,6 +1287,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1287 | 1287 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1288 | 1288 | ); |
| 1289 | 1289 | |
| 1290 | it.effect("compresses large JSON responses through the composed routes", () => | |
| 1291 | Effect.gen(function* () { | |
| 1292 | const descriptor = { | |
| 1293 | ...testEnvironmentDescriptor, | |
| 1294 | label: "Test environment".repeat(100), | |
| 1295 | }; | |
| 1296 | yield* buildAppUnderTest({ | |
| 1297 | layers: { | |
| 1298 | serverEnvironment: { | |
| 1299 | getDescriptor: Effect.succeed(descriptor), | |
| 1300 | }, | |
| 1301 | }, | |
| 1302 | }); | |
| 1303 | ||
| 1304 | const url = yield* getHttpServerUrl("/.well-known/t3/environment"); | |
| 1305 | const response = yield* fetchEffect(url, { | |
| 1306 | headers: { | |
| 1307 | "accept-encoding": "gzip", | |
| 1308 | }, | |
| 1309 | }); | |
| 1310 | const body = yield* responseJsonEffect<typeof descriptor>(response); | |
| 1311 | ||
| 1312 | assert.equal(response.status, 200); | |
| 1313 | assert.equal(response.headers["content-encoding"], "gzip"); | |
| 1314 | assert.equal(response.headers.vary, "Accept-Encoding"); | |
| 1315 | assert.deepEqual(body, descriptor); | |
| 1316 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 1317 | ); | |
| 1318 | ||
| 1290 | 1319 | it.effect("includes CORS headers on public environment descriptor responses", () => |
| 1291 | 1320 | Effect.gen(function* () { |
| 1292 | 1321 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+2| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | serverEnvironmentHttpApiLayer, |
| 12 | 12 | staticAndDevRouteLayer, |
| 13 | 13 | browserApiCorsLayer, |
| 14 | httpCompressionLayer, | |
| 14 | 15 | } from "./http.ts"; |
| 15 | 16 | import { fixPath } from "./os-jank.ts"; |
| 16 | 17 | import { websocketRpcRouteLayer } from "./ws.ts"; |
| ... | ... | @@ -372,6 +373,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 372 | 373 | Layer.provide(PreviewAutomationBroker.layer), |
| 373 | 374 | Layer.provide(ServerSelfUpdate.layer), |
| 374 | 375 | Layer.provide(browserApiCorsLayer), |
| 376 | Layer.provide(httpCompressionLayer), | |
| 375 | 377 | ); |
| 376 | 378 | |
| 377 | 379 | export const makeServerLayer = Layer.unwrap( |