Skip to content

Commit 89b6d7f

Browse files
authoredMay 13, 2024··
miniflare: compress responses more like Cloudflare FL (#5798)
* chore: fix whitespace (use tabs within tempate literal strings) * fix: compress responses more like Cloudflare FL * add changeset * fix import order * nits * chore: refactor undici imports to use namespace * fix: remove Content-Encoding header in dispatchFetch since undici has already decompressed the response body * add test assertions + explanatory comments
1 parent 151bc3d commit 89b6d7f

File tree

6 files changed

+422
-276
lines changed

6 files changed

+422
-276
lines changed
 

‎.changeset/shy-plants-push.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"miniflare": patch
3+
---
4+
5+
fix: update miniflare's response compression to act more like Cloudflare platform

‎packages/miniflare/src/http/fetch.ts

+9-9
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
import http from "http";
22
import { IncomingRequestCfProperties } from "@cloudflare/workers-types/experimental";
3-
import { Dispatcher, Headers, fetch as baseFetch } from "undici";
3+
import * as undici from "undici";
44
import NodeWebSocket from "ws";
55
import { CoreHeaders, DeferredPromise } from "../workers";
66
import { Request, RequestInfo, RequestInit } from "./request";
77
import { Response } from "./response";
88
import { WebSocketPair, coupleWebSocket } from "./websocket";
99

1010
const ignored = ["transfer-encoding", "connection", "keep-alive", "expect"];
11-
function headersFromIncomingRequest(req: http.IncomingMessage): Headers {
11+
function headersFromIncomingRequest(req: http.IncomingMessage): undici.Headers {
1212
const entries = Object.entries(req.headers).filter(
1313
(pair): pair is [string, string | string[]] => {
1414
const [name, value] = pair;
1515
return !ignored.includes(name) && value !== undefined;
1616
}
1717
);
18-
return new Headers(Object.fromEntries(entries));
18+
return new undici.Headers(Object.fromEntries(entries));
1919
}
2020

2121
export async function fetch(
@@ -87,7 +87,7 @@ export async function fetch(
8787
return responsePromise;
8888
}
8989

90-
const response = await baseFetch(request, {
90+
const response = await undici.fetch(request, {
9191
dispatcher: requestInit?.dispatcher,
9292
});
9393
return new Response(response.body, response);
@@ -110,7 +110,7 @@ function addHeader(/* mut */ headers: AnyHeaders, key: string, value: string) {
110110
* `workerd` server is listening on. Handles cases where `fetch()` redirects to
111111
* same origin and different external origins.
112112
*/
113-
export class DispatchFetchDispatcher extends Dispatcher {
113+
export class DispatchFetchDispatcher extends undici.Dispatcher {
114114
private readonly cfBlobJson?: string;
115115

116116
/**
@@ -124,8 +124,8 @@ export class DispatchFetchDispatcher extends Dispatcher {
124124
* @param cfBlob `request.cf` blob override for runtime requests
125125
*/
126126
constructor(
127-
private readonly globalDispatcher: Dispatcher,
128-
private readonly runtimeDispatcher: Dispatcher,
127+
private readonly globalDispatcher: undici.Dispatcher,
128+
private readonly runtimeDispatcher: undici.Dispatcher,
129129
private readonly actualRuntimeOrigin: string,
130130
private readonly userRuntimeOrigin: string,
131131
cfBlob?: IncomingRequestCfProperties
@@ -149,8 +149,8 @@ export class DispatchFetchDispatcher extends Dispatcher {
149149
}
150150

151151
dispatch(
152-
/* mut */ options: Dispatcher.DispatchOptions,
153-
handler: Dispatcher.DispatchHandlers
152+
/* mut */ options: undici.Dispatcher.DispatchOptions,
153+
handler: undici.Dispatcher.DispatchHandlers
154154
): boolean {
155155
let origin = String(options.origin);
156156
// The first request in a redirect chain will always match the user origin

‎packages/miniflare/src/index.ts

+16-2
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import {
103103
parseWithRootPath,
104104
stripAnsi,
105105
} from "./shared";
106+
import { isCompressedByCloudflareFL } from "./shared/mime-types";
106107
import {
107108
CoreBindings,
108109
CoreHeaders,
@@ -546,9 +547,11 @@ const restrictedWebSocketUpgradeHeaders = [
546547
"sec-websocket-accept",
547548
];
548549

549-
export function _transformsForContentEncoding(encoding?: string): Transform[] {
550+
export function _transformsForContentEncodingAndContentType(encoding: string | undefined, type: string | undefined | null): Transform[] {
550551
const encoders: Transform[] = [];
551552
if (!encoding) return encoders;
553+
// if cloudflare's FL does not compress this mime-type, then don't compress locally either
554+
if (!isCompressedByCloudflareFL(type)) return encoders;
552555

553556
// Reverse of https://github.com/nodejs/undici/blob/48d9578f431cbbd6e74f77455ba92184f57096cf/lib/fetch/index.js#L1660
554557
const codings = encoding
@@ -587,7 +590,8 @@ async function writeResponse(response: Response, res: http.ServerResponse) {
587590
// If a `Content-Encoding` header is set, we'll need to encode the body
588591
// (likely only set by custom service bindings)
589592
const encoding = headers["content-encoding"]?.toString();
590-
const encoders = _transformsForContentEncoding(encoding);
593+
const type = headers["content-type"]?.toString();
594+
const encoders = _transformsForContentEncodingAndContentType(encoding, type);
591595
if (encoders.length > 0) {
592596
// `Content-Length` if set, will be wrong as it's for the decoded length
593597
delete headers["content-length"];
@@ -1586,6 +1590,16 @@ export class Miniflare {
15861590
throw reviveError(this.#workerSrcOpts, caught);
15871591
}
15881592

1593+
// At this point, undici.fetch (used inside fetch, above)
1594+
// has decompressed the response body but retained the Content-Encoding header.
1595+
// This can cause problems for client implementations which rely
1596+
// on the Content-Encoding header rather than trying to infer it from the body.
1597+
// Technically, at this point, this a malformed response so let's remove the header
1598+
// Retain it as MF-Content-Encoding so we can tell the body was actually compressed.
1599+
const contentEncoding = response.headers.get('Content-Encoding');
1600+
if (contentEncoding) response.headers.set('MF-Content-Encoding', contentEncoding);
1601+
response.headers.delete('Content-Encoding');
1602+
15891603
if (
15901604
process.env.MINIFLARE_ASSERT_BODIES_CONSUMED === "true" &&
15911605
response.body !== null
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
2+
export const compressedByCloudflareFL = new Set([
3+
// list copied from https://developers.cloudflare.com/speed/optimization/content/brotli/content-compression/#:~:text=If%20supported%20by%20visitors%E2%80%99%20web%20browsers%2C%20Cloudflare%20will%20return%20Gzip%20or%20Brotli%2Dencoded%20responses%20for%20the%20following%20content%20types%3A
4+
"text/html",
5+
"text/richtext",
6+
"text/plain",
7+
"text/css",
8+
"text/x-script",
9+
"text/x-component",
10+
"text/x-java-source",
11+
"text/x-markdown",
12+
"application/javascript",
13+
"application/x-javascript",
14+
"text/javascript",
15+
"text/js",
16+
"image/x-icon",
17+
"image/vnd.microsoft.icon",
18+
"application/x-perl",
19+
"application/x-httpd-cgi",
20+
"text/xml",
21+
"application/xml",
22+
"application/rss+xml",
23+
"application/vnd.api+json",
24+
"application/x-protobuf",
25+
"application/json",
26+
"multipart/bag",
27+
"multipart/mixed",
28+
"application/xhtml+xml",
29+
"font/ttf",
30+
"font/otf",
31+
"font/x-woff",
32+
"image/svg+xml",
33+
"application/vnd.ms-fontobject",
34+
"application/ttf",
35+
"application/x-ttf",
36+
"application/otf",
37+
"application/x-otf",
38+
"application/truetype",
39+
"application/opentype",
40+
"application/x-opentype",
41+
"application/font-woff",
42+
"application/eot",
43+
"application/font",
44+
"application/font-sfnt",
45+
"application/wasm",
46+
"application/javascript-binast",
47+
"application/manifest+json",
48+
"application/ld+json",
49+
"application/graphql+json",
50+
"application/geo+json",
51+
]);
52+
53+
export function isCompressedByCloudflareFL(contentTypeHeader: string | undefined | null) {
54+
if (!contentTypeHeader) return true; // Content-Type inferred as text/plain
55+
56+
const [contentType] = contentTypeHeader.split(';');
57+
58+
return compressedByCloudflareFL.has(contentType);
59+
}

‎packages/miniflare/src/workers/core/entry.worker.ts

+7
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
yellow,
1010
} from "kleur/colors";
1111
import { HttpError, LogLevel, SharedHeaders } from "miniflare:shared";
12+
import { isCompressedByCloudflareFL } from '../../shared/mime-types';
1213
import { CoreBindings, CoreHeaders } from "./constants";
1314
import { STATUS_CODES } from "./http";
1415
import { WorkerRoute, matchRoutes } from "./routing";
@@ -223,6 +224,12 @@ function ensureAcceptableEncoding(
223224
if (encodings.length === 0) return response;
224225

225226
const contentEncoding = response.headers.get("Content-Encoding");
227+
const contentType = response.headers.get("Content-Type");
228+
229+
// if cloudflare's FL does not compress this mime-type, then don't compress locally either
230+
if (!isCompressedByCloudflareFL(contentType)) {
231+
return response;
232+
}
226233

227234
// If `Content-Encoding` is defined, but unknown, return the response as is
228235
if (

‎packages/miniflare/test/index.spec.ts

+326-265
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.