authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-29 02:03:39+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-29 00:03:39+00:00
log8650f05f95ac9f19254de679fdf4b380c37d7b94
treeef0d651b35e0ac7f41b91e015044c4972e9187ee
parent8fe8f9a9c1486528f689a7499c2eb78217d35caf
signature Signed by PGP key B5690EEEBB952194

refactor(server): use native HTTP compression streams (#4798)

Co-authored-by: codex <codex@users.noreply.github.com>

6 files changed, 83 insertions(+), 64 deletions(-)

apps/server/src/http.test.ts+1-51
...@@ -1,11 +1,7 @@...@@ -1,11 +1,7 @@
1import { expect, it } from "@effect/vitest";1import { expect, it } from "@effect/vitest";
2import * as NodeZlib from "node:zlib";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { HttpServerResponse } from "effect/unstable/http";
6import { describe } from "vite-plus/test";2import { describe } from "vite-plus/test";
73
8import { compressHttpResponse, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";4import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
95
10describe("http dev routing", () => {6describe("http dev routing", () => {
11 it("treats localhost and loopback addresses as local", () => {7 it("treats localhost and loopback addresses as local", () => {
...@@ -30,49 +26,3 @@ describe("http dev routing", () => {...@@ -30,49 +26,3 @@ describe("http dev routing", () => {
30 );26 );
31 });27 });
32});28});
33
34describe("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+10-13
...@@ -12,7 +12,6 @@ import * as FileSystem from "effect/FileSystem";...@@ -12,7 +12,6 @@ import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Path from "effect/Path";14import * as Path from "effect/Path";
15import * as Stream from "effect/Stream";
16import { cast } from "effect/Function";15import { cast } from "effect/Function";
17import {16import {
18 Headers,17 Headers,
...@@ -31,6 +30,7 @@ import * as ServerConfig from "./config.ts";...@@ -31,6 +30,7 @@ import * as ServerConfig from "./config.ts";
31import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";30import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
32import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";31import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
33import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";32import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
33import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
34import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";34import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
35import {35import {
36 annotateEnvironmentRequest,36 annotateEnvironmentRequest,
...@@ -67,10 +67,10 @@ function varyByAcceptEncoding(value: string | undefined): string {...@@ -67,10 +67,10 @@ function varyByAcceptEncoding(value: string | undefined): string {
67 return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;67 return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
68}68}
6969
70export function compressHttpResponse(70const compressHttpResponse = Effect.fnUntraced(function* (
71 response: HttpServerResponse.HttpServerResponse,71 response: HttpServerResponse.HttpServerResponse,
72 acceptEncoding: string | undefined,72 acceptEncoding: string | undefined,
73): HttpServerResponse.HttpServerResponse {73) {
74 const body = response.body;74 const body = response.body;
75 if (75 if (
76 body._tag !== "Uint8Array" ||76 body._tag !== "Uint8Array" ||
...@@ -88,30 +88,27 @@ export function compressHttpResponse(...@@ -88,30 +88,27 @@ export function compressHttpResponse(
88 );88 );
89 if (!acceptsGzip(acceptEncoding)) return variedResponse;89 if (!acceptsGzip(acceptEncoding)) return variedResponse;
9090
91 const compressedBody = Stream.fromReadableStream({91 const compression = yield* HttpResponseCompression.HttpResponseCompression;
92 evaluate: () => new Response(body.body).body!.pipeThrough(new CompressionStream("gzip")),
93 onError: (cause) => cause,
94 });
95 const headers = Headers.set(92 const headers = Headers.set(
96 Headers.remove(variedResponse.headers, "content-length"),93 Headers.remove(variedResponse.headers, "content-length"),
97 "content-encoding",94 "content-encoding",
98 "gzip",95 "gzip",
99 );96 );
100 return HttpServerResponse.stream(compressedBody, {97 return compression.gzip(body.body, {
101 status: response.status,98 status: response.status,
102 statusText: response.statusText,99 statusText: response.statusText,
103 headers,100 headers,
104 cookies: response.cookies,101 cookies: response.cookies,
105 contentType: body.contentType,102 contentType: body.contentType,
106 });103 });
107}104});
108105
109export const httpCompressionLayer = HttpRouter.middleware(106export const httpCompressionLayer = HttpRouter.middleware(
110 (httpEffect) =>107 (httpEffect) =>
111 Effect.gen(function* () {108 Effect.flatMap(
112 const request = yield* HttpServerRequest.HttpServerRequest;109 Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]),
113 return compressHttpResponse(yield* httpEffect, request.headers["accept-encoding"]);110 ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"]),
114 }),111 ),
115 { global: true },112 { global: true },
116);113);
117114
apps/server/src/httpCompression/HttpResponseCompression.test.ts created+29
...@@ -0,0 +1,29 @@
1import { expect, it } from "@effect/vitest";
2import * as NodeStream from "node:stream";
3import * as Effect from "effect/Effect";
4
5import * as HttpResponseCompression from "./HttpResponseCompression.ts";
6
7it.effect("uses a native Node stream", () =>
8 Effect.gen(function* () {
9 const compression = yield* HttpResponseCompression.HttpResponseCompression;
10 const response = compression.gzip(new Uint8Array(), {});
11
12 expect(response.body._tag).toBe("Raw");
13 if (response.body._tag === "Raw") {
14 expect(response.body.body).toBeInstanceOf(NodeStream.Readable);
15 }
16 }).pipe(Effect.provide(HttpResponseCompression.layerNode)),
17);
18
19it.effect("uses a native Web stream for Bun", () =>
20 Effect.gen(function* () {
21 const compression = yield* HttpResponseCompression.HttpResponseCompression;
22 const response = compression.gzip(new Uint8Array(), {});
23
24 expect(response.body._tag).toBe("Raw");
25 if (response.body._tag === "Raw") {
26 expect(response.body.body).toBeInstanceOf(ReadableStream);
27 }
28 }).pipe(Effect.provide(HttpResponseCompression.layerBun)),
29);
apps/server/src/httpCompression/HttpResponseCompression.ts created+36
...@@ -0,0 +1,36 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
5
6export class HttpResponseCompression extends Context.Service<
7 HttpResponseCompression,
8 {
9 readonly gzip: (
10 body: Uint8Array,
11 options: HttpServerResponse.Options,
12 ) => HttpServerResponse.HttpServerResponse;
13 }
14>()("t3/httpCompression/HttpResponseCompression") {}
15
16export const layerNode = Layer.effect(
17 HttpResponseCompression,
18 Effect.gen(function* () {
19 const NodeZlib = yield* Effect.promise(() => import("node:zlib"));
20 return {
21 gzip: (body, options) => {
22 const stream = NodeZlib.createGzip();
23 stream.end(body);
24 return HttpServerResponse.raw(stream, options);
25 },
26 };
27 }),
28);
29
30export const layerBun = Layer.succeed(HttpResponseCompression, {
31 gzip: (body, options) =>
32 HttpServerResponse.raw(
33 new Response(body).body!.pipeThrough(new CompressionStream("gzip")),
34 options,
35 ),
36});
apps/server/src/server.test.ts+2
...@@ -72,6 +72,7 @@ import { vi } from "vite-plus/test";...@@ -72,6 +72,7 @@ import { vi } from "vite-plus/test";
72const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");72const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7373
74import * as ServerConfig from "./config.ts";74import * as ServerConfig from "./config.ts";
75import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
75import { makeRoutesLayer } from "./server.ts";76import { makeRoutesLayer } from "./server.ts";
76import { resolveAvailableEditorsForConfig } from "./ws.ts";77import { resolveAvailableEditorsForConfig } from "./ws.ts";
77import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";78import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
...@@ -815,6 +816,7 @@ const buildAppUnderTest = (options?: {...@@ -815,6 +816,7 @@ const buildAppUnderTest = (options?: {
815 Layer.provideMerge(ServerSecretStore.layer),816 Layer.provideMerge(ServerSecretStore.layer),
816 Layer.provide(workspaceAndProjectServicesLayer),817 Layer.provide(workspaceAndProjectServicesLayer),
817 Layer.provideMerge(FetchHttpClient.layer),818 Layer.provideMerge(FetchHttpClient.layer),
819 Layer.provide(HttpResponseCompression.layerNode),
818 Layer.provide(layerConfig),820 Layer.provide(layerConfig),
819 );821 );
820822
apps/server/src/server.ts+5
...@@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";...@@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";5import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
7import * as ServerConfig from "./config.ts";7import * as ServerConfig from "./config.ts";
8import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
8import {9import {
9 otlpTracesProxyRouteLayer,10 otlpTracesProxyRouteLayer,
10 assetRouteLayer,11 assetRouteLayer,
...@@ -151,6 +152,9 @@ const HttpServerLive = Layer.unwrap(...@@ -151,6 +152,9 @@ const HttpServerLive = Layer.unwrap(
151 }),152 }),
152);153);
153154
155const HttpResponseCompressionLive =
156 typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode;
157
154const PlatformServicesLive = Layer.unwrap(158const PlatformServicesLive = Layer.unwrap(
155 Effect.gen(function* () {159 Effect.gen(function* () {
156 if (typeof Bun !== "undefined") {160 if (typeof Bun !== "undefined") {
...@@ -516,6 +520,7 @@ export const makeServerLayer = Layer.unwrap(...@@ -516,6 +520,7 @@ export const makeServerLayer = Layer.unwrap(
516 return serverApplicationLayer.pipe(520 return serverApplicationLayer.pipe(
517 Layer.provideMerge(RuntimeServicesLive),521 Layer.provideMerge(RuntimeServicesLive),
518 Layer.provideMerge(serverRelayBrokerTracingLayer),522 Layer.provideMerge(serverRelayBrokerTracingLayer),
523 Layer.provideMerge(HttpResponseCompressionLive),
519 Layer.provideMerge(HttpServerLive),524 Layer.provideMerge(HttpServerLive),
520 Layer.provide(ObservabilityLive),525 Layer.provide(ObservabilityLive),
521 Layer.provideMerge(FetchHttpClient.layer),526 Layer.provideMerge(FetchHttpClient.layer),