Created
October 30, 2024 01:26
-
-
Save aabccd021/2c244a8700910255180207b415127903 to your computer and use it in GitHub Desktop.
Bun compression middleware
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import zlib from "node:zlib"; | |
import type { Transform } from "stream"; | |
import mimeDb from "mime-db"; | |
function getQValue(qValue: string | undefined): number { | |
if (qValue === undefined) { | |
return 1; | |
} | |
const [_key, value] = qValue.split("="); | |
if (value === undefined) { | |
return 1; | |
} | |
return parseFloat(value); | |
} | |
const defaultTransforms = { | |
gzip: (): Transform => zlib.createGzip(), | |
br: (): Transform => zlib.createBrotliCompress(), | |
deflate: (): Transform => zlib.createDeflate(), | |
} satisfies CompressionMethods; | |
const defaultCompression: Compression<typeof defaultTransforms> = { | |
transforms: defaultTransforms, | |
priority: ["deflate", "br", "gzip"], | |
}; | |
interface Encoding { | |
readonly name: string; | |
readonly qValue: number; | |
} | |
function parseEncoding(encoding: string): Encoding | undefined { | |
const [name, qValue] = encoding | |
.trim() | |
.split(";") | |
.map((s) => s.trim()); | |
if (name === undefined) { | |
return undefined; | |
} | |
return { name, qValue: getQValue(qValue) }; | |
} | |
function encodingByPriority( | |
compressionPriority: readonly string[], | |
a: Encoding, | |
b: Encoding, | |
): number { | |
const qDiff = b.qValue - a.qValue; | |
if (qDiff !== 0) { | |
return qDiff; | |
} | |
const bIndex = compressionPriority.indexOf(b.name); | |
const aIndex = compressionPriority.indexOf(a.name); | |
return bIndex - aIndex; | |
} | |
function buildCompressionPipe(transform: Transform): ReadableWritablePair { | |
const readable = new ReadableStream({ | |
start(controller: ReadableStreamDefaultController<Uint8Array>): void { | |
transform.on("data", (chunk: unknown) => { | |
if (!(chunk instanceof Uint8Array) && typeof chunk !== "undefined") { | |
throw new Error("Unexpected chunk type"); | |
} | |
controller.enqueue(chunk); | |
}); | |
transform.once("end", () => { | |
controller.close(); | |
}); | |
}, | |
}); | |
const writable = new WritableStream({ | |
write: (chunk: unknown): void => { | |
transform.write(chunk); | |
}, | |
close: (): void => { | |
transform.end(); | |
}, | |
}); | |
return { readable, writable }; | |
} | |
type RequestHandler = (req: Request) => Promise<Response>; | |
type CompressionMethods = Record<string, () => Transform>; | |
interface Compression<T extends CompressionMethods = CompressionMethods> { | |
readonly transforms: T; | |
// Righter value has higher priority (1) | |
// Lefter value has lower priority (0) | |
// Value not in the list will be considered as lowest priority (-1) | |
readonly priority: readonly (keyof T)[]; | |
} | |
type Middleware = (handle: RequestHandler) => RequestHandler; | |
function shouldTransform(cacheControl: string | null): boolean { | |
if (cacheControl === null) { | |
return true; | |
} | |
const noTransform = cacheControl | |
.split(",") | |
.some((directive) => directive.trim() === "no-transform"); | |
return !noTransform; | |
} | |
export function makeMiddleware(option: { | |
readonly compression?: Compression; | |
}): Middleware { | |
return (handle) => async (req) => { | |
const response = await handle(req); | |
if (response.body === null) { | |
return response; | |
} | |
const acceptEncs = req.headers.get("Accept-Encoding"); | |
if (acceptEncs === null) { | |
return response; | |
} | |
const mime = response.headers.get("Content-Type")?.split(";")[0]; | |
if (mime === undefined) { | |
return response; | |
} | |
// Don't compress for Cache-Control: no-transform | |
// https://tools.ietf.org/html/rfc7234#section-5.2.2.4 | |
const cacheControl = response.headers.get("Cache-Control"); | |
if (!shouldTransform(cacheControl)) { | |
return response; | |
} | |
// Make sure text/event-stream is not compressed | |
if (mimeDb[mime]?.compressible !== true) { | |
return response; | |
} | |
const alreadyCompressed = response.headers.get("Content-Encoding") !== null; | |
if (alreadyCompressed) { | |
return response; | |
} | |
const compressionTransforms: Record<string, () => Transform> = | |
option.compression?.transforms ?? defaultCompression.transforms; | |
const supportedEncs = Object.keys(compressionTransforms); | |
const compressionPriority = | |
option.compression?.priority ?? defaultCompression.priority; | |
const [preferredEncoding] = acceptEncs | |
.split(",") | |
.map(parseEncoding) | |
.filter((enc): enc is Encoding => enc !== undefined) | |
.filter((enc) => supportedEncs.includes(enc.name)) | |
.toSorted((a, b) => encodingByPriority(compressionPriority, a, b)); | |
if (preferredEncoding === undefined) { | |
return response; | |
} | |
const transform = compressionTransforms[preferredEncoding.name]; | |
if (transform === undefined) { | |
return response; | |
} | |
const compresionPipe = buildCompressionPipe(transform()); | |
const compressed = response.body.pipeThrough(compresionPipe); | |
const newResponse = new Response(compressed, response); | |
newResponse.headers.set("Content-Encoding", preferredEncoding.name); | |
newResponse.headers.set("Vary", "Accept-Encoding"); | |
return newResponse; | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment