| author | |
| committer | |
| log | 8650f05f95ac9f19254de679fdf4b380c37d7b94 |
| tree | ef0d651b35e0ac7f41b91e015044c4972e9187ee |
| parent | 8fe8f9a9c1486528f689a7499c2eb78217d35caf |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 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 | 2 | import { describe } from "vite-plus/test"; |
| 7 | 3 | |
| 8 | import { compressHttpResponse, isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 4 | import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts"; | |
| 9 | 5 | |
| 10 | 6 | describe("http dev routing", () => { |
| 11 | 7 | it("treats localhost and loopback addresses as local", () => { |
| ... | ... | @@ -30,49 +26,3 @@ describe("http dev routing", () => { |
| 30 | 26 | ); |
| 31 | 27 | }); |
| 32 | 28 | }); |
| 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+10-13| ... | ... | @@ -12,7 +12,6 @@ import * as FileSystem from "effect/FileSystem"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | 13 | import * as Option from "effect/Option"; |
| 14 | 14 | import * as Path from "effect/Path"; |
| 15 | import * as Stream from "effect/Stream"; | |
| 16 | 15 | import { cast } from "effect/Function"; |
| 17 | 16 | import { |
| 18 | 17 | Headers, |
| ... | ... | @@ -31,6 +30,7 @@ import * as ServerConfig from "./config.ts"; |
| 31 | 30 | import { ASSET_ROUTE_PREFIX, resolveAsset } from "./assets/AssetAccess.ts"; |
| 32 | 31 | import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; |
| 33 | 32 | import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; |
| 33 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; | |
| 34 | 34 | import { traceRelayRequest } from "./cloud/traceRelayRequest.ts"; |
| 35 | 35 | import { |
| 36 | 36 | annotateEnvironmentRequest, |
| ... | ... | @@ -67,10 +67,10 @@ function varyByAcceptEncoding(value: string | undefined): string { |
| 67 | 67 | return values.has("*") || values.has("accept-encoding") ? value : `${value}, Accept-Encoding`; |
| 68 | 68 | } |
| 69 | 69 | |
| 70 | export function compressHttpResponse( | |
| 70 | const compressHttpResponse = Effect.fnUntraced(function* ( | |
| 71 | 71 | response: HttpServerResponse.HttpServerResponse, |
| 72 | 72 | acceptEncoding: string | undefined, |
| 73 | ): HttpServerResponse.HttpServerResponse { | |
| 73 | ) { | |
| 74 | 74 | const body = response.body; |
| 75 | 75 | if ( |
| 76 | 76 | body._tag !== "Uint8Array" || |
| ... | ... | @@ -88,30 +88,27 @@ export function compressHttpResponse( |
| 88 | 88 | ); |
| 89 | 89 | if (!acceptsGzip(acceptEncoding)) return variedResponse; |
| 90 | 90 | |
| 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; | |
| 95 | 92 | const headers = Headers.set( |
| 96 | 93 | Headers.remove(variedResponse.headers, "content-length"), |
| 97 | 94 | "content-encoding", |
| 98 | 95 | "gzip", |
| 99 | 96 | ); |
| 100 | return HttpServerResponse.stream(compressedBody, { | |
| 97 | return compression.gzip(body.body, { | |
| 101 | 98 | status: response.status, |
| 102 | 99 | statusText: response.statusText, |
| 103 | 100 | headers, |
| 104 | 101 | cookies: response.cookies, |
| 105 | 102 | contentType: body.contentType, |
| 106 | 103 | }); |
| 107 | } | |
| 104 | }); | |
| 108 | 105 | |
| 109 | 106 | export const httpCompressionLayer = HttpRouter.middleware( |
| 110 | 107 | (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 | ), | |
| 115 | 112 | { global: true }, |
| 116 | 113 | ); |
| 117 | 114 |
apps/server/src/httpCompression/HttpResponseCompression.test.ts created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import * as NodeStream from "node:stream"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import * as HttpResponseCompression from "./HttpResponseCompression.ts"; | |
| 6 | ||
| 7 | it.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 | ||
| 19 | it.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 @@ |
| 1 | import * as Context from "effect/Context"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Layer from "effect/Layer"; | |
| 4 | import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; | |
| 5 | ||
| 6 | export 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 | ||
| 16 | export 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 | ||
| 30 | export 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 | 72 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 73 | 73 | |
| 74 | 74 | import * as ServerConfig from "./config.ts"; |
| 75 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; | |
| 75 | 76 | import { makeRoutesLayer } from "./server.ts"; |
| 76 | 77 | import { resolveAvailableEditorsForConfig } from "./ws.ts"; |
| 77 | 78 | import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; |
| ... | ... | @@ -815,6 +816,7 @@ const buildAppUnderTest = (options?: { |
| 815 | 816 | Layer.provideMerge(ServerSecretStore.layer), |
| 816 | 817 | Layer.provide(workspaceAndProjectServicesLayer), |
| 817 | 818 | Layer.provideMerge(FetchHttpClient.layer), |
| 819 | Layer.provide(HttpResponseCompression.layerNode), | |
| 818 | 820 | Layer.provide(layerConfig), |
| 819 | 821 | ); |
| 820 | 822 |
apps/server/src/server.ts+5| ... | ... | @@ -5,6 +5,7 @@ import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; |
| 5 | 5 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; |
| 6 | 6 | |
| 7 | 7 | import * as ServerConfig from "./config.ts"; |
| 8 | import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; | |
| 8 | 9 | import { |
| 9 | 10 | otlpTracesProxyRouteLayer, |
| 10 | 11 | assetRouteLayer, |
| ... | ... | @@ -151,6 +152,9 @@ const HttpServerLive = Layer.unwrap( |
| 151 | 152 | }), |
| 152 | 153 | ); |
| 153 | 154 | |
| 155 | const HttpResponseCompressionLive = | |
| 156 | typeof Bun !== "undefined" ? HttpResponseCompression.layerBun : HttpResponseCompression.layerNode; | |
| 157 | ||
| 154 | 158 | const PlatformServicesLive = Layer.unwrap( |
| 155 | 159 | Effect.gen(function* () { |
| 156 | 160 | if (typeof Bun !== "undefined") { |
| ... | ... | @@ -516,6 +520,7 @@ export const makeServerLayer = Layer.unwrap( |
| 516 | 520 | return serverApplicationLayer.pipe( |
| 517 | 521 | Layer.provideMerge(RuntimeServicesLive), |
| 518 | 522 | Layer.provideMerge(serverRelayBrokerTracingLayer), |
| 523 | Layer.provideMerge(HttpResponseCompressionLive), | |
| 519 | 524 | Layer.provideMerge(HttpServerLive), |
| 520 | 525 | Layer.provide(ObservabilityLive), |
| 521 | 526 | Layer.provideMerge(FetchHttpClient.layer), |