@remix-run/response 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,7 @@ Basically, these are all the static response helpers we wish existed on the `Res
10
10
  - [**File Responses:**](#file-responses) Full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support
11
11
  - [**HTML Responses:**](#html-responses) Automatic DOCTYPE prepending and proper Content-Type headers
12
12
  - [**Redirect Responses:**](#redirect-responses) Simple redirect creation with customizable status codes
13
+ - [**Compress Responses:**](#compress-responses) Streaming compression based on Accept-Encoding header
13
14
 
14
15
  ## Installation
15
16
 
@@ -25,6 +26,7 @@ This package provides no default export. Instead, import the specific helper you
25
26
  import { createFileResponse } from '@remix-run/response/file'
26
27
  import { createHtmlResponse } from '@remix-run/response/html'
27
28
  import { createRedirectResponse } from '@remix-run/response/redirect'
29
+ import { compressResponse } from '@remix-run/response/compress'
28
30
  ```
29
31
 
30
32
  ### File Responses
@@ -163,12 +165,74 @@ let response = createRedirectResponse('/dashboard', {
163
165
  })
164
166
  ```
165
167
 
168
+ ### Compress Responses
169
+
170
+ The `compressResponse` helper compresses a `Response` based on the client's `Accept-Encoding` header:
171
+
172
+ ```ts
173
+ import { compressResponse } from '@remix-run/response/compress'
174
+
175
+ let response = new Response(JSON.stringify(data), {
176
+ headers: { 'Content-Type': 'application/json' },
177
+ })
178
+ let compressed = await compressResponse(response, request)
179
+ ```
180
+
181
+ Compression is automatically skipped for:
182
+
183
+ - Responses with no `Accept-Encoding` header
184
+ - Responses that are already compressed (existing `Content-Encoding`)
185
+ - Responses with `Cache-Control: no-transform`
186
+ - Responses with `Content-Length` below threshold (default: 1024 bytes)
187
+ - Responses with range support (`Accept-Ranges: bytes`)
188
+ - 206 Partial Content responses
189
+ - HEAD requests (only headers are modified)
190
+
191
+ #### Options
192
+
193
+ The `compressResponse` helper accepts options to customize compression behavior:
194
+
195
+ ```ts
196
+ await compressResponse(response, request, {
197
+ // Minimum size in bytes to compress (only enforced if Content-Length is present).
198
+ // Default: 1024
199
+ threshold: 1024,
200
+
201
+ // Which encodings the server supports for negotiation.
202
+ // Defaults to ['br', 'gzip', 'deflate']
203
+ encodings: ['br', 'gzip', 'deflate'],
204
+
205
+ // node:zlib options for gzip/deflate compression.
206
+ // For SSE responses (text/event-stream), flush: Z_SYNC_FLUSH
207
+ // is automatically applied unless you explicitly set a flush value.
208
+ // See: https://nodejs.org/api/zlib.html#class-options
209
+ zlib: {
210
+ level: 6,
211
+ },
212
+
213
+ // node:zlib options for Brotli compression.
214
+ // For SSE responses (text/event-stream), flush: BROTLI_OPERATION_FLUSH
215
+ // is automatically applied unless you explicitly set a flush value.
216
+ // See: https://nodejs.org/api/zlib.html#class-brotlioptions
217
+ brotli: {
218
+ params: {
219
+ [zlib.constants.BROTLI_PARAM_QUALITY]: 4,
220
+ },
221
+ },
222
+ })
223
+ ```
224
+
225
+ #### Range Requests and Compression
226
+
227
+ Range requests and compression are mutually exclusive. When `Accept-Ranges: bytes` is present in the response headers, `compressResponse` will not compress the response. This is why the `createFileResponse` helper enables ranges only for non-compressible MIME types by default - to allow text-based assets to be compressed while still supporting resumable downloads for media files.
228
+
166
229
  ## Related Packages
167
230
 
168
231
  - [`@remix-run/headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Type-safe HTTP header manipulation
169
232
  - [`@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template) - Safe HTML templating with automatic escaping
170
233
  - [`@remix-run/fs`](https://github.com/remix-run/remix/tree/main/packages/fs) - File system utilities including `openFile`
171
234
  - [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Build HTTP routers using the web fetch API
235
+ - [`@remix-run/mime`](https://github.com/remix-run/remix/tree/main/packages/mime) - MIME type utilities
172
236
 
173
237
  ## License
174
238
 
@@ -0,0 +1,2 @@
1
+ export { compressResponse, type CompressResponseOptions, type Encoding } from './lib/compress.ts';
2
+ //# sourceMappingURL=compress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../src/compress.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,KAAK,QAAQ,EAAE,MAAM,mBAAmB,CAAA"}
@@ -0,0 +1 @@
1
+ export { compressResponse } from "./lib/compress.js";
@@ -0,0 +1,68 @@
1
+ import { type BrotliCompress, type Gzip, type Deflate } from 'node:zlib';
2
+ import type { BrotliOptions, ZlibOptions } from 'node:zlib';
3
+ export type Encoding = 'br' | 'gzip' | 'deflate';
4
+ export interface CompressResponseOptions {
5
+ /**
6
+ * Minimum size in bytes to compress (only enforced if Content-Length is present).
7
+ * If Content-Length is absent, compression is applied regardless of this threshold.
8
+ *
9
+ * Default: 1024
10
+ */
11
+ threshold?: number;
12
+ /**
13
+ * Which encodings the server supports for negotiation in order of preference.
14
+ * Supported encodings: 'br', 'gzip', 'deflate'.
15
+ * Default: ['br', 'gzip', 'deflate']
16
+ */
17
+ encodings?: Encoding[];
18
+ /**
19
+ * node:zlib options for gzip/deflate compression.
20
+ *
21
+ * For SSE responses (text/event-stream), `flush: Z_SYNC_FLUSH` is automatically
22
+ * applied unless you explicitly set a flush value.
23
+ *
24
+ * See: https://nodejs.org/api/zlib.html#class-options
25
+ */
26
+ zlib?: ZlibOptions;
27
+ /**
28
+ * node:zlib options for Brotli compression.
29
+ *
30
+ * For SSE responses (text/event-stream), `flush: BROTLI_OPERATION_FLUSH` is
31
+ * automatically applied unless you explicitly set a flush value.
32
+ *
33
+ * See: https://nodejs.org/api/zlib.html#class-brotlioptions
34
+ */
35
+ brotli?: BrotliOptions;
36
+ }
37
+ /**
38
+ * Compresses a Response based on the client's Accept-Encoding header.
39
+ *
40
+ * Compression is skipped for:
41
+ * - Responses with no Accept-Encoding header (RFC 7231)
42
+ * - Empty responses
43
+ * - Already compressed responses
44
+ * - Responses with Content-Length below threshold (default: 1024 bytes)
45
+ * - Responses with Cache-Control: no-transform
46
+ * - Responses advertising range support (Accept-Ranges: bytes)
47
+ * - Partial content responses (206 status)
48
+ *
49
+ * When compressing, this function:
50
+ * - Sets Content-Encoding header
51
+ * - Removes Content-Length header
52
+ * - Sets Accept-Ranges to 'none'
53
+ * - Adds 'Accept-Encoding' to Vary header
54
+ * - Converts strong ETags to weak ETags (per RFC 7232)
55
+ *
56
+ * @param response The response to compress
57
+ * @param request The request (needed to check Accept-Encoding header)
58
+ * @param options Optional compression settings
59
+ * @returns A compressed Response or the original if no compression is suitable
60
+ */
61
+ export declare function compressResponse(response: Response, request: Request, options?: CompressResponseOptions): Promise<Response>;
62
+ /**
63
+ * Compresses a response stream that bridges node:zlib to Web Streams.
64
+ * Reads from the input stream, compresses chunks through the compressor,
65
+ * and returns a new ReadableStream with the compressed data.
66
+ */
67
+ export declare function compressStream(input: ReadableStream<Uint8Array>, compressor: Gzip | Deflate | BrotliCompress): ReadableStream<Uint8Array>;
68
+ //# sourceMappingURL=compress.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compress.d.ts","sourceRoot":"","sources":["../../src/lib/compress.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,OAAO,EACb,MAAM,WAAW,CAAA;AAClB,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAI3D,MAAM,MAAM,QAAQ,GAAG,IAAI,GAAG,MAAM,GAAG,SAAS,CAAA;AAGhD,MAAM,WAAW,uBAAuB;IACtC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAElB;;;;OAIG;IACH,SAAS,CAAC,EAAE,QAAQ,EAAE,CAAA;IAEtB;;;;;;;OAOG;IACH,IAAI,CAAC,EAAE,WAAW,CAAA;IAElB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,aAAa,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,gBAAgB,CACpC,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,OAAO,EAChB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC,QAAQ,CAAC,CAuDnB;AA8ED;;;;GAIG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,cAAc,CAAC,UAAU,CAAC,EACjC,UAAU,EAAE,IAAI,GAAG,OAAO,GAAG,cAAc,GAC1C,cAAc,CAAC,UAAU,CAAC,CA2F5B"}
@@ -0,0 +1,226 @@
1
+ import { constants, createBrotliCompress, createDeflate, createGzip, } from 'node:zlib';
2
+ import { AcceptEncoding, SuperHeaders } from '@remix-run/headers';
3
+ const defaultEncodings = ['br', 'gzip', 'deflate'];
4
+ /**
5
+ * Compresses a Response based on the client's Accept-Encoding header.
6
+ *
7
+ * Compression is skipped for:
8
+ * - Responses with no Accept-Encoding header (RFC 7231)
9
+ * - Empty responses
10
+ * - Already compressed responses
11
+ * - Responses with Content-Length below threshold (default: 1024 bytes)
12
+ * - Responses with Cache-Control: no-transform
13
+ * - Responses advertising range support (Accept-Ranges: bytes)
14
+ * - Partial content responses (206 status)
15
+ *
16
+ * When compressing, this function:
17
+ * - Sets Content-Encoding header
18
+ * - Removes Content-Length header
19
+ * - Sets Accept-Ranges to 'none'
20
+ * - Adds 'Accept-Encoding' to Vary header
21
+ * - Converts strong ETags to weak ETags (per RFC 7232)
22
+ *
23
+ * @param response The response to compress
24
+ * @param request The request (needed to check Accept-Encoding header)
25
+ * @param options Optional compression settings
26
+ * @returns A compressed Response or the original if no compression is suitable
27
+ */
28
+ export async function compressResponse(response, request, options) {
29
+ let compressOptions = options ?? {};
30
+ let supportedEncodings = compressOptions.encodings ?? defaultEncodings;
31
+ let threshold = compressOptions.threshold ?? 1024;
32
+ let acceptEncodingHeader = request.headers.get('Accept-Encoding');
33
+ let responseHeaders = new SuperHeaders(response.headers);
34
+ if (!acceptEncodingHeader ||
35
+ supportedEncodings.length === 0 ||
36
+ // Empty response
37
+ (request.method !== 'HEAD' && !response.body) ||
38
+ // Already compressed
39
+ responseHeaders.contentEncoding != null ||
40
+ // Content-Length below threshold
41
+ (responseHeaders.contentLength != null && responseHeaders.contentLength < threshold) ||
42
+ // Cache-Control: no-transform
43
+ responseHeaders.cacheControl.noTransform ||
44
+ // Response advertising range support
45
+ responseHeaders.acceptRanges === 'bytes' ||
46
+ // Partial content responses
47
+ response.status === 206) {
48
+ return response;
49
+ }
50
+ let acceptEncoding = new AcceptEncoding(acceptEncodingHeader);
51
+ let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings);
52
+ if (selectedEncoding === null) {
53
+ // Client has explicitly rejected all supported encodings, including 'identity'
54
+ return new Response(`Only ${[supportedEncodings, 'identity'].map((encoding) => `'${encoding}'`).join(', ')} encodings are supported`, {
55
+ status: 406,
56
+ statusText: 'Not Acceptable',
57
+ });
58
+ }
59
+ if (selectedEncoding === 'identity') {
60
+ return response;
61
+ }
62
+ // For HEAD requests, set compression headers without actually compressing
63
+ if (request.method === 'HEAD') {
64
+ setCompressionHeaders(responseHeaders, selectedEncoding);
65
+ return new Response(null, {
66
+ status: response.status,
67
+ statusText: response.statusText,
68
+ headers: responseHeaders,
69
+ });
70
+ }
71
+ return applyCompression(response, responseHeaders, selectedEncoding, compressOptions);
72
+ }
73
+ function negotiateEncoding(acceptEncoding, supportedEncodings) {
74
+ if (acceptEncoding.encodings.length === 0) {
75
+ return 'identity';
76
+ }
77
+ let preferred = acceptEncoding.getPreferred(supportedEncodings);
78
+ if (!preferred) {
79
+ // Clients can explicitly reject 'identity' by setting its weight to 0,
80
+ // otherwise it is considered an acceptable fallback.
81
+ return acceptEncoding.getWeight('identity') === 0 ? null : 'identity';
82
+ }
83
+ return preferred;
84
+ }
85
+ function setCompressionHeaders(headers, encoding) {
86
+ headers.contentEncoding = encoding;
87
+ headers.acceptRanges = 'none';
88
+ headers.contentLength = null;
89
+ headers.vary.add('Accept-Encoding');
90
+ // Convert strong ETags to weak since compressed representation is byte-different
91
+ if (headers.etag && !headers.etag.startsWith('W/')) {
92
+ headers.etag = `W/${headers.etag}`;
93
+ }
94
+ }
95
+ const zlibFlushOptions = {
96
+ flush: constants.Z_SYNC_FLUSH,
97
+ };
98
+ const brotliFlushOptions = {
99
+ flush: constants.BROTLI_OPERATION_FLUSH,
100
+ };
101
+ function applyCompression(response, responseHeaders, encoding, options) {
102
+ if (!response.body) {
103
+ return response;
104
+ }
105
+ // Detect SSE for automatic flush configuration
106
+ let contentType = response.headers.get('Content-Type');
107
+ let mediaType = contentType?.split(';')[0].trim();
108
+ let isSSE = mediaType === 'text/event-stream';
109
+ let compressor = createCompressor(encoding, {
110
+ ...options,
111
+ // Apply SSE flush defaults if not explicitly set
112
+ brotli: {
113
+ ...options.brotli,
114
+ ...(isSSE && options.brotli?.flush === undefined ? brotliFlushOptions : null),
115
+ },
116
+ zlib: {
117
+ ...options.zlib,
118
+ ...(isSSE && options.zlib?.flush === undefined ? zlibFlushOptions : null),
119
+ },
120
+ });
121
+ setCompressionHeaders(responseHeaders, encoding);
122
+ return new Response(compressStream(response.body, compressor), {
123
+ status: response.status,
124
+ statusText: response.statusText,
125
+ headers: responseHeaders,
126
+ });
127
+ }
128
+ /**
129
+ * Compresses a response stream that bridges node:zlib to Web Streams.
130
+ * Reads from the input stream, compresses chunks through the compressor,
131
+ * and returns a new ReadableStream with the compressed data.
132
+ */
133
+ export function compressStream(input, compressor) {
134
+ let reader = null;
135
+ let cancelled = false;
136
+ let errored = false;
137
+ return new ReadableStream({
138
+ async start(controller) {
139
+ reader = input.getReader();
140
+ compressor.on('data', (chunk) => {
141
+ if (!cancelled && !errored) {
142
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
143
+ }
144
+ });
145
+ compressor.on('end', () => {
146
+ if (!cancelled && !errored) {
147
+ controller.close();
148
+ }
149
+ });
150
+ compressor.on('error', (error) => {
151
+ // Ignore duplicate error events
152
+ if (errored) {
153
+ return;
154
+ }
155
+ errored = true;
156
+ if (!cancelled) {
157
+ controller.error(error);
158
+ }
159
+ });
160
+ try {
161
+ while (true) {
162
+ if (cancelled || errored) {
163
+ break;
164
+ }
165
+ let { done, value } = await reader.read();
166
+ if (cancelled || errored) {
167
+ break;
168
+ }
169
+ if (done) {
170
+ compressor.end();
171
+ break;
172
+ }
173
+ if (!value) {
174
+ continue;
175
+ }
176
+ await new Promise((resolve, reject) => {
177
+ let resolvedImmediately = false;
178
+ let canContinue = compressor.write(Buffer.from(value), (error) => {
179
+ if (resolvedImmediately) {
180
+ return;
181
+ }
182
+ if (error) {
183
+ reject(error);
184
+ }
185
+ else {
186
+ resolve();
187
+ }
188
+ });
189
+ if (canContinue) {
190
+ resolvedImmediately = true;
191
+ resolve();
192
+ }
193
+ });
194
+ }
195
+ }
196
+ catch (error) {
197
+ errored = true;
198
+ compressor.destroy(error);
199
+ if (!cancelled) {
200
+ controller.error(error);
201
+ }
202
+ }
203
+ finally {
204
+ reader.releaseLock();
205
+ }
206
+ },
207
+ async cancel(reason) {
208
+ cancelled = true;
209
+ // Destroy compressor first to unblock any pending write operations
210
+ compressor.destroy();
211
+ await reader?.cancel(reason);
212
+ },
213
+ });
214
+ }
215
+ function createCompressor(encoding, options) {
216
+ switch (encoding) {
217
+ case 'br':
218
+ return createBrotliCompress(options.brotli);
219
+ case 'gzip':
220
+ return createGzip(options.zlib);
221
+ case 'deflate':
222
+ return createDeflate(options.zlib);
223
+ default:
224
+ throw new Error(`Unsupported encoding: ${encoding}`);
225
+ }
226
+ }
@@ -11,6 +11,9 @@
11
11
  * }
12
12
  */
13
13
  export type FileDigestFunction = (file: File) => Promise<string>;
14
+ /**
15
+ * Options for creating a file response.
16
+ */
14
17
  export interface FileResponseOptions {
15
18
  /**
16
19
  * Cache-Control header value. If not provided, no Cache-Control header will be set.
@@ -56,7 +59,13 @@ export interface FileResponseOptions {
56
59
  * When enabled, includes `Accept-Ranges` header and handles `Range` requests
57
60
  * with 206 Partial Content responses.
58
61
  *
59
- * @default true
62
+ * Defaults to enabling ranges only for non-compressible MIME types,
63
+ * as defined by `isCompressibleMimeType()` from `@remix-run/mime`.
64
+ *
65
+ * Note: Range requests and compression are mutually exclusive. When
66
+ * `Accept-Ranges: bytes` is present in the response headers, the compression
67
+ * middleware will not compress the response. This is why the default behavior
68
+ * enables ranges only for non-compressible types.
60
69
  */
61
70
  acceptRanges?: boolean;
62
71
  }
@@ -66,7 +75,7 @@ export interface FileResponseOptions {
66
75
  *
67
76
  * @param file The file to send
68
77
  * @param request The request object
69
- * @param options (optional) configuration options
78
+ * @param options Configuration options
70
79
  * @returns A `Response` object containing the file
71
80
  *
72
81
  * @example
@@ -1 +1 @@
1
- {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/lib/file.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;AAEhE,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAAA;IACjD;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,QAAQ,CAAC,CA0KnB"}
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/lib/file.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;AAEhE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAAA;IACjD;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,QAAQ,CAAC,CA+KnB"}
package/dist/lib/file.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import SuperHeaders from '@remix-run/headers';
2
+ import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime';
2
3
  /**
3
4
  * Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
4
5
  * with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
5
6
  *
6
7
  * @param file The file to send
7
8
  * @param request The request object
8
- * @param options (optional) configuration options
9
+ * @param options Configuration options
9
10
  * @returns A `Response` object containing the file
10
11
  *
11
12
  * @example
@@ -16,9 +17,9 @@ import SuperHeaders from '@remix-run/headers';
16
17
  * })
17
18
  */
18
19
  export async function createFileResponse(file, request, options = {}) {
19
- let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = options;
20
+ let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesOption, } = options;
20
21
  let headers = new SuperHeaders(request.headers);
21
- let contentType = file.type;
22
+ let contentType = mimeTypeToContentType(file.type);
22
23
  let contentLength = file.size;
23
24
  let etag;
24
25
  if (etagStrategy === 'weak') {
@@ -32,6 +33,9 @@ export async function createFileResponse(file, request, options = {}) {
32
33
  if (lastModifiedEnabled) {
33
34
  lastModified = file.lastModified;
34
35
  }
36
+ // Determine if we should accept ranges
37
+ // Default: enable ranges only for non-compressible MIME types
38
+ let acceptRangesEnabled = acceptRangesOption !== undefined ? acceptRangesOption : !isCompressibleMimeType(contentType);
35
39
  let acceptRanges;
36
40
  if (acceptRangesEnabled) {
37
41
  acceptRanges = 'bytes';
@@ -4,9 +4,9 @@ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Ar
4
4
  * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
5
5
  * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
6
6
  *
7
- * @param body The body of the response.
8
- * @param init (optional) The `ResponseInit` object for the response.
9
- * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
7
+ * @param body The body of the response
8
+ * @param init The `ResponseInit` object for the response
9
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header
10
10
  */
11
11
  export declare function createHtmlResponse(body: HtmlBody, init?: ResponseInit): Response;
12
12
  export {};
package/dist/lib/html.js CHANGED
@@ -4,9 +4,9 @@ const DOCTYPE = '<!DOCTYPE html>';
4
4
  * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
5
5
  * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
6
6
  *
7
- * @param body The body of the response.
8
- * @param init (optional) The `ResponseInit` object for the response.
9
- * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
7
+ * @param body The body of the response
8
+ * @param init The `ResponseInit` object for the response
9
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header
10
10
  */
11
11
  export function createHtmlResponse(body, init) {
12
12
  let payload = ensureDoctype(body);
@@ -2,7 +2,7 @@
2
2
  * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
3
  *
4
4
  * @param location The location to redirect to
5
- * @param init (optional) The `ResponseInit` object for the response, or a status code
5
+ * @param init The `ResponseInit` object for the response, or a status code
6
6
  * @returns A `Response` object with a redirect header
7
7
  */
8
8
  export declare function createRedirectResponse(location: string | URL, init?: ResponseInit | number): Response;
@@ -2,7 +2,7 @@
2
2
  * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
3
  *
4
4
  * @param location The location to redirect to
5
- * @param init (optional) The `ResponseInit` object for the response, or a status code
5
+ * @param init The `ResponseInit` object for the response, or a status code
6
6
  * @returns A `Response` object with a redirect header
7
7
  */
8
8
  export function createRedirectResponse(location, init) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remix-run/response",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Response helpers for the web Fetch API",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
@@ -19,6 +19,10 @@
19
19
  ],
20
20
  "type": "module",
21
21
  "exports": {
22
+ "./compress": {
23
+ "types": "./dist/compress.d.ts",
24
+ "default": "./dist/compress.js"
25
+ },
22
26
  "./file": {
23
27
  "types": "./dist/file.d.ts",
24
28
  "default": "./dist/file.js"
@@ -35,11 +39,13 @@
35
39
  },
36
40
  "devDependencies": {
37
41
  "@types/node": "^24.6.0",
38
- "typescript": "^5.9.3"
42
+ "@typescript/native-preview": "7.0.0-dev.20251125.1",
43
+ "@remix-run/mime": "0.2.0"
39
44
  },
40
45
  "peerDependencies": {
41
- "@remix-run/html-template": "^0.3.0",
42
- "@remix-run/headers": "^0.17.1"
46
+ "@remix-run/headers": "^0.18.0",
47
+ "@remix-run/mime": "^0.2.0",
48
+ "@remix-run/html-template": "^0.3.0"
43
49
  },
44
50
  "keywords": [
45
51
  "fetch",
@@ -50,9 +56,9 @@
50
56
  "redirect"
51
57
  ],
52
58
  "scripts": {
53
- "build": "tsc -p tsconfig.build.json",
59
+ "build": "tsgo -p tsconfig.build.json",
54
60
  "clean": "git clean -fdX",
55
- "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
56
- "typecheck": "tsc --noEmit"
61
+ "test": "node --disable-warning=ExperimentalWarning --test",
62
+ "typecheck": "tsgo --noEmit"
57
63
  }
58
64
  }
@@ -0,0 +1 @@
1
+ export { compressResponse, type CompressResponseOptions, type Encoding } from './lib/compress.ts'
@@ -0,0 +1,330 @@
1
+ import {
2
+ constants,
3
+ createBrotliCompress,
4
+ createDeflate,
5
+ createGzip,
6
+ type BrotliCompress,
7
+ type Gzip,
8
+ type Deflate,
9
+ } from 'node:zlib'
10
+ import type { BrotliOptions, ZlibOptions } from 'node:zlib'
11
+
12
+ import { AcceptEncoding, SuperHeaders } from '@remix-run/headers'
13
+
14
+ export type Encoding = 'br' | 'gzip' | 'deflate'
15
+ const defaultEncodings: Encoding[] = ['br', 'gzip', 'deflate']
16
+
17
+ export interface CompressResponseOptions {
18
+ /**
19
+ * Minimum size in bytes to compress (only enforced if Content-Length is present).
20
+ * If Content-Length is absent, compression is applied regardless of this threshold.
21
+ *
22
+ * Default: 1024
23
+ */
24
+ threshold?: number
25
+
26
+ /**
27
+ * Which encodings the server supports for negotiation in order of preference.
28
+ * Supported encodings: 'br', 'gzip', 'deflate'.
29
+ * Default: ['br', 'gzip', 'deflate']
30
+ */
31
+ encodings?: Encoding[]
32
+
33
+ /**
34
+ * node:zlib options for gzip/deflate compression.
35
+ *
36
+ * For SSE responses (text/event-stream), `flush: Z_SYNC_FLUSH` is automatically
37
+ * applied unless you explicitly set a flush value.
38
+ *
39
+ * See: https://nodejs.org/api/zlib.html#class-options
40
+ */
41
+ zlib?: ZlibOptions
42
+
43
+ /**
44
+ * node:zlib options for Brotli compression.
45
+ *
46
+ * For SSE responses (text/event-stream), `flush: BROTLI_OPERATION_FLUSH` is
47
+ * automatically applied unless you explicitly set a flush value.
48
+ *
49
+ * See: https://nodejs.org/api/zlib.html#class-brotlioptions
50
+ */
51
+ brotli?: BrotliOptions
52
+ }
53
+
54
+ /**
55
+ * Compresses a Response based on the client's Accept-Encoding header.
56
+ *
57
+ * Compression is skipped for:
58
+ * - Responses with no Accept-Encoding header (RFC 7231)
59
+ * - Empty responses
60
+ * - Already compressed responses
61
+ * - Responses with Content-Length below threshold (default: 1024 bytes)
62
+ * - Responses with Cache-Control: no-transform
63
+ * - Responses advertising range support (Accept-Ranges: bytes)
64
+ * - Partial content responses (206 status)
65
+ *
66
+ * When compressing, this function:
67
+ * - Sets Content-Encoding header
68
+ * - Removes Content-Length header
69
+ * - Sets Accept-Ranges to 'none'
70
+ * - Adds 'Accept-Encoding' to Vary header
71
+ * - Converts strong ETags to weak ETags (per RFC 7232)
72
+ *
73
+ * @param response The response to compress
74
+ * @param request The request (needed to check Accept-Encoding header)
75
+ * @param options Optional compression settings
76
+ * @returns A compressed Response or the original if no compression is suitable
77
+ */
78
+ export async function compressResponse(
79
+ response: Response,
80
+ request: Request,
81
+ options?: CompressResponseOptions,
82
+ ): Promise<Response> {
83
+ let compressOptions = options ?? {}
84
+ let supportedEncodings = compressOptions.encodings ?? defaultEncodings
85
+ let threshold = compressOptions.threshold ?? 1024
86
+ let acceptEncodingHeader = request.headers.get('Accept-Encoding')
87
+ let responseHeaders = new SuperHeaders(response.headers)
88
+
89
+ if (
90
+ !acceptEncodingHeader ||
91
+ supportedEncodings.length === 0 ||
92
+ // Empty response
93
+ (request.method !== 'HEAD' && !response.body) ||
94
+ // Already compressed
95
+ responseHeaders.contentEncoding != null ||
96
+ // Content-Length below threshold
97
+ (responseHeaders.contentLength != null && responseHeaders.contentLength < threshold) ||
98
+ // Cache-Control: no-transform
99
+ responseHeaders.cacheControl.noTransform ||
100
+ // Response advertising range support
101
+ responseHeaders.acceptRanges === 'bytes' ||
102
+ // Partial content responses
103
+ response.status === 206
104
+ ) {
105
+ return response
106
+ }
107
+
108
+ let acceptEncoding = new AcceptEncoding(acceptEncodingHeader)
109
+ let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings)
110
+ if (selectedEncoding === null) {
111
+ // Client has explicitly rejected all supported encodings, including 'identity'
112
+ return new Response(
113
+ `Only ${[supportedEncodings, 'identity'].map((encoding) => `'${encoding}'`).join(', ')} encodings are supported`,
114
+ {
115
+ status: 406,
116
+ statusText: 'Not Acceptable',
117
+ },
118
+ )
119
+ }
120
+
121
+ if (selectedEncoding === 'identity') {
122
+ return response
123
+ }
124
+
125
+ // For HEAD requests, set compression headers without actually compressing
126
+ if (request.method === 'HEAD') {
127
+ setCompressionHeaders(responseHeaders, selectedEncoding)
128
+
129
+ return new Response(null, {
130
+ status: response.status,
131
+ statusText: response.statusText,
132
+ headers: responseHeaders,
133
+ })
134
+ }
135
+
136
+ return applyCompression(response, responseHeaders, selectedEncoding, compressOptions)
137
+ }
138
+
139
+ function negotiateEncoding(
140
+ acceptEncoding: AcceptEncoding,
141
+ supportedEncodings: readonly Encoding[],
142
+ ): Encoding | 'identity' | null {
143
+ if (acceptEncoding.encodings.length === 0) {
144
+ return 'identity'
145
+ }
146
+
147
+ let preferred = acceptEncoding.getPreferred(supportedEncodings)
148
+
149
+ if (!preferred) {
150
+ // Clients can explicitly reject 'identity' by setting its weight to 0,
151
+ // otherwise it is considered an acceptable fallback.
152
+ return acceptEncoding.getWeight('identity') === 0 ? null : 'identity'
153
+ }
154
+
155
+ return preferred
156
+ }
157
+
158
+ function setCompressionHeaders(headers: SuperHeaders, encoding: string): void {
159
+ headers.contentEncoding = encoding
160
+ headers.acceptRanges = 'none'
161
+ headers.contentLength = null
162
+ headers.vary.add('Accept-Encoding')
163
+
164
+ // Convert strong ETags to weak since compressed representation is byte-different
165
+ if (headers.etag && !headers.etag.startsWith('W/')) {
166
+ headers.etag = `W/${headers.etag}`
167
+ }
168
+ }
169
+
170
+ const zlibFlushOptions = {
171
+ flush: constants.Z_SYNC_FLUSH,
172
+ }
173
+
174
+ const brotliFlushOptions = {
175
+ flush: constants.BROTLI_OPERATION_FLUSH,
176
+ }
177
+
178
+ function applyCompression(
179
+ response: Response,
180
+ responseHeaders: SuperHeaders,
181
+ encoding: Encoding,
182
+ options: CompressResponseOptions,
183
+ ): Response {
184
+ if (!response.body) {
185
+ return response
186
+ }
187
+
188
+ // Detect SSE for automatic flush configuration
189
+ let contentType = response.headers.get('Content-Type')
190
+ let mediaType = contentType?.split(';')[0].trim()
191
+ let isSSE = mediaType === 'text/event-stream'
192
+
193
+ let compressor = createCompressor(encoding, {
194
+ ...options,
195
+ // Apply SSE flush defaults if not explicitly set
196
+ brotli: {
197
+ ...options.brotli,
198
+ ...(isSSE && options.brotli?.flush === undefined ? brotliFlushOptions : null),
199
+ },
200
+ zlib: {
201
+ ...options.zlib,
202
+ ...(isSSE && options.zlib?.flush === undefined ? zlibFlushOptions : null),
203
+ },
204
+ })
205
+
206
+ setCompressionHeaders(responseHeaders, encoding)
207
+
208
+ return new Response(compressStream(response.body, compressor), {
209
+ status: response.status,
210
+ statusText: response.statusText,
211
+ headers: responseHeaders,
212
+ })
213
+ }
214
+
215
+ /**
216
+ * Compresses a response stream that bridges node:zlib to Web Streams.
217
+ * Reads from the input stream, compresses chunks through the compressor,
218
+ * and returns a new ReadableStream with the compressed data.
219
+ */
220
+ export function compressStream(
221
+ input: ReadableStream<Uint8Array>,
222
+ compressor: Gzip | Deflate | BrotliCompress,
223
+ ): ReadableStream<Uint8Array> {
224
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null
225
+ let cancelled = false
226
+ let errored = false
227
+
228
+ return new ReadableStream<Uint8Array>({
229
+ async start(controller) {
230
+ reader = input.getReader()
231
+
232
+ compressor.on('data', (chunk: Buffer) => {
233
+ if (!cancelled && !errored) {
234
+ controller.enqueue(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength))
235
+ }
236
+ })
237
+
238
+ compressor.on('end', () => {
239
+ if (!cancelled && !errored) {
240
+ controller.close()
241
+ }
242
+ })
243
+
244
+ compressor.on('error', (error) => {
245
+ // Ignore duplicate error events
246
+ if (errored) {
247
+ return
248
+ }
249
+ errored = true
250
+ if (!cancelled) {
251
+ controller.error(error)
252
+ }
253
+ })
254
+
255
+ try {
256
+ while (true) {
257
+ if (cancelled || errored) {
258
+ break
259
+ }
260
+
261
+ let { done, value } = await reader.read()
262
+
263
+ if (cancelled || errored) {
264
+ break
265
+ }
266
+
267
+ if (done) {
268
+ compressor.end()
269
+ break
270
+ }
271
+
272
+ if (!value) {
273
+ continue
274
+ }
275
+
276
+ await new Promise<void>((resolve, reject) => {
277
+ let resolvedImmediately = false
278
+
279
+ let canContinue = compressor.write(Buffer.from(value), (error) => {
280
+ if (resolvedImmediately) {
281
+ return
282
+ }
283
+ if (error) {
284
+ reject(error)
285
+ } else {
286
+ resolve()
287
+ }
288
+ })
289
+
290
+ if (canContinue) {
291
+ resolvedImmediately = true
292
+ resolve()
293
+ }
294
+ })
295
+ }
296
+ } catch (error) {
297
+ errored = true
298
+ compressor.destroy(error as Error)
299
+ if (!cancelled) {
300
+ controller.error(error)
301
+ }
302
+ } finally {
303
+ reader.releaseLock()
304
+ }
305
+ },
306
+
307
+ async cancel(reason) {
308
+ cancelled = true
309
+ // Destroy compressor first to unblock any pending write operations
310
+ compressor.destroy()
311
+ await reader?.cancel(reason)
312
+ },
313
+ })
314
+ }
315
+
316
+ function createCompressor(
317
+ encoding: Encoding,
318
+ options: CompressResponseOptions,
319
+ ): Gzip | Deflate | BrotliCompress {
320
+ switch (encoding) {
321
+ case 'br':
322
+ return createBrotliCompress(options.brotli)
323
+ case 'gzip':
324
+ return createGzip(options.zlib)
325
+ case 'deflate':
326
+ return createDeflate(options.zlib)
327
+ default:
328
+ throw new Error(`Unsupported encoding: ${encoding}`)
329
+ }
330
+ }
package/src/lib/file.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import SuperHeaders from '@remix-run/headers'
2
+ import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime'
2
3
 
3
4
  /**
4
5
  * Custom function for computing file digests.
@@ -14,6 +15,9 @@ import SuperHeaders from '@remix-run/headers'
14
15
  */
15
16
  export type FileDigestFunction = (file: File) => Promise<string>
16
17
 
18
+ /**
19
+ * Options for creating a file response.
20
+ */
17
21
  export interface FileResponseOptions {
18
22
  /**
19
23
  * Cache-Control header value. If not provided, no Cache-Control header will be set.
@@ -59,7 +63,13 @@ export interface FileResponseOptions {
59
63
  * When enabled, includes `Accept-Ranges` header and handles `Range` requests
60
64
  * with 206 Partial Content responses.
61
65
  *
62
- * @default true
66
+ * Defaults to enabling ranges only for non-compressible MIME types,
67
+ * as defined by `isCompressibleMimeType()` from `@remix-run/mime`.
68
+ *
69
+ * Note: Range requests and compression are mutually exclusive. When
70
+ * `Accept-Ranges: bytes` is present in the response headers, the compression
71
+ * middleware will not compress the response. This is why the default behavior
72
+ * enables ranges only for non-compressible types.
63
73
  */
64
74
  acceptRanges?: boolean
65
75
  }
@@ -70,7 +80,7 @@ export interface FileResponseOptions {
70
80
  *
71
81
  * @param file The file to send
72
82
  * @param request The request object
73
- * @param options (optional) configuration options
83
+ * @param options Configuration options
74
84
  * @returns A `Response` object containing the file
75
85
  *
76
86
  * @example
@@ -90,12 +100,12 @@ export async function createFileResponse(
90
100
  etag: etagStrategy = 'weak',
91
101
  digest: digestOption = 'SHA-256',
92
102
  lastModified: lastModifiedEnabled = true,
93
- acceptRanges: acceptRangesEnabled = true,
103
+ acceptRanges: acceptRangesOption,
94
104
  } = options
95
105
 
96
106
  let headers = new SuperHeaders(request.headers)
97
107
 
98
- let contentType = file.type
108
+ let contentType = mimeTypeToContentType(file.type)
99
109
  let contentLength = file.size
100
110
 
101
111
  let etag: string | undefined
@@ -111,6 +121,11 @@ export async function createFileResponse(
111
121
  lastModified = file.lastModified
112
122
  }
113
123
 
124
+ // Determine if we should accept ranges
125
+ // Default: enable ranges only for non-compressible MIME types
126
+ let acceptRangesEnabled =
127
+ acceptRangesOption !== undefined ? acceptRangesOption : !isCompressibleMimeType(contentType)
128
+
114
129
  let acceptRanges: 'bytes' | undefined
115
130
  if (acceptRangesEnabled) {
116
131
  acceptRanges = 'bytes'
package/src/lib/html.ts CHANGED
@@ -8,9 +8,9 @@ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Ar
8
8
  * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
9
9
  * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
10
10
  *
11
- * @param body The body of the response.
12
- * @param init (optional) The `ResponseInit` object for the response.
13
- * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
11
+ * @param body The body of the response
12
+ * @param init The `ResponseInit` object for the response
13
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header
14
14
  */
15
15
  export function createHtmlResponse(body: HtmlBody, init?: ResponseInit): Response {
16
16
  let payload: BodyInit = ensureDoctype(body)
@@ -2,7 +2,7 @@
2
2
  * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
3
  *
4
4
  * @param location The location to redirect to
5
- * @param init (optional) The `ResponseInit` object for the response, or a status code
5
+ * @param init The `ResponseInit` object for the response, or a status code
6
6
  * @returns A `Response` object with a redirect header
7
7
  */
8
8
  export function createRedirectResponse(