| 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,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 | describe("http dev routing", () => { | 10 | describe("http dev routing", () => { |
| 6 | it("treats localhost and loopback addresses as local", () => { | 11 | it("treats localhost and loopback addresses as local", () => { |
| ... | @@ -25,3 +30,49 @@ describe("http dev routing", () => { | ... | @@ -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,8 +12,10 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | 13 | import * as Option from "effect/Option"; |
| 14 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 15 | import * as Stream from "effect/Stream"; | ||
| 15 | import { cast } from "effect/Function"; | 16 | import { cast } from "effect/Function"; |
| 16 | import { | 17 | import { |
| 18 | Headers, | ||
| 17 | HttpBody, | 19 | HttpBody, |
| 18 | HttpClient, | 20 | HttpClient, |
| 19 | HttpClientResponse, | 21 | HttpClientResponse, |
| ... | @@ -42,6 +44,76 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht | ... | @@ -42,6 +44,76 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht |
| 42 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; | 44 | const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; |
| 43 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); | 45 | const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); |
| 44 | const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; | 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 | export const browserApiCorsLayer = Layer.unwrap( | 118 | export const browserApiCorsLayer = Layer.unwrap( |
| 47 | Effect.gen(function* () { | 119 | Effect.gen(function* () { |
apps/server/src/server.test.ts+29| ... | @@ -1287,6 +1287,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -1287,6 +1287,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1287 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 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 | it.effect("includes CORS headers on public environment descriptor responses", () => | 1319 | it.effect("includes CORS headers on public environment descriptor responses", () => |
| 1291 | Effect.gen(function* () { | 1320 | Effect.gen(function* () { |
| 1292 | yield* buildAppUnderTest(); | 1321 | yield* buildAppUnderTest(); |
apps/server/src/server.ts+2| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | serverEnvironmentHttpApiLayer, | 11 | serverEnvironmentHttpApiLayer, |
| 12 | staticAndDevRouteLayer, | 12 | staticAndDevRouteLayer, |
| 13 | browserApiCorsLayer, | 13 | browserApiCorsLayer, |
| 14 | httpCompressionLayer, | ||
| 14 | } from "./http.ts"; | 15 | } from "./http.ts"; |
| 15 | import { fixPath } from "./os-jank.ts"; | 16 | import { fixPath } from "./os-jank.ts"; |
| 16 | import { websocketRpcRouteLayer } from "./ws.ts"; | 17 | import { websocketRpcRouteLayer } from "./ws.ts"; |
| ... | @@ -372,6 +373,7 @@ export const makeRoutesLayer = Layer.mergeAll( | ... | @@ -372,6 +373,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 372 | Layer.provide(PreviewAutomationBroker.layer), | 373 | Layer.provide(PreviewAutomationBroker.layer), |
| 373 | Layer.provide(ServerSelfUpdate.layer), | 374 | Layer.provide(ServerSelfUpdate.layer), |
| 374 | Layer.provide(browserApiCorsLayer), | 375 | Layer.provide(browserApiCorsLayer), |
| 376 | Layer.provide(httpCompressionLayer), | ||
| 375 | ); | 377 | ); |
| 376 | 378 | ||
| 377 | export const makeServerLayer = Layer.unwrap( | 379 | export const makeServerLayer = Layer.unwrap( |