Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions apps/server/src/http.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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");
});
});
72 changes: 72 additions & 0 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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* () {
Expand Down
29 changes: 29 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof descriptor>(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();
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
serverEnvironmentHttpApiLayer,
staticAndDevRouteLayer,
browserApiCorsLayer,
httpCompressionLayer,
} from "./http.ts";
import { fixPath } from "./os-jank.ts";
import { websocketRpcRouteLayer } from "./ws.ts";
Expand Down Expand Up @@ -372,6 +373,7 @@ export const makeRoutesLayer = Layer.mergeAll(
Layer.provide(PreviewAutomationBroker.layer),
Layer.provide(ServerSelfUpdate.layer),
Layer.provide(browserApiCorsLayer),
Layer.provide(httpCompressionLayer),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

export const makeServerLayer = Layer.unwrap(
Expand Down
Loading