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 @@
11import { 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";
62import { describe } from "vite-plus/test";
73
8import { compressHttpResponse, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
95
106describe("http dev routing", () => {
117 it("treats localhost and loopback addresses as local", () => {
......@@ -30,49 +26,3 @@ describe("http dev routing", () => {
3026 );
3127 });
3228});
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";
1212import * as Layer from "effect/Layer";
1313import * as Option from "effect/Option";
1414import * as Path from "effect/Path";
15import * as Stream from "effect/Stream";
1615import { cast } from "effect/Function";
1716import {
1817 Headers,
......@@ -31,6 +30,7 @@ import * as ServerConfig from "./config.ts";
3130import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts";
3231import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts";
3332import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
33import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
3434import { traceRelayRequest } from "./cloud/traceRelayRequest.ts";
3535import {
3636 annotateEnvironmentRequest,
......@@ -67,10 +67,10 @@ function varyByAcceptEncoding(value: string | undefined): string {
6767 return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`;
6868}
6969
70export function compressHttpResponse(
70const compressHttpResponse = Effect.fnUntraced(function* (
7171 response: HttpServerResponse.HttpServerResponse,
7272 acceptEncoding: string | undefined,
73): HttpServerResponse.HttpServerResponse {
73) {
7474 const body = response.body;
7575 if (
7676 body._tag !== "Uint8Array" ||
......@@ -88,30 +88,27 @@ export function compressHttpResponse(
8888 );
8989 if (!acceptsGzip(acceptEncoding)) return variedResponse;
9090
91 const compressedBody = Stream.fromReadableStream({
92 evaluate: () => new Response(body.body).body!.pipeThrough(new CompressionStream("gzip")),
93 onError: (cause) => cause,
94 });
91 const compression = yield* HttpResponseCompression.HttpResponseCompression;
9592 const headers = Headers.set(
9693 Headers.remove(variedResponse.headers, "content-length"),
9794 "content-encoding",
9895 "gzip",
9996 );
100 return HttpServerResponse.stream(compressedBody, {
97 return compression.gzip(body.body, {
10198 status: response.status,
10299 statusText: response.statusText,
103100 headers,
104101 cookies: response.cookies,
105102 contentType: body.contentType,
106103 });
107}
104});
108105
109106export const httpCompressionLayer = HttpRouter.middleware(
110107 (httpEffect) =>
111 Effect.gen(function* () {
112 const request = yield* HttpServerRequest.HttpServerRequest;
113 return compressHttpResponse(yield* httpEffect, request.headers["accept-encoding"]);
114 }),
108 Effect.flatMap(
109 Effect.all([httpEffect, HttpServerRequest.HttpServerRequest]),
110 ([response, request]) => compressHttpResponse(response, request.headers["accept-encoding"]),
111 ),
115112 { global: true },
116113);
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";
7272const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
7373
7474import * as ServerConfig from "./config.ts";
75import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
7576import { makeRoutesLayer } from "./server.ts";
7677import { resolveAvailableEditorsForConfig } from "./ws.ts";
7778import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
......@@ -815,6 +816,7 @@ const buildAppUnderTest = (options?: {
815816 Layer.provideMerge(ServerSecretStore.layer),
816817 Layer.provide(workspaceAndProjectServicesLayer),
817818 Layer.provideMerge(FetchHttpClient.layer),
819 Layer.provide(HttpResponseCompression.layerNode),
818820 Layer.provide(layerConfig),
819821 );
820822
apps/server/src/server.ts+5
......@@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http";
55import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
66
77import * as ServerConfig from "./config.ts";
8import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts";
89import {
910 otlpTracesProxyRouteLayer,
1011 assetRouteLayer,
......@@ -151,6 +152,9 @@ const HttpServerLive = Layer.unwrap(
151152 }),
152153);
153154
155const HttpResponseCompressionLive =
156 typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode;
157
154158const PlatformServicesLive = Layer.unwrap(
155159 Effect.gen(function* () {
156160 if (typeof Bun !== "undefined") {
......@@ -516,6 +520,7 @@ export const makeServerLayer = Layer.unwrap(
516520 return serverApplicationLayer.pipe(
517521 Layer.provideMerge(RuntimeServicesLive),
518522 Layer.provideMerge(serverRelayBrokerTracingLayer),
523 Layer.provideMerge(HttpResponseCompressionLive),
519524 Layer.provideMerge(HttpServerLive),
520525 Layer.provide(ObservabilityLive),
521526 Layer.provideMerge(FetchHttpClient.layer),