Blame Raw
Cohee · e3f41666 · · 131 lines (4.3 KB)
1 contributor
1import { gzip } from '/lib.js';
2
3/**
4 * @type {RequestCompressionConfig}
5 *
6 * @typedef {Object} RequestCompressionConfig
7 * @property {boolean} enabled Whether request compression is enabled.
8 * @property {number} minPayloadSize Minimum payload size in bytes to trigger compression.
9 * @property {number} maxPayloadSize Hard upper payload size limit for compression.
10 * @property {number} timeout Timeout for request compression in milliseconds.
11 */
12const requestCompressionConfig = {
13 enabled: false,
14 minPayloadSize: 0,
15 maxPayloadSize: 0,
16 timeout: 0,
17};
18
19/**
20 * Sets the configuration for request compression from the server.
21 * @param {RequestCompressionConfig} config Configuration object for request compression
22 */
23export function setRequestCompressionConfig(config) {
24 Object.assign(requestCompressionConfig, (config ?? {}));
25}
26
27/**
28 * Compresses a Uint8Array using gzip.
29 * @param {Uint8Array<ArrayBuffer>} input Uint8Array to compress
30 * @returns {{ promise: Promise<Uint8Array<ArrayBuffer>>, terminate: () => void }} Gzip-compressed Uint8Array promise and a terminate function.
31 */
32function gzipBuffer(input) {
33 let terminate = () => {};
34 const promise = new Promise((resolve, reject) => {
35 try {
36 terminate = gzip(input, (error, compressed) => {
37 if (error) {
38 reject(error);
39 return;
40 }
41
42 resolve(new Uint8Array(compressed));
43 });
44 } catch (error) {
45 reject(error);
46 }
47 });
48 return { promise, terminate };
49}
50
51/**
52 * Wraps a promise with a timeout, rejecting if the promise does not settle within the specified time.
53 * Note: timeout does not cancel the underlying compression task; it only stops waiting for it.
54 * @param {Promise<T>} promise Promise to wrap with a timeout
55 * @param {number} timeoutMs Timeout in milliseconds
56 * @param {string} label Used for error message if timeout occurs
57 * @returns {Promise<T>} Resolves with the original promise's value if it settles in time, otherwise rejects with a timeout error
58 * @template T Type of the promise's resolved value
59 */
60async function withTimeout(promise, timeoutMs, label) {
61 let timeoutId = null;
62 const timeoutPromise = new Promise((_, reject) => {
63 timeoutId = setTimeout(() => reject(new Error(`${label}_timeout`)), timeoutMs);
64 });
65
66 try {
67 return await Promise.race([promise, timeoutPromise]);
68 } finally {
69 if (timeoutId !== null) {
70 clearTimeout(timeoutId);
71 }
72 }
73}
74
75/**
76 * Compresses a fetch request using gzip when supported and worthwhile.
77 * Compression is skipped when feature-toggle is disabled, body is too small,
78 * body is not a string, or compression fails/timeouts.
79 *
80 * @param {RequestInit} request fetch request parameters
81 * @returns {Promise<RequestInit>} A request init object that may include gzip-compressed body
82 */
83export async function compressRequest(request) {
84 const plainRequest = { ...request };
85 const requestBody = plainRequest?.body;
86
87 if (!requestCompressionConfig.enabled) {
88 return plainRequest;
89 }
90
91 if (!requestBody || typeof requestBody !== 'string') {
92 return plainRequest;
93 }
94
95 const textEncoder = new TextEncoder();
96 const encodedBody = textEncoder.encode(requestBody);
97 const bodySize = encodedBody.byteLength;
98 const minBytes = Number(requestCompressionConfig.minPayloadSize) || 0;
99 const maxBytes = Number(requestCompressionConfig.maxPayloadSize) || 0;
100
101 if (bodySize < minBytes || (maxBytes > 0 && bodySize > maxBytes)) {
102 return plainRequest;
103 }
104
105 const { promise, terminate } = gzipBuffer(encodedBody);
106
107 try {
108 const compressedBody = await withTimeout(
109 promise,
110 requestCompressionConfig.timeout,
111 'compress_fflate_gzip',
112 );
113
114 if (!compressedBody || compressedBody.byteLength >= bodySize) {
115 return plainRequest;
116 }
117
118 const headers = new Headers(plainRequest.headers ?? {});
119 headers.set('Content-Encoding', 'gzip');
120
121 return {
122 ...plainRequest,
123 headers,
124 body: compressedBody,
125 };
126 } catch (error) {
127 terminate();
128 console.warn('Failed to compress request body, using plain request.', error);
129 return plainRequest;
130 }
131}