diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index a1d4230e75c..125c1d509f1 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -1,6 +1,11 @@ -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "@effect/vitest"; +import * as NodeZlib from "node:zlib"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; +import { HttpServerResponse } from "effect/unstable/http"; +import { describe } from "vite-plus/test"; -import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; +import { compressHttpResponse, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; describe("http dev routing", () => { it("treats localhost and loopback addresses as local", () => { @@ -25,3 +30,49 @@ describe("http dev routing", () => { ); }); }); + +describe("http compression", () => { + it.effect("gzips large JSON responses when the client accepts it", () => + Effect.gen(function* () { + const body = `{"value":"${"compressible".repeat(1_000)}"}`; + const response = compressHttpResponse( + HttpServerResponse.text(body, { contentType: "application/json" }), + "br, gzip, deflate", + ); + + expect(response.headers["content-encoding"]).toBe("gzip"); + expect(response.headers["content-length"]).toBeUndefined(); + expect(response.headers.vary).toBe("Accept-Encoding"); + expect(response.body._tag).toBe("Stream"); + if (response.body._tag === "Stream") { + const chunks = yield* Stream.runCollect(Stream.orDie(response.body.stream)); + expect(NodeZlib.gunzipSync(Buffer.concat(Array.from(chunks))).toString()).toBe(body); + } + }), + ); + + it("keeps the original body when gzip is declined", () => { + const response = compressHttpResponse( + HttpServerResponse.text("x".repeat(2_000), { contentType: "application/json" }), + "gzip;q=0, *;q=1", + ); + + expect(response.headers["content-encoding"]).toBeUndefined(); + expect(response.headers["content-length"]).toBe("2000"); + expect(response.headers.vary).toBe("Accept-Encoding"); + }); + + it("preserves existing Vary semantics", () => { + const makeResponse = (vary: string) => + compressHttpResponse( + HttpServerResponse.text("x".repeat(2_000), { + contentType: "application/json", + headers: { vary }, + }), + undefined, + ); + + expect(makeResponse("*").headers.vary).toBe("*"); + expect(makeResponse("Origin, accept-encoding").headers.vary).toBe("Origin, accept-encoding"); + }); +}); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 77eef955d55..c3070f54055 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -12,8 +12,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { + Headers, HttpBody, HttpClient, HttpClientResponse, @@ -42,6 +44,76 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"]; +const GZIP_MIN_BYTES = 1024; + +function acceptsGzip(value: string | undefined): boolean { + if (!value) return false; + + const accepted = new Map( + value.split(",").map((entry) => { + const [coding = "", ...parameters] = entry.trim().toLowerCase().split(";"); + const quality = parameters + .map((parameter) => parameter.trim().match(/^q=(.+)$/)?.[1]) + .find((parameter) => parameter !== undefined); + return [coding, quality === undefined ? 1 : Number(quality)] as const; + }), + ); + return (accepted.get("gzip") ?? accepted.get("*") ?? 0) > 0; +} + +function varyByAcceptEncoding(value: string | undefined): string { + if (!value) return "Accept-Encoding"; + const values = new Set(value.split(",").map((entry) => entry.trim().toLowerCase())); + return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`; +} + +export function compressHttpResponse( + response: HttpServerResponse.HttpServerResponse, + acceptEncoding: string | undefined, +): HttpServerResponse.HttpServerResponse { + const body = response.body; + if ( + body._tag !== "Uint8Array" || + body.contentLength < GZIP_MIN_BYTES || + !body.contentType.startsWith("application/json") || + response.headers["content-encoding"] + ) { + return response; + } + + const variedResponse = HttpServerResponse.setHeader( + response, + "vary", + varyByAcceptEncoding(response.headers.vary), + ); + if (!acceptsGzip(acceptEncoding)) return variedResponse; + + const compressedBody = Stream.fromReadableStream({ + evaluate: () => new Response(body.body).body!.pipeThrough(new CompressionStream("gzip")), + onError: (cause) => cause, + }); + const headers = Headers.set( + Headers.remove(variedResponse.headers, "content-length"), + "content-encoding", + "gzip", + ); + return HttpServerResponse.stream(compressedBody, { + status: response.status, + statusText: response.statusText, + headers, + cookies: response.cookies, + contentType: body.contentType, + }); +} + +export const httpCompressionLayer = HttpRouter.middleware( + (httpEffect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + return compressHttpResponse(yield* httpEffect, request.headers["accept-encoding"]); + }), + { global: true }, +); export const browserApiCorsLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index b255c939617..30e14770de5 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1287,6 +1287,35 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("compresses large JSON responses through the composed routes", () => + Effect.gen(function* () { + const descriptor = { + ...testEnvironmentDescriptor, + label: "Test environment".repeat(100), + }; + yield* buildAppUnderTest({ + layers: { + serverEnvironment: { + getDescriptor: Effect.succeed(descriptor), + }, + }, + }); + + const url = yield* getHttpServerUrl("/.well-known/t3/environment"); + const response = yield* fetchEffect(url, { + headers: { + "accept-encoding": "gzip", + }, + }); + const body = yield* responseJsonEffect(response); + + assert.equal(response.status, 200); + assert.equal(response.headers["content-encoding"], "gzip"); + assert.equal(response.headers.vary, "Accept-Encoding"); + assert.deepEqual(body, descriptor); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("includes CORS headers on public environment descriptor responses", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0412d910b8c..d78555cb30b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,7 @@ import { serverEnvironmentHttpApiLayer, staticAndDevRouteLayer, browserApiCorsLayer, + httpCompressionLayer, } from "./http.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; @@ -372,6 +373,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(browserApiCorsLayer), + Layer.provide(httpCompressionLayer), ); export const makeServerLayer = Layer.unwrap(