Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 58c22f4

Browse files
committed
Add encodeBody, encode gzip, deflate and br, closes #72
Responses with `Content-Type`s including `gzip`, `deflate` or `br` are now automatically encoded, unless `encodeBody` is set to `manual`.
1 parent 733344d commit 58c22f4

File tree

4 files changed

+157
-17
lines changed

4 files changed

+157
-17
lines changed

packages/core/src/standards/http.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -327,12 +327,14 @@ export function withImmutableHeaders(req: Request): Request {
327327
}
328328

329329
export interface ResponseInit extends BaseResponseInit {
330+
readonly encodeBody?: "auto" | "manual";
330331
readonly webSocket?: WebSocket;
331332
}
332333

333334
const kWaitUntil = Symbol("kWaitUntil");
334335

335336
const enumerableResponseKeys: (keyof Response)[] = [
337+
"encodeBody",
336338
"webSocket",
337339
"url",
338340
"redirected",
@@ -351,27 +353,31 @@ export class Response<
351353
return new Response(res.body, res);
352354
}
353355

356+
// https://developers.cloudflare.com/workers/runtime-apis/response#properties
357+
// noinspection TypeScriptFieldCanBeMadeReadonly
358+
#encodeBody: "auto" | "manual";
354359
// noinspection TypeScriptFieldCanBeMadeReadonly
355360
#status?: number;
356361
readonly #webSocket?: WebSocket;
357362
[kWaitUntil]?: Promise<WaitUntil>;
358363

359-
// TODO: add encodeBody: https://developers.cloudflare.com/workers/runtime-apis/response#properties
360-
361364
constructor(body?: BodyInit, init?: ResponseInit | Response | BaseResponse) {
365+
let encodeBody: string | undefined;
362366
let status: number | undefined;
363367
let webSocket: WebSocket | undefined;
364368
if (init instanceof BaseResponse && body === init.body) {
365369
// For cloning
366370
super(init);
367371
} else {
368372
if (init instanceof Response) {
373+
encodeBody = init.#encodeBody;
369374
// Don't pass our strange hybrid Response to undici
370375
init = init[kInner];
371-
} else if (!(init instanceof BaseResponse) /* ResponseInit */) {
376+
} else if (!(init instanceof BaseResponse) /* ResponseInit */ && init) {
377+
encodeBody = init.encodeBody;
372378
// Status 101 Switching Protocols would normally throw a RangeError, but we
373379
// need to allow it for WebSockets
374-
if (init?.webSocket) {
380+
if (init.webSocket) {
375381
if (init.status !== 101) {
376382
throw new RangeError(
377383
"Responses with a WebSocket must have status code 101."
@@ -384,6 +390,13 @@ export class Response<
384390
}
385391
super(new BaseResponse(body, init));
386392
}
393+
394+
encodeBody ??= "auto";
395+
if (encodeBody !== "auto" && encodeBody !== "manual") {
396+
throw new TypeError(`encodeBody: unexpected value: ${encodeBody}`);
397+
}
398+
this.#encodeBody = encodeBody;
399+
387400
this.#status = status;
388401
this.#webSocket = webSocket;
389402

@@ -399,13 +412,18 @@ export class Response<
399412
const clone = new Response(innerClone.body, innerClone);
400413
clone[kInputGated] = this[kInputGated];
401414
clone[kFormDataFiles] = this[kFormDataFiles];
415+
clone.#encodeBody = this.#encodeBody;
402416
// Technically don't need to copy status, as it should only be set for
403417
// WebSocket handshake responses
404418
clone.#status = this.#status;
405419
clone[kWaitUntil] = this[kWaitUntil];
406420
return clone;
407421
}
408422

423+
get encodeBody(): "auto" | "manual" {
424+
return this.#encodeBody;
425+
}
426+
409427
get webSocket(): WebSocket | undefined {
410428
return this.#webSocket;
411429
}

packages/core/test/standards/http.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,14 +447,20 @@ test("Response: can construct new Response from existing Response", async (t) =>
447447
test("Response: supports non-standard properties", (t) => {
448448
const pair = new WebSocketPair();
449449
const res = new Response(null, {
450+
encodeBody: "manual",
450451
status: 101,
451452
webSocket: pair["0"],
452453
headers: { "X-Key": "value" },
453454
});
455+
t.is(res.encodeBody, "manual");
454456
t.is(res.status, 101);
455457
t.is(res.webSocket, pair[0]);
456458
t.is(res.headers.get("X-Key"), "value");
457459
});
460+
test("Response: encodeBody defaults to auto", (t) => {
461+
const res = new Response(null);
462+
t.is(res.encodeBody, "auto");
463+
});
458464
test("Response: requires status 101 for WebSocket response", (t) => {
459465
const pair = new WebSocketPair();
460466
t.throws(() => new Response(null, { webSocket: pair["0"] }), {
@@ -470,16 +476,18 @@ test("Response: only allows status 101 for WebSocket response", (t) => {
470476
});
471477
});
472478
test("Response: clones non-standard properties", async (t) => {
473-
const res = new Response("body");
479+
const res = new Response("body", { encodeBody: "manual" });
474480
const waitUntil = [1, "2", true];
475481
withWaitUntil(res, Promise.resolve(waitUntil));
476482
t.is(await res.waitUntil(), waitUntil);
477483
const res2 = res.clone();
484+
t.is(res2.encodeBody, "manual");
478485
t.is(await res2.waitUntil(), waitUntil);
479486

480487
// Check prototype correct and clone still clones non-standard properties
481488
t.is(Object.getPrototypeOf(res2), Response.prototype);
482489
const res3 = res2.clone();
490+
t.is(res3.encodeBody, "manual");
483491
t.is(await res3.waitUntil(), waitUntil);
484492
t.is(await res.text(), "body");
485493
t.is(await res2.text(), "body");
@@ -533,6 +541,7 @@ test("Response: Object.keys() returns getters", async (t) => {
533541
"body",
534542
"bodyUsed",
535543
"headers",
544+
"encodeBody",
536545
"webSocket",
537546
"url",
538547
"redirected",

packages/http-server/src/index.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
import assert from "assert";
44
import http, { OutgoingHttpHeaders } from "http";
55
import https from "https";
6+
import { PassThrough, Transform } from "stream";
67
import { arrayBuffer } from "stream/consumers";
8+
import { pipeline } from "stream/promises";
79
import { URL } from "url";
10+
import zlib from "zlib";
811
import {
912
CorePluginSignatures,
1013
MiniflareCore,
@@ -76,7 +79,8 @@ export async function convertNodeRequest(
7679
// We're a bit naughty here mutating the incoming request, but this ensures
7780
// the headers are included in the pretty-error page. If we used the new
7881
// converted Request instance's headers, we wouldn't have connection, keep-
79-
// alive, etc as we strip those
82+
// alive, etc as we strip those. We need to take ownership of the request
83+
// anyway though, since we're consuming its body.
8084
req.headers["x-forwarded-proto"] ??= proto;
8185
req.headers["x-real-ip"] ??= ip;
8286
req.headers["cf-connecting-ip"] ??= ip;
@@ -162,19 +166,53 @@ export function createRequestListener<Plugins extends HTTPPluginSignatures>(
162166
waitUntil = response.waitUntil();
163167
status = response.status;
164168
const headers: OutgoingHttpHeaders = {};
165-
for (const [key, value] of response.headers) {
166-
if (key.length === 10 && key.toLowerCase() === "set-cookie") {
169+
// eslint-disable-next-line prefer-const
170+
for (let [key, value] of response.headers) {
171+
key = key.toLowerCase();
172+
if (key === "set-cookie") {
167173
// Multiple Set-Cookie headers should be treated as separate headers
168174
headers["set-cookie"] = response.headers.getAll("set-cookie");
169175
} else {
170176
headers[key] = value;
171177
}
172178
}
179+
180+
// If a Content-Encoding is set, and the user hasn't encoded the body,
181+
// we're responsible for doing so.
182+
const encoders: Transform[] = [];
183+
if (headers["content-encoding"] && response.encodeBody === "auto") {
184+
// Content-Length will be wrong as it's for the decoded length
185+
delete headers["content-length"];
186+
// Reverse of https://github.com/nodejs/undici/blob/48d9578f431cbbd6e74f77455ba92184f57096cf/lib/fetch/index.js#L1660
187+
const codings = headers["content-encoding"]
188+
.toString()
189+
.toLowerCase()
190+
.split(",")
191+
.map((x) => x.trim());
192+
for (const coding of codings) {
193+
if (/(x-)?gzip/.test(coding)) {
194+
encoders.push(zlib.createGzip());
195+
} else if (/(x-)?deflate/.test(coding)) {
196+
encoders.push(zlib.createDeflate());
197+
} else if (coding === "br") {
198+
encoders.push(zlib.createBrotliCompress());
199+
} else {
200+
// Unknown encoding, don't do any encoding at all
201+
mf.log.warn(
202+
`Unknown encoding \"${coding}\", sending plain response...`
203+
);
204+
delete headers["content-encoding"];
205+
encoders.length = 0;
206+
break;
207+
}
208+
}
209+
}
173210
res?.writeHead(status, headers);
174211

175212
// Add live reload script if enabled and this is an HTML response
176213
if (
177214
HTTPPlugin.liveReload &&
215+
response.encodeBody === "auto" &&
178216
response.headers
179217
.get("content-type")
180218
?.toLowerCase()
@@ -196,12 +234,18 @@ export function createRequestListener<Plugins extends HTTPPluginSignatures>(
196234
}
197235

198236
// Response body may be null if empty
199-
if (response.body) {
200-
for await (const chunk of response.body) {
201-
if (chunk) res?.write(chunk);
237+
if (res) {
238+
const passThrough = new PassThrough();
239+
// @ts-expect-error passThrough is definitely a PipelineSource
240+
const pipelinePromise = pipeline(passThrough, ...encoders, res);
241+
if (response.body) {
242+
for await (const chunk of response.body) {
243+
if (chunk) passThrough.write(chunk);
244+
}
202245
}
246+
passThrough.end();
247+
await pipelinePromise;
203248
}
204-
res?.end();
205249
} catch (e: any) {
206250
// MIME types aren't case sensitive
207251
const accept = req.headers.accept?.toLowerCase() ?? "";

packages/http-server/test/index.spec.ts

Lines changed: 74 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import http from "http";
44
import https from "https";
55
import { AddressInfo } from "net";
66
import { Readable } from "stream";
7+
import { buffer, text } from "stream/consumers";
78
import { setTimeout } from "timers/promises";
9+
import zlib from "zlib";
810
import {
911
BindingsPlugin,
1012
IncomingRequestCfProperties,
@@ -29,7 +31,7 @@ import {
2931
useTmp,
3032
} from "@miniflare/shared-test";
3133
import { MessageEvent, WebSocketPlugin } from "@miniflare/web-sockets";
32-
import test, { ExecutionContext } from "ava";
34+
import test, { ExecutionContext, Macro } from "ava";
3335
import StandardWebSocket from "ws";
3436

3537
function listen(
@@ -86,10 +88,9 @@ function request(
8688
headers,
8789
rejectUnauthorized: false,
8890
},
89-
(res) => {
90-
let body = "";
91-
res.on("data", (chunk) => (body += chunk));
92-
res.on("end", () => resolve([body, res.headers, res.statusCode ?? 0]));
91+
async (res) => {
92+
const body = await text(res);
93+
resolve([body, res.headers, res.statusCode ?? 0]);
9394
}
9495
);
9596
});
@@ -390,6 +391,74 @@ test("createRequestListener: includes CF-* headers in html error response", asyn
390391
t.regex(body, /CF-CONNECTING-IP/);
391392
});
392393

394+
const longText = "".padStart(1024, "x");
395+
const autoEncodeMacro: Macro<
396+
[encoding: string, decompress: (buffer: Buffer) => Buffer, encodes?: boolean]
397+
> = async (t, encoding, decompress, encodes = true) => {
398+
const mf = useMiniflareWithHandler(
399+
{ HTTPPlugin, BindingsPlugin },
400+
{ bindings: { longText, encoding } },
401+
(globals) => {
402+
return new globals.Response(globals.longText, {
403+
headers: { "Content-Encoding": globals.encoding },
404+
});
405+
}
406+
);
407+
const port = await listen(t, http.createServer(createRequestListener(mf)));
408+
return new Promise<void>((resolve) => {
409+
http.get({ port }, async (res) => {
410+
t.is(res.headers["content-length"], undefined);
411+
t.is(res.headers["transfer-encoding"], "chunked");
412+
t.is(res.headers["content-encoding"], encodes ? encoding : undefined);
413+
const compressed = await buffer(res);
414+
const decompressed = decompress(compressed);
415+
if (encodes) t.true(compressed.byteLength < decompressed.byteLength);
416+
t.is(decompressed.toString("utf8"), longText);
417+
resolve();
418+
});
419+
});
420+
};
421+
autoEncodeMacro.title = (providedTitle, encoding, decompress, encodes = true) =>
422+
`createRequestListener: ${
423+
encodes ? "auto-encodes" : "doesn't encode"
424+
} response with Content-Encoding: ${encoding}`;
425+
test(autoEncodeMacro, "gzip", (buffer) => zlib.gunzipSync(buffer));
426+
test(autoEncodeMacro, "deFlaTe", (buffer) => zlib.inflateSync(buffer));
427+
test(autoEncodeMacro, "br", (buffer) => zlib.brotliDecompressSync(buffer));
428+
test(autoEncodeMacro, "deflate, gZip", (buffer) =>
429+
zlib.inflateSync(zlib.gunzipSync(buffer))
430+
);
431+
// Should skip all encoding with single unknown encoding
432+
test(autoEncodeMacro, "deflate, unknown, gzip", (buffer) => buffer, false);
433+
test("createRequestListener: skips encoding already encoded data", async (t) => {
434+
const encoded = new Uint8Array(zlib.gzipSync(Buffer.from(longText, "utf8")));
435+
const mf = useMiniflareWithHandler(
436+
{ HTTPPlugin, BindingsPlugin },
437+
{ bindings: { encoded } },
438+
(globals) => {
439+
return new globals.Response(globals.encoded, {
440+
encodeBody: "manual",
441+
headers: {
442+
"Content-Length": globals.encoded.byteLength.toString(),
443+
"Content-Encoding": "gzip",
444+
},
445+
});
446+
}
447+
);
448+
const port = await listen(t, http.createServer(createRequestListener(mf)));
449+
return new Promise<void>((resolve) => {
450+
http.get({ port }, async (res) => {
451+
t.is(res.headers["content-length"], encoded.byteLength.toString());
452+
t.is(res.headers["content-encoding"], "gzip");
453+
const compressed = await buffer(res);
454+
const decompressed = zlib.gunzipSync(compressed);
455+
t.true(compressed.byteLength < decompressed.byteLength);
456+
t.is(decompressed.toString("utf8"), longText);
457+
resolve();
458+
});
459+
});
460+
});
461+
393462
test("createServer: handles regular requests", async (t) => {
394463
const mf = useMiniflareWithHandler({ HTTPPlugin }, {}, (globals) => {
395464
return new globals.Response("body");

0 commit comments

Comments
 (0)