@remix-run/response 0.0.0 → 0.2.0
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 +64 -0
- package/dist/compress.d.ts +2 -0
- package/dist/compress.d.ts.map +1 -0
- package/dist/compress.js +1 -0
- package/dist/lib/compress.d.ts +68 -0
- package/dist/lib/compress.d.ts.map +1 -0
- package/dist/lib/compress.js +226 -0
- package/dist/lib/file.d.ts +13 -4
- package/dist/lib/file.d.ts.map +1 -1
- package/dist/lib/file.js +7 -3
- package/dist/lib/html.d.ts +3 -3
- package/dist/lib/html.js +3 -3
- package/dist/lib/redirect.d.ts +1 -1
- package/dist/lib/redirect.js +1 -1
- package/package.json +10 -4
- package/src/compress.ts +1 -0
- package/src/lib/compress.ts +330 -0
- package/src/lib/file.ts +20 -5
- package/src/lib/html.ts +3 -3
- package/src/lib/redirect.ts +1 -1
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 @@
|
|
|
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"}
|
package/dist/compress.js
ADDED
|
@@ -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
|
+
}
|
package/dist/lib/file.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Custom function for computing file digests.
|
|
3
3
|
*
|
|
4
4
|
* @param file The file to hash
|
|
5
|
-
* @
|
|
5
|
+
* @return The computed digest as a string
|
|
6
6
|
*
|
|
7
7
|
* @example
|
|
8
8
|
* async (file) => {
|
|
@@ -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
|
-
*
|
|
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,8 +75,8 @@ export interface FileResponseOptions {
|
|
|
66
75
|
*
|
|
67
76
|
* @param file The file to send
|
|
68
77
|
* @param request The request object
|
|
69
|
-
* @param options
|
|
70
|
-
* @
|
|
78
|
+
* @param options Configuration options
|
|
79
|
+
* @return A `Response` object containing the file
|
|
71
80
|
*
|
|
72
81
|
* @example
|
|
73
82
|
* import { createFileResponse } from '@remix-run/response/file'
|
package/dist/lib/file.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/lib/file.ts"],"names":[],"mappings":"
|
|
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,12 +1,13 @@
|
|
|
1
1
|
import SuperHeaders from '@remix-run/headers';
|
|
2
|
+
import { isCompressibleMimeType } 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
|
|
9
|
-
* @
|
|
9
|
+
* @param options Configuration options
|
|
10
|
+
* @return A `Response` object containing the file
|
|
10
11
|
*
|
|
11
12
|
* @example
|
|
12
13
|
* import { createFileResponse } from '@remix-run/response/file'
|
|
@@ -16,7 +17,7 @@ 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:
|
|
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
22
|
let contentType = file.type;
|
|
22
23
|
let contentLength = file.size;
|
|
@@ -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';
|
package/dist/lib/html.d.ts
CHANGED
|
@@ -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
|
|
9
|
-
* @
|
|
7
|
+
* @param body The body of the response
|
|
8
|
+
* @param init The `ResponseInit` object for the response
|
|
9
|
+
* @return 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
|
|
9
|
-
* @
|
|
7
|
+
* @param body The body of the response
|
|
8
|
+
* @param init The `ResponseInit` object for the response
|
|
9
|
+
* @return 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);
|
package/dist/lib/redirect.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/lib/redirect.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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": "^5.9.3",
|
|
43
|
+
"@remix-run/mime": "0.1.0"
|
|
39
44
|
},
|
|
40
45
|
"peerDependencies": {
|
|
41
|
-
"@remix-run/headers": "^0.
|
|
42
|
-
"@remix-run/html-template": "^0.3.0"
|
|
46
|
+
"@remix-run/headers": "^0.18.0",
|
|
47
|
+
"@remix-run/html-template": "^0.3.0",
|
|
48
|
+
"@remix-run/mime": "^0.1.0"
|
|
43
49
|
},
|
|
44
50
|
"keywords": [
|
|
45
51
|
"fetch",
|
package/src/compress.ts
ADDED
|
@@ -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,10 +1,11 @@
|
|
|
1
1
|
import SuperHeaders from '@remix-run/headers'
|
|
2
|
+
import { isCompressibleMimeType } from '@remix-run/mime'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Custom function for computing file digests.
|
|
5
6
|
*
|
|
6
7
|
* @param file The file to hash
|
|
7
|
-
* @
|
|
8
|
+
* @return The computed digest as a string
|
|
8
9
|
*
|
|
9
10
|
* @example
|
|
10
11
|
* async (file) => {
|
|
@@ -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
|
-
*
|
|
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,8 +80,8 @@ export interface FileResponseOptions {
|
|
|
70
80
|
*
|
|
71
81
|
* @param file The file to send
|
|
72
82
|
* @param request The request object
|
|
73
|
-
* @param options
|
|
74
|
-
* @
|
|
83
|
+
* @param options Configuration options
|
|
84
|
+
* @return A `Response` object containing the file
|
|
75
85
|
*
|
|
76
86
|
* @example
|
|
77
87
|
* import { createFileResponse } from '@remix-run/response/file'
|
|
@@ -90,7 +100,7 @@ export async function createFileResponse(
|
|
|
90
100
|
etag: etagStrategy = 'weak',
|
|
91
101
|
digest: digestOption = 'SHA-256',
|
|
92
102
|
lastModified: lastModifiedEnabled = true,
|
|
93
|
-
acceptRanges:
|
|
103
|
+
acceptRanges: acceptRangesOption,
|
|
94
104
|
} = options
|
|
95
105
|
|
|
96
106
|
let headers = new SuperHeaders(request.headers)
|
|
@@ -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
|
|
13
|
-
* @
|
|
11
|
+
* @param body The body of the response
|
|
12
|
+
* @param init The `ResponseInit` object for the response
|
|
13
|
+
* @return 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)
|
package/src/lib/redirect.ts
CHANGED
|
@@ -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
|
|
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(
|