@remix-run/response 0.2.1 → 0.3.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 +4 -4
- package/dist/lib/compress.d.ts +4 -0
- package/dist/lib/compress.d.ts.map +1 -1
- package/dist/lib/compress.js +28 -15
- package/dist/lib/file.d.ts +33 -8
- package/dist/lib/file.d.ts.map +1 -1
- package/dist/lib/file.js +75 -43
- package/dist/lib/redirect.d.ts +1 -0
- package/dist/lib/redirect.d.ts.map +1 -1
- package/dist/lib/redirect.js +1 -0
- package/dist/redirect.d.ts +1 -1
- package/dist/redirect.d.ts.map +1 -1
- package/dist/redirect.js +2 -1
- package/package.json +7 -6
- package/src/lib/compress.ts +32 -17
- package/src/lib/file.ts +145 -82
- package/src/lib/redirect.ts +1 -0
- package/src/redirect.ts +4 -1
package/README.md
CHANGED
|
@@ -31,14 +31,14 @@ import { compressResponse } from '@remix-run/response/compress'
|
|
|
31
31
|
|
|
32
32
|
### File Responses
|
|
33
33
|
|
|
34
|
-
The `createFileResponse` helper creates a response for serving files with full HTTP semantics
|
|
34
|
+
The `createFileResponse` helper creates a response for serving files with full HTTP semantics. It works with both native `File` objects and `LazyFile` from `@remix-run/lazy-file`:
|
|
35
35
|
|
|
36
36
|
```ts
|
|
37
37
|
import { createFileResponse } from '@remix-run/response/file'
|
|
38
|
-
import {
|
|
38
|
+
import { openLazyFile } from '@remix-run/fs'
|
|
39
39
|
|
|
40
|
-
let
|
|
41
|
-
let response = await createFileResponse(
|
|
40
|
+
let lazyFile = openLazyFile('./public/image.jpg')
|
|
41
|
+
let response = await createFileResponse(lazyFile, request, {
|
|
42
42
|
cacheControl: 'public, max-age=3600',
|
|
43
43
|
})
|
|
44
44
|
```
|
package/dist/lib/compress.d.ts
CHANGED
|
@@ -63,6 +63,10 @@ export declare function compressResponse(response: Response, request: Request, o
|
|
|
63
63
|
* Compresses a response stream that bridges node:zlib to Web Streams.
|
|
64
64
|
* Reads from the input stream, compresses chunks through the compressor,
|
|
65
65
|
* and returns a new ReadableStream with the compressed data.
|
|
66
|
+
*
|
|
67
|
+
* @param input The input stream to compress
|
|
68
|
+
* @param compressor The zlib compressor instance to use
|
|
69
|
+
* @returns A new ReadableStream with the compressed data
|
|
66
70
|
*/
|
|
67
71
|
export declare function compressStream(input: ReadableStream<Uint8Array>, compressor: Gzip | Deflate | BrotliCompress): ReadableStream<Uint8Array>;
|
|
68
72
|
//# sourceMappingURL=compress.d.ts.map
|
|
@@ -1 +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,
|
|
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,CA6DnB;AAmFD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,cAAc,CAAC,UAAU,CAAC,EACjC,UAAU,EAAE,IAAI,GAAG,OAAO,GAAG,cAAc,GAC1C,cAAc,CAAC,UAAU,CAAC,CA2F5B"}
|
package/dist/lib/compress.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { constants, createBrotliCompress, createDeflate, createGzip, } from 'node:zlib';
|
|
2
|
-
import { AcceptEncoding,
|
|
2
|
+
import { AcceptEncoding, CacheControl, Vary } from '@remix-run/headers';
|
|
3
3
|
const defaultEncodings = ['br', 'gzip', 'deflate'];
|
|
4
4
|
/**
|
|
5
5
|
* Compresses a Response based on the client's Accept-Encoding header.
|
|
@@ -30,24 +30,29 @@ export async function compressResponse(response, request, options) {
|
|
|
30
30
|
let supportedEncodings = compressOptions.encodings ?? defaultEncodings;
|
|
31
31
|
let threshold = compressOptions.threshold ?? 1024;
|
|
32
32
|
let acceptEncodingHeader = request.headers.get('Accept-Encoding');
|
|
33
|
-
let responseHeaders = new
|
|
33
|
+
let responseHeaders = new Headers(response.headers);
|
|
34
|
+
let contentEncodingHeader = responseHeaders.get('content-encoding');
|
|
35
|
+
let contentLengthHeader = responseHeaders.get('content-length');
|
|
36
|
+
let contentLength = contentLengthHeader != null ? parseInt(contentLengthHeader, 10) : null;
|
|
37
|
+
let acceptRangesHeader = responseHeaders.get('accept-ranges');
|
|
38
|
+
let cacheControl = CacheControl.from(responseHeaders.get('cache-control'));
|
|
34
39
|
if (!acceptEncodingHeader ||
|
|
35
40
|
supportedEncodings.length === 0 ||
|
|
36
41
|
// Empty response
|
|
37
42
|
(request.method !== 'HEAD' && !response.body) ||
|
|
38
43
|
// Already compressed
|
|
39
|
-
|
|
44
|
+
contentEncodingHeader != null ||
|
|
40
45
|
// Content-Length below threshold
|
|
41
|
-
(
|
|
46
|
+
(contentLength != null && contentLength < threshold) ||
|
|
42
47
|
// Cache-Control: no-transform
|
|
43
|
-
|
|
48
|
+
cacheControl.noTransform ||
|
|
44
49
|
// Response advertising range support
|
|
45
|
-
|
|
50
|
+
acceptRangesHeader === 'bytes' ||
|
|
46
51
|
// Partial content responses
|
|
47
52
|
response.status === 206) {
|
|
48
53
|
return response;
|
|
49
54
|
}
|
|
50
|
-
let acceptEncoding =
|
|
55
|
+
let acceptEncoding = AcceptEncoding.from(acceptEncodingHeader);
|
|
51
56
|
let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings);
|
|
52
57
|
if (selectedEncoding === null) {
|
|
53
58
|
// Client has explicitly rejected all supported encodings, including 'identity'
|
|
@@ -83,13 +88,17 @@ function negotiateEncoding(acceptEncoding, supportedEncodings) {
|
|
|
83
88
|
return preferred;
|
|
84
89
|
}
|
|
85
90
|
function setCompressionHeaders(headers, encoding) {
|
|
86
|
-
headers.
|
|
87
|
-
headers.
|
|
88
|
-
headers.
|
|
89
|
-
|
|
91
|
+
headers.set('content-encoding', encoding);
|
|
92
|
+
headers.set('accept-ranges', 'none');
|
|
93
|
+
headers.delete('content-length');
|
|
94
|
+
// Update Vary header to include Accept-Encoding
|
|
95
|
+
let vary = Vary.from(headers.get('vary'));
|
|
96
|
+
vary.add('Accept-Encoding');
|
|
97
|
+
headers.set('vary', vary.toString());
|
|
90
98
|
// Convert strong ETags to weak since compressed representation is byte-different
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
let etagHeader = headers.get('etag');
|
|
100
|
+
if (etagHeader && !etagHeader.startsWith('W/')) {
|
|
101
|
+
headers.set('etag', `W/${etagHeader}`);
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
const zlibFlushOptions = {
|
|
@@ -103,8 +112,8 @@ function applyCompression(response, responseHeaders, encoding, options) {
|
|
|
103
112
|
return response;
|
|
104
113
|
}
|
|
105
114
|
// Detect SSE for automatic flush configuration
|
|
106
|
-
let
|
|
107
|
-
let mediaType =
|
|
115
|
+
let contentTypeHeader = response.headers.get('Content-Type');
|
|
116
|
+
let mediaType = contentTypeHeader?.split(';')[0].trim();
|
|
108
117
|
let isSSE = mediaType === 'text/event-stream';
|
|
109
118
|
let compressor = createCompressor(encoding, {
|
|
110
119
|
...options,
|
|
@@ -129,6 +138,10 @@ function applyCompression(response, responseHeaders, encoding, options) {
|
|
|
129
138
|
* Compresses a response stream that bridges node:zlib to Web Streams.
|
|
130
139
|
* Reads from the input stream, compresses chunks through the compressor,
|
|
131
140
|
* and returns a new ReadableStream with the compressed data.
|
|
141
|
+
*
|
|
142
|
+
* @param input The input stream to compress
|
|
143
|
+
* @param compressor The zlib compressor instance to use
|
|
144
|
+
* @returns A new ReadableStream with the compressed data
|
|
132
145
|
*/
|
|
133
146
|
export function compressStream(input, compressor) {
|
|
134
147
|
let reader = null;
|
package/dist/lib/file.d.ts
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal interface for file-like objects used by `createFileResponse`.
|
|
3
|
+
*/
|
|
4
|
+
export interface FileLike {
|
|
5
|
+
/** File compatibility - included for interface completeness */
|
|
6
|
+
readonly name: string;
|
|
7
|
+
/** Used for Content-Length header and range calculations */
|
|
8
|
+
readonly size: number;
|
|
9
|
+
/** Used for Content-Type header */
|
|
10
|
+
readonly type: string;
|
|
11
|
+
/** Used for Last-Modified header and weak ETag generation */
|
|
12
|
+
readonly lastModified: number;
|
|
13
|
+
/** Used for streaming the response body */
|
|
14
|
+
stream(): ReadableStream<Uint8Array>;
|
|
15
|
+
/** Used for strong ETag digest calculation */
|
|
16
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
17
|
+
/** Used for range requests (206 Partial Content) */
|
|
18
|
+
slice(start?: number, end?: number, contentType?: string): {
|
|
19
|
+
stream(): ReadableStream<Uint8Array>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
1
22
|
/**
|
|
2
23
|
* Custom function for computing file digests.
|
|
3
24
|
*
|
|
@@ -10,11 +31,11 @@
|
|
|
10
31
|
* return customHash(buffer)
|
|
11
32
|
* }
|
|
12
33
|
*/
|
|
13
|
-
export type FileDigestFunction = (file:
|
|
34
|
+
export type FileDigestFunction<file extends FileLike = File> = (file: file) => Promise<string>;
|
|
14
35
|
/**
|
|
15
36
|
* Options for creating a file response.
|
|
16
37
|
*/
|
|
17
|
-
export interface FileResponseOptions {
|
|
38
|
+
export interface FileResponseOptions<file extends FileLike = File> {
|
|
18
39
|
/**
|
|
19
40
|
* Cache-Control header value. If not provided, no Cache-Control header will be set.
|
|
20
41
|
*
|
|
@@ -39,14 +60,14 @@ export interface FileResponseOptions {
|
|
|
39
60
|
* - String: Web Crypto API algorithm name ('SHA-256', 'SHA-384', 'SHA-512', 'SHA-1').
|
|
40
61
|
* Note: Using strong ETags will buffer the entire file into memory before hashing.
|
|
41
62
|
* Consider using weak ETags (default) or a custom digest function for large files.
|
|
42
|
-
* - Function: Custom digest computation that receives a
|
|
63
|
+
* - Function: Custom digest computation that receives a file and returns the digest string
|
|
43
64
|
*
|
|
44
65
|
* Only used when `etag: 'strong'`. Ignored for weak ETags.
|
|
45
66
|
*
|
|
46
67
|
* @default 'SHA-256'
|
|
47
68
|
* @example async (file) => await customHash(file)
|
|
48
69
|
*/
|
|
49
|
-
digest?: AlgorithmIdentifier | FileDigestFunction
|
|
70
|
+
digest?: AlgorithmIdentifier | FileDigestFunction<file>;
|
|
50
71
|
/**
|
|
51
72
|
* Whether to include `Last-Modified` headers.
|
|
52
73
|
*
|
|
@@ -73,17 +94,21 @@ export interface FileResponseOptions {
|
|
|
73
94
|
* Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
|
|
74
95
|
* with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
|
|
75
96
|
*
|
|
76
|
-
*
|
|
97
|
+
* Accepts both native `File` objects and `LazyFile` from `@remix-run/lazy-file`.
|
|
98
|
+
*
|
|
99
|
+
* @param file The file to send (native `File` or `LazyFile`)
|
|
77
100
|
* @param request The request object
|
|
78
101
|
* @param options Configuration options
|
|
79
102
|
* @returns A `Response` object containing the file
|
|
80
103
|
*
|
|
81
104
|
* @example
|
|
82
105
|
* import { createFileResponse } from '@remix-run/response/file'
|
|
83
|
-
*
|
|
84
|
-
*
|
|
106
|
+
* import { openLazyFile } from '@remix-run/fs'
|
|
107
|
+
*
|
|
108
|
+
* let lazyFile = openLazyFile('./public/image.jpg')
|
|
109
|
+
* return createFileResponse(lazyFile, request, {
|
|
85
110
|
* cacheControl: 'public, max-age=3600'
|
|
86
111
|
* })
|
|
87
112
|
*/
|
|
88
|
-
export declare function createFileResponse(file:
|
|
113
|
+
export declare function createFileResponse<file extends FileLike>(file: file, request: Request, options?: FileResponseOptions<file>): Promise<Response>;
|
|
89
114
|
//# sourceMappingURL=file.d.ts.map
|
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":"AAUA;;GAEG;AACH,MAAM,WAAW,QAAQ;IACvB,+DAA+D;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,mCAAmC;IACnC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,6DAA6D;IAC7D,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,2CAA2C;IAC3C,MAAM,IAAI,cAAc,CAAC,UAAU,CAAC,CAAA;IACpC,8CAA8C;IAC9C,WAAW,IAAI,OAAO,CAAC,WAAW,CAAC,CAAA;IACnC,oDAAoD;IACpD,KAAK,CACH,KAAK,CAAC,EAAE,MAAM,EACd,GAAG,CAAC,EAAE,MAAM,EACZ,WAAW,CAAC,EAAE,MAAM,GACnB;QAAE,MAAM,IAAI,cAAc,CAAC,UAAU,CAAC,CAAA;KAAE,CAAA;CAC5C;AAED;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,kBAAkB,CAAC,IAAI,SAAS,QAAQ,GAAG,IAAI,IAAI,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;AAE9F;;GAEG;AACH,MAAM,WAAW,mBAAmB,CAAC,IAAI,SAAS,QAAQ,GAAG,IAAI;IAC/D;;;;;;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,CAAC,IAAI,CAAC,CAAA;IACvD;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;;;;;;;OAaG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,kBAAkB,CAAC,IAAI,SAAS,QAAQ,EAC5D,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAmB,CAAC,IAAI,CAAM,GACtC,OAAO,CAAC,QAAQ,CAAC,CA4KnB"}
|
package/dist/lib/file.js
CHANGED
|
@@ -1,24 +1,28 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { ContentRange, IfMatch, IfNoneMatch, IfRange, Range, } from '@remix-run/headers';
|
|
2
2
|
import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime';
|
|
3
3
|
/**
|
|
4
4
|
* Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
|
|
5
5
|
* with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Accepts both native `File` objects and `LazyFile` from `@remix-run/lazy-file`.
|
|
8
|
+
*
|
|
9
|
+
* @param file The file to send (native `File` or `LazyFile`)
|
|
8
10
|
* @param request The request object
|
|
9
11
|
* @param options Configuration options
|
|
10
12
|
* @returns A `Response` object containing the file
|
|
11
13
|
*
|
|
12
14
|
* @example
|
|
13
15
|
* import { createFileResponse } from '@remix-run/response/file'
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* import { openLazyFile } from '@remix-run/fs'
|
|
17
|
+
*
|
|
18
|
+
* let lazyFile = openLazyFile('./public/image.jpg')
|
|
19
|
+
* return createFileResponse(lazyFile, request, {
|
|
16
20
|
* cacheControl: 'public, max-age=3600'
|
|
17
21
|
* })
|
|
18
22
|
*/
|
|
19
23
|
export async function createFileResponse(file, request, options = {}) {
|
|
20
24
|
let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesOption, } = options;
|
|
21
|
-
let headers =
|
|
25
|
+
let headers = request.headers;
|
|
22
26
|
let contentType = mimeTypeToContentType(file.type);
|
|
23
27
|
let contentLength = file.size;
|
|
24
28
|
let etag;
|
|
@@ -42,28 +46,32 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
42
46
|
}
|
|
43
47
|
let hasIfMatch = headers.has('If-Match');
|
|
44
48
|
// If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match
|
|
45
|
-
if (etag && hasIfMatch
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
if (etag && hasIfMatch) {
|
|
50
|
+
let ifMatch = IfMatch.from(headers.get('if-match'));
|
|
51
|
+
if (!ifMatch.matches(etag)) {
|
|
52
|
+
return new Response('Precondition Failed', {
|
|
53
|
+
status: 412,
|
|
54
|
+
headers: buildResponseHeaders({
|
|
55
|
+
etag,
|
|
56
|
+
lastModified,
|
|
57
|
+
acceptRanges,
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
62
|
// If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since
|
|
56
63
|
if (lastModified && !hasIfMatch) {
|
|
57
|
-
let
|
|
58
|
-
if (
|
|
64
|
+
let ifUnmodifiedSinceHeader = headers.get('if-unmodified-since');
|
|
65
|
+
if (ifUnmodifiedSinceHeader != null) {
|
|
66
|
+
let ifUnmodifiedSince = new Date(ifUnmodifiedSinceHeader);
|
|
59
67
|
if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) {
|
|
60
68
|
return new Response('Precondition Failed', {
|
|
61
69
|
status: 412,
|
|
62
|
-
headers:
|
|
70
|
+
headers: buildResponseHeaders({
|
|
63
71
|
etag,
|
|
64
72
|
lastModified,
|
|
65
73
|
acceptRanges,
|
|
66
|
-
})
|
|
74
|
+
}),
|
|
67
75
|
});
|
|
68
76
|
}
|
|
69
77
|
}
|
|
@@ -72,12 +80,14 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
72
80
|
// If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since
|
|
73
81
|
if (etag || lastModified) {
|
|
74
82
|
let shouldReturnNotModified = false;
|
|
75
|
-
|
|
83
|
+
let ifNoneMatch = IfNoneMatch.from(headers.get('if-none-match'));
|
|
84
|
+
if (etag && ifNoneMatch.matches(etag)) {
|
|
76
85
|
shouldReturnNotModified = true;
|
|
77
86
|
}
|
|
78
|
-
else if (lastModified &&
|
|
79
|
-
let
|
|
80
|
-
if (
|
|
87
|
+
else if (lastModified && ifNoneMatch.tags.length === 0) {
|
|
88
|
+
let ifModifiedSinceHeader = headers.get('if-modified-since');
|
|
89
|
+
if (ifModifiedSinceHeader != null) {
|
|
90
|
+
let ifModifiedSince = new Date(ifModifiedSinceHeader);
|
|
81
91
|
if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) {
|
|
82
92
|
shouldReturnNotModified = true;
|
|
83
93
|
}
|
|
@@ -86,18 +96,18 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
86
96
|
if (shouldReturnNotModified) {
|
|
87
97
|
return new Response(null, {
|
|
88
98
|
status: 304,
|
|
89
|
-
headers:
|
|
99
|
+
headers: buildResponseHeaders({
|
|
90
100
|
etag,
|
|
91
101
|
lastModified,
|
|
92
102
|
acceptRanges,
|
|
93
|
-
})
|
|
103
|
+
}),
|
|
94
104
|
});
|
|
95
105
|
}
|
|
96
106
|
}
|
|
97
107
|
// Range support: https://httpwg.org/specs/rfc9110.html#field.range
|
|
98
108
|
// If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
|
|
99
109
|
if (acceptRanges && request.method === 'GET' && headers.has('Range')) {
|
|
100
|
-
let range = headers.range;
|
|
110
|
+
let range = Range.from(headers.get('range'));
|
|
101
111
|
// Check if the Range header was sent but parsing resulted in no valid ranges (malformed)
|
|
102
112
|
if (range.ranges.length === 0) {
|
|
103
113
|
return new Response('Bad Request', {
|
|
@@ -105,15 +115,16 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
105
115
|
});
|
|
106
116
|
}
|
|
107
117
|
// If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
|
|
108
|
-
|
|
118
|
+
let ifRange = IfRange.from(headers.get('if-range'));
|
|
119
|
+
if (ifRange.matches({
|
|
109
120
|
etag,
|
|
110
121
|
lastModified,
|
|
111
122
|
})) {
|
|
112
123
|
if (!range.canSatisfy(file.size)) {
|
|
113
124
|
return new Response('Range Not Satisfiable', {
|
|
114
125
|
status: 416,
|
|
115
|
-
headers:
|
|
116
|
-
contentRange: { unit: 'bytes', size: file.size },
|
|
126
|
+
headers: buildResponseHeaders({
|
|
127
|
+
contentRange: ContentRange.from({ unit: 'bytes', size: file.size }),
|
|
117
128
|
}),
|
|
118
129
|
});
|
|
119
130
|
}
|
|
@@ -122,16 +133,16 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
122
133
|
if (normalizedRanges.length > 1) {
|
|
123
134
|
return new Response('Range Not Satisfiable', {
|
|
124
135
|
status: 416,
|
|
125
|
-
headers:
|
|
126
|
-
contentRange: { unit: 'bytes', size: file.size },
|
|
136
|
+
headers: buildResponseHeaders({
|
|
137
|
+
contentRange: ContentRange.from({ unit: 'bytes', size: file.size }),
|
|
127
138
|
}),
|
|
128
139
|
});
|
|
129
140
|
}
|
|
130
141
|
let { start, end } = normalizedRanges[0];
|
|
131
142
|
let { size } = file;
|
|
132
|
-
return new Response(file.slice(start, end + 1), {
|
|
143
|
+
return new Response(file.slice(start, end + 1).stream(), {
|
|
133
144
|
status: 206,
|
|
134
|
-
headers:
|
|
145
|
+
headers: buildResponseHeaders({
|
|
135
146
|
contentType,
|
|
136
147
|
contentLength: end - start + 1,
|
|
137
148
|
contentRange: { unit: 'bytes', start, end, size },
|
|
@@ -139,33 +150,51 @@ export async function createFileResponse(file, request, options = {}) {
|
|
|
139
150
|
lastModified,
|
|
140
151
|
cacheControl,
|
|
141
152
|
acceptRanges,
|
|
142
|
-
})
|
|
153
|
+
}),
|
|
143
154
|
});
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
|
-
return new Response(request.method === 'HEAD' ? null : file, {
|
|
157
|
+
return new Response(request.method === 'HEAD' ? null : file.stream(), {
|
|
147
158
|
status: 200,
|
|
148
|
-
headers:
|
|
159
|
+
headers: buildResponseHeaders({
|
|
149
160
|
contentType,
|
|
150
161
|
contentLength,
|
|
151
162
|
etag,
|
|
152
163
|
lastModified,
|
|
153
164
|
cacheControl,
|
|
154
165
|
acceptRanges,
|
|
155
|
-
})
|
|
166
|
+
}),
|
|
156
167
|
});
|
|
157
168
|
}
|
|
158
169
|
function generateWeakETag(file) {
|
|
159
170
|
return `W/"${file.size}-${file.lastModified}"`;
|
|
160
171
|
}
|
|
161
|
-
function
|
|
162
|
-
let
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
function buildResponseHeaders(values) {
|
|
173
|
+
let headers = new Headers();
|
|
174
|
+
if (values.contentType) {
|
|
175
|
+
headers.set('Content-Type', values.contentType);
|
|
176
|
+
}
|
|
177
|
+
if (values.contentLength != null) {
|
|
178
|
+
headers.set('Content-Length', String(values.contentLength));
|
|
179
|
+
}
|
|
180
|
+
if (values.contentRange) {
|
|
181
|
+
let str = ContentRange.from(values.contentRange).toString();
|
|
182
|
+
if (str)
|
|
183
|
+
headers.set('Content-Range', str);
|
|
184
|
+
}
|
|
185
|
+
if (values.etag) {
|
|
186
|
+
headers.set('ETag', values.etag);
|
|
167
187
|
}
|
|
168
|
-
|
|
188
|
+
if (values.lastModified != null) {
|
|
189
|
+
headers.set('Last-Modified', new Date(values.lastModified).toUTCString());
|
|
190
|
+
}
|
|
191
|
+
if (values.cacheControl) {
|
|
192
|
+
headers.set('Cache-Control', values.cacheControl);
|
|
193
|
+
}
|
|
194
|
+
if (values.acceptRanges) {
|
|
195
|
+
headers.set('Accept-Ranges', values.acceptRanges);
|
|
196
|
+
}
|
|
197
|
+
return headers;
|
|
169
198
|
}
|
|
170
199
|
/**
|
|
171
200
|
* Computes a digest (hash) for a file.
|
|
@@ -199,6 +228,9 @@ async function hashFile(file, algorithm = 'SHA-256') {
|
|
|
199
228
|
/**
|
|
200
229
|
* Removes milliseconds from a timestamp, returning seconds.
|
|
201
230
|
* HTTP dates only have second precision, so this is useful for date comparisons.
|
|
231
|
+
*
|
|
232
|
+
* @param time The timestamp or Date to truncate
|
|
233
|
+
* @returns The timestamp in seconds (milliseconds removed)
|
|
202
234
|
*/
|
|
203
235
|
function removeMilliseconds(time) {
|
|
204
236
|
let timestamp = time instanceof Date ? time.getTime() : time;
|
package/dist/lib/redirect.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
|
3
3
|
*
|
|
4
|
+
* @alias redirect
|
|
4
5
|
* @param location The location to redirect to
|
|
5
6
|
* @param init The `ResponseInit` object for the response, or a status code
|
|
6
7
|
* @returns A `Response` object with a redirect header
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../../src/lib/redirect.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../../src/lib/redirect.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,GAAG,GAAG,EACtB,IAAI,CAAC,EAAE,YAAY,GAAG,MAAM,GAC3B,QAAQ,CAaV"}
|
package/dist/lib/redirect.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
|
3
3
|
*
|
|
4
|
+
* @alias redirect
|
|
4
5
|
* @param location The location to redirect to
|
|
5
6
|
* @param init The `ResponseInit` object for the response, or a status code
|
|
6
7
|
* @returns A `Response` object with a redirect header
|
package/dist/redirect.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { createRedirectResponse } from './lib/redirect.ts';
|
|
1
|
+
export { createRedirectResponse, createRedirectResponse as redirect, } from './lib/redirect.ts';
|
|
2
2
|
//# sourceMappingURL=redirect.d.ts.map
|
package/dist/redirect.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../src/redirect.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../src/redirect.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,sBAAsB,EACtB,sBAAsB,IAAI,QAAQ,GACnC,MAAM,mBAAmB,CAAA"}
|
package/dist/redirect.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export { createRedirectResponse
|
|
1
|
+
export { createRedirectResponse, createRedirectResponse as redirect, // shorthand
|
|
2
|
+
} from "./lib/redirect.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remix-run/response",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Response helpers for the web Fetch API",
|
|
5
5
|
"author": "Michael Jackson <mjijackson@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,12 +40,13 @@
|
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^24.6.0",
|
|
42
42
|
"@typescript/native-preview": "7.0.0-dev.20251125.1",
|
|
43
|
-
"@remix-run/
|
|
43
|
+
"@remix-run/lazy-file": "5.0.1",
|
|
44
|
+
"@remix-run/mime": "0.3.0"
|
|
44
45
|
},
|
|
45
|
-
"
|
|
46
|
-
"@remix-run/headers": "^0.
|
|
47
|
-
"@remix-run/
|
|
48
|
-
"@remix-run/
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@remix-run/headers": "^0.19.0",
|
|
48
|
+
"@remix-run/html-template": "^0.3.0",
|
|
49
|
+
"@remix-run/mime": "^0.3.0"
|
|
49
50
|
},
|
|
50
51
|
"keywords": [
|
|
51
52
|
"fetch",
|
package/src/lib/compress.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from 'node:zlib'
|
|
10
10
|
import type { BrotliOptions, ZlibOptions } from 'node:zlib'
|
|
11
11
|
|
|
12
|
-
import { AcceptEncoding,
|
|
12
|
+
import { AcceptEncoding, CacheControl, Vary } from '@remix-run/headers'
|
|
13
13
|
|
|
14
14
|
export type Encoding = 'br' | 'gzip' | 'deflate'
|
|
15
15
|
const defaultEncodings: Encoding[] = ['br', 'gzip', 'deflate']
|
|
@@ -84,7 +84,13 @@ export async function compressResponse(
|
|
|
84
84
|
let supportedEncodings = compressOptions.encodings ?? defaultEncodings
|
|
85
85
|
let threshold = compressOptions.threshold ?? 1024
|
|
86
86
|
let acceptEncodingHeader = request.headers.get('Accept-Encoding')
|
|
87
|
-
let responseHeaders = new
|
|
87
|
+
let responseHeaders = new Headers(response.headers)
|
|
88
|
+
|
|
89
|
+
let contentEncodingHeader = responseHeaders.get('content-encoding')
|
|
90
|
+
let contentLengthHeader = responseHeaders.get('content-length')
|
|
91
|
+
let contentLength = contentLengthHeader != null ? parseInt(contentLengthHeader, 10) : null
|
|
92
|
+
let acceptRangesHeader = responseHeaders.get('accept-ranges')
|
|
93
|
+
let cacheControl = CacheControl.from(responseHeaders.get('cache-control'))
|
|
88
94
|
|
|
89
95
|
if (
|
|
90
96
|
!acceptEncodingHeader ||
|
|
@@ -92,20 +98,20 @@ export async function compressResponse(
|
|
|
92
98
|
// Empty response
|
|
93
99
|
(request.method !== 'HEAD' && !response.body) ||
|
|
94
100
|
// Already compressed
|
|
95
|
-
|
|
101
|
+
contentEncodingHeader != null ||
|
|
96
102
|
// Content-Length below threshold
|
|
97
|
-
(
|
|
103
|
+
(contentLength != null && contentLength < threshold) ||
|
|
98
104
|
// Cache-Control: no-transform
|
|
99
|
-
|
|
105
|
+
cacheControl.noTransform ||
|
|
100
106
|
// Response advertising range support
|
|
101
|
-
|
|
107
|
+
acceptRangesHeader === 'bytes' ||
|
|
102
108
|
// Partial content responses
|
|
103
109
|
response.status === 206
|
|
104
110
|
) {
|
|
105
111
|
return response
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
let acceptEncoding =
|
|
114
|
+
let acceptEncoding = AcceptEncoding.from(acceptEncodingHeader)
|
|
109
115
|
let selectedEncoding = negotiateEncoding(acceptEncoding, supportedEncodings)
|
|
110
116
|
if (selectedEncoding === null) {
|
|
111
117
|
// Client has explicitly rejected all supported encodings, including 'identity'
|
|
@@ -155,15 +161,20 @@ function negotiateEncoding(
|
|
|
155
161
|
return preferred
|
|
156
162
|
}
|
|
157
163
|
|
|
158
|
-
function setCompressionHeaders(headers:
|
|
159
|
-
headers.
|
|
160
|
-
headers.
|
|
161
|
-
headers.
|
|
162
|
-
|
|
164
|
+
function setCompressionHeaders(headers: Headers, encoding: string): void {
|
|
165
|
+
headers.set('content-encoding', encoding)
|
|
166
|
+
headers.set('accept-ranges', 'none')
|
|
167
|
+
headers.delete('content-length')
|
|
168
|
+
|
|
169
|
+
// Update Vary header to include Accept-Encoding
|
|
170
|
+
let vary = Vary.from(headers.get('vary'))
|
|
171
|
+
vary.add('Accept-Encoding')
|
|
172
|
+
headers.set('vary', vary.toString())
|
|
163
173
|
|
|
164
174
|
// Convert strong ETags to weak since compressed representation is byte-different
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
let etagHeader = headers.get('etag')
|
|
176
|
+
if (etagHeader && !etagHeader.startsWith('W/')) {
|
|
177
|
+
headers.set('etag', `W/${etagHeader}`)
|
|
167
178
|
}
|
|
168
179
|
}
|
|
169
180
|
|
|
@@ -177,7 +188,7 @@ const brotliFlushOptions = {
|
|
|
177
188
|
|
|
178
189
|
function applyCompression(
|
|
179
190
|
response: Response,
|
|
180
|
-
responseHeaders:
|
|
191
|
+
responseHeaders: Headers,
|
|
181
192
|
encoding: Encoding,
|
|
182
193
|
options: CompressResponseOptions,
|
|
183
194
|
): Response {
|
|
@@ -186,8 +197,8 @@ function applyCompression(
|
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
// Detect SSE for automatic flush configuration
|
|
189
|
-
let
|
|
190
|
-
let mediaType =
|
|
200
|
+
let contentTypeHeader = response.headers.get('Content-Type')
|
|
201
|
+
let mediaType = contentTypeHeader?.split(';')[0].trim()
|
|
191
202
|
let isSSE = mediaType === 'text/event-stream'
|
|
192
203
|
|
|
193
204
|
let compressor = createCompressor(encoding, {
|
|
@@ -216,6 +227,10 @@ function applyCompression(
|
|
|
216
227
|
* Compresses a response stream that bridges node:zlib to Web Streams.
|
|
217
228
|
* Reads from the input stream, compresses chunks through the compressor,
|
|
218
229
|
* and returns a new ReadableStream with the compressed data.
|
|
230
|
+
*
|
|
231
|
+
* @param input The input stream to compress
|
|
232
|
+
* @param compressor The zlib compressor instance to use
|
|
233
|
+
* @returns A new ReadableStream with the compressed data
|
|
219
234
|
*/
|
|
220
235
|
export function compressStream(
|
|
221
236
|
input: ReadableStream<Uint8Array>,
|
package/src/lib/file.ts
CHANGED
|
@@ -1,6 +1,37 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type ContentRangeInit,
|
|
3
|
+
ContentRange,
|
|
4
|
+
IfMatch,
|
|
5
|
+
IfNoneMatch,
|
|
6
|
+
IfRange,
|
|
7
|
+
Range,
|
|
8
|
+
} from '@remix-run/headers'
|
|
2
9
|
import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime'
|
|
3
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Minimal interface for file-like objects used by `createFileResponse`.
|
|
13
|
+
*/
|
|
14
|
+
export interface FileLike {
|
|
15
|
+
/** File compatibility - included for interface completeness */
|
|
16
|
+
readonly name: string
|
|
17
|
+
/** Used for Content-Length header and range calculations */
|
|
18
|
+
readonly size: number
|
|
19
|
+
/** Used for Content-Type header */
|
|
20
|
+
readonly type: string
|
|
21
|
+
/** Used for Last-Modified header and weak ETag generation */
|
|
22
|
+
readonly lastModified: number
|
|
23
|
+
/** Used for streaming the response body */
|
|
24
|
+
stream(): ReadableStream<Uint8Array>
|
|
25
|
+
/** Used for strong ETag digest calculation */
|
|
26
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
27
|
+
/** Used for range requests (206 Partial Content) */
|
|
28
|
+
slice(
|
|
29
|
+
start?: number,
|
|
30
|
+
end?: number,
|
|
31
|
+
contentType?: string,
|
|
32
|
+
): { stream(): ReadableStream<Uint8Array> }
|
|
33
|
+
}
|
|
34
|
+
|
|
4
35
|
/**
|
|
5
36
|
* Custom function for computing file digests.
|
|
6
37
|
*
|
|
@@ -13,12 +44,12 @@ import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime'
|
|
|
13
44
|
* return customHash(buffer)
|
|
14
45
|
* }
|
|
15
46
|
*/
|
|
16
|
-
export type FileDigestFunction = (file:
|
|
47
|
+
export type FileDigestFunction<file extends FileLike = File> = (file: file) => Promise<string>
|
|
17
48
|
|
|
18
49
|
/**
|
|
19
50
|
* Options for creating a file response.
|
|
20
51
|
*/
|
|
21
|
-
export interface FileResponseOptions {
|
|
52
|
+
export interface FileResponseOptions<file extends FileLike = File> {
|
|
22
53
|
/**
|
|
23
54
|
* Cache-Control header value. If not provided, no Cache-Control header will be set.
|
|
24
55
|
*
|
|
@@ -43,14 +74,14 @@ export interface FileResponseOptions {
|
|
|
43
74
|
* - String: Web Crypto API algorithm name ('SHA-256', 'SHA-384', 'SHA-512', 'SHA-1').
|
|
44
75
|
* Note: Using strong ETags will buffer the entire file into memory before hashing.
|
|
45
76
|
* Consider using weak ETags (default) or a custom digest function for large files.
|
|
46
|
-
* - Function: Custom digest computation that receives a
|
|
77
|
+
* - Function: Custom digest computation that receives a file and returns the digest string
|
|
47
78
|
*
|
|
48
79
|
* Only used when `etag: 'strong'`. Ignored for weak ETags.
|
|
49
80
|
*
|
|
50
81
|
* @default 'SHA-256'
|
|
51
82
|
* @example async (file) => await customHash(file)
|
|
52
83
|
*/
|
|
53
|
-
digest?: AlgorithmIdentifier | FileDigestFunction
|
|
84
|
+
digest?: AlgorithmIdentifier | FileDigestFunction<file>
|
|
54
85
|
/**
|
|
55
86
|
* Whether to include `Last-Modified` headers.
|
|
56
87
|
*
|
|
@@ -78,22 +109,26 @@ export interface FileResponseOptions {
|
|
|
78
109
|
* Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
|
|
79
110
|
* with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
|
|
80
111
|
*
|
|
81
|
-
*
|
|
112
|
+
* Accepts both native `File` objects and `LazyFile` from `@remix-run/lazy-file`.
|
|
113
|
+
*
|
|
114
|
+
* @param file The file to send (native `File` or `LazyFile`)
|
|
82
115
|
* @param request The request object
|
|
83
116
|
* @param options Configuration options
|
|
84
117
|
* @returns A `Response` object containing the file
|
|
85
118
|
*
|
|
86
119
|
* @example
|
|
87
120
|
* import { createFileResponse } from '@remix-run/response/file'
|
|
88
|
-
*
|
|
89
|
-
*
|
|
121
|
+
* import { openLazyFile } from '@remix-run/fs'
|
|
122
|
+
*
|
|
123
|
+
* let lazyFile = openLazyFile('./public/image.jpg')
|
|
124
|
+
* return createFileResponse(lazyFile, request, {
|
|
90
125
|
* cacheControl: 'public, max-age=3600'
|
|
91
126
|
* })
|
|
92
127
|
*/
|
|
93
|
-
export async function createFileResponse(
|
|
94
|
-
file:
|
|
128
|
+
export async function createFileResponse<file extends FileLike>(
|
|
129
|
+
file: file,
|
|
95
130
|
request: Request,
|
|
96
|
-
options: FileResponseOptions = {},
|
|
131
|
+
options: FileResponseOptions<file> = {},
|
|
97
132
|
): Promise<Response> {
|
|
98
133
|
let {
|
|
99
134
|
cacheControl,
|
|
@@ -103,7 +138,7 @@ export async function createFileResponse(
|
|
|
103
138
|
acceptRanges: acceptRangesOption,
|
|
104
139
|
} = options
|
|
105
140
|
|
|
106
|
-
let headers =
|
|
141
|
+
let headers = request.headers
|
|
107
142
|
|
|
108
143
|
let contentType = mimeTypeToContentType(file.type)
|
|
109
144
|
let contentLength = file.size
|
|
@@ -134,33 +169,33 @@ export async function createFileResponse(
|
|
|
134
169
|
let hasIfMatch = headers.has('If-Match')
|
|
135
170
|
|
|
136
171
|
// If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match
|
|
137
|
-
if (etag && hasIfMatch
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
172
|
+
if (etag && hasIfMatch) {
|
|
173
|
+
let ifMatch = IfMatch.from(headers.get('if-match'))
|
|
174
|
+
if (!ifMatch.matches(etag)) {
|
|
175
|
+
return new Response('Precondition Failed', {
|
|
176
|
+
status: 412,
|
|
177
|
+
headers: buildResponseHeaders({
|
|
142
178
|
etag,
|
|
143
179
|
lastModified,
|
|
144
180
|
acceptRanges,
|
|
145
181
|
}),
|
|
146
|
-
)
|
|
147
|
-
}
|
|
182
|
+
})
|
|
183
|
+
}
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
// If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since
|
|
151
187
|
if (lastModified && !hasIfMatch) {
|
|
152
|
-
let
|
|
153
|
-
if (
|
|
188
|
+
let ifUnmodifiedSinceHeader = headers.get('if-unmodified-since')
|
|
189
|
+
if (ifUnmodifiedSinceHeader != null) {
|
|
190
|
+
let ifUnmodifiedSince = new Date(ifUnmodifiedSinceHeader)
|
|
154
191
|
if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) {
|
|
155
192
|
return new Response('Precondition Failed', {
|
|
156
193
|
status: 412,
|
|
157
|
-
headers:
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}),
|
|
163
|
-
),
|
|
194
|
+
headers: buildResponseHeaders({
|
|
195
|
+
etag,
|
|
196
|
+
lastModified,
|
|
197
|
+
acceptRanges,
|
|
198
|
+
}),
|
|
164
199
|
})
|
|
165
200
|
}
|
|
166
201
|
}
|
|
@@ -170,12 +205,14 @@ export async function createFileResponse(
|
|
|
170
205
|
// If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since
|
|
171
206
|
if (etag || lastModified) {
|
|
172
207
|
let shouldReturnNotModified = false
|
|
208
|
+
let ifNoneMatch = IfNoneMatch.from(headers.get('if-none-match'))
|
|
173
209
|
|
|
174
|
-
if (etag &&
|
|
210
|
+
if (etag && ifNoneMatch.matches(etag)) {
|
|
175
211
|
shouldReturnNotModified = true
|
|
176
|
-
} else if (lastModified &&
|
|
177
|
-
let
|
|
178
|
-
if (
|
|
212
|
+
} else if (lastModified && ifNoneMatch.tags.length === 0) {
|
|
213
|
+
let ifModifiedSinceHeader = headers.get('if-modified-since')
|
|
214
|
+
if (ifModifiedSinceHeader != null) {
|
|
215
|
+
let ifModifiedSince = new Date(ifModifiedSinceHeader)
|
|
179
216
|
if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) {
|
|
180
217
|
shouldReturnNotModified = true
|
|
181
218
|
}
|
|
@@ -185,13 +222,11 @@ export async function createFileResponse(
|
|
|
185
222
|
if (shouldReturnNotModified) {
|
|
186
223
|
return new Response(null, {
|
|
187
224
|
status: 304,
|
|
188
|
-
headers:
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}),
|
|
194
|
-
),
|
|
225
|
+
headers: buildResponseHeaders({
|
|
226
|
+
etag,
|
|
227
|
+
lastModified,
|
|
228
|
+
acceptRanges,
|
|
229
|
+
}),
|
|
195
230
|
})
|
|
196
231
|
}
|
|
197
232
|
}
|
|
@@ -199,7 +234,7 @@ export async function createFileResponse(
|
|
|
199
234
|
// Range support: https://httpwg.org/specs/rfc9110.html#field.range
|
|
200
235
|
// If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
|
|
201
236
|
if (acceptRanges && request.method === 'GET' && headers.has('Range')) {
|
|
202
|
-
let range = headers.range
|
|
237
|
+
let range = Range.from(headers.get('range'))
|
|
203
238
|
|
|
204
239
|
// Check if the Range header was sent but parsing resulted in no valid ranges (malformed)
|
|
205
240
|
if (range.ranges.length === 0) {
|
|
@@ -209,8 +244,9 @@ export async function createFileResponse(
|
|
|
209
244
|
}
|
|
210
245
|
|
|
211
246
|
// If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
|
|
247
|
+
let ifRange = IfRange.from(headers.get('if-range'))
|
|
212
248
|
if (
|
|
213
|
-
|
|
249
|
+
ifRange.matches({
|
|
214
250
|
etag,
|
|
215
251
|
lastModified,
|
|
216
252
|
})
|
|
@@ -218,8 +254,8 @@ export async function createFileResponse(
|
|
|
218
254
|
if (!range.canSatisfy(file.size)) {
|
|
219
255
|
return new Response('Range Not Satisfiable', {
|
|
220
256
|
status: 416,
|
|
221
|
-
headers:
|
|
222
|
-
contentRange: { unit: 'bytes', size: file.size },
|
|
257
|
+
headers: buildResponseHeaders({
|
|
258
|
+
contentRange: ContentRange.from({ unit: 'bytes', size: file.size }),
|
|
223
259
|
}),
|
|
224
260
|
})
|
|
225
261
|
}
|
|
@@ -230,8 +266,8 @@ export async function createFileResponse(
|
|
|
230
266
|
if (normalizedRanges.length > 1) {
|
|
231
267
|
return new Response('Range Not Satisfiable', {
|
|
232
268
|
status: 416,
|
|
233
|
-
headers:
|
|
234
|
-
contentRange: { unit: 'bytes', size: file.size },
|
|
269
|
+
headers: buildResponseHeaders({
|
|
270
|
+
contentRange: ContentRange.from({ unit: 'bytes', size: file.size }),
|
|
235
271
|
}),
|
|
236
272
|
})
|
|
237
273
|
}
|
|
@@ -239,54 +275,75 @@ export async function createFileResponse(
|
|
|
239
275
|
let { start, end } = normalizedRanges[0]
|
|
240
276
|
let { size } = file
|
|
241
277
|
|
|
242
|
-
return new Response(file.slice(start, end + 1), {
|
|
278
|
+
return new Response(file.slice(start, end + 1).stream(), {
|
|
243
279
|
status: 206,
|
|
244
|
-
headers:
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}),
|
|
254
|
-
),
|
|
280
|
+
headers: buildResponseHeaders({
|
|
281
|
+
contentType,
|
|
282
|
+
contentLength: end - start + 1,
|
|
283
|
+
contentRange: { unit: 'bytes', start, end, size },
|
|
284
|
+
etag,
|
|
285
|
+
lastModified,
|
|
286
|
+
cacheControl,
|
|
287
|
+
acceptRanges,
|
|
288
|
+
}),
|
|
255
289
|
})
|
|
256
290
|
}
|
|
257
291
|
}
|
|
258
292
|
|
|
259
|
-
return new Response(request.method === 'HEAD' ? null : file, {
|
|
293
|
+
return new Response(request.method === 'HEAD' ? null : file.stream(), {
|
|
260
294
|
status: 200,
|
|
261
|
-
headers:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
}),
|
|
270
|
-
),
|
|
295
|
+
headers: buildResponseHeaders({
|
|
296
|
+
contentType,
|
|
297
|
+
contentLength,
|
|
298
|
+
etag,
|
|
299
|
+
lastModified,
|
|
300
|
+
cacheControl,
|
|
301
|
+
acceptRanges,
|
|
302
|
+
}),
|
|
271
303
|
})
|
|
272
304
|
}
|
|
273
305
|
|
|
274
|
-
function generateWeakETag(file:
|
|
306
|
+
function generateWeakETag(file: FileLike): string {
|
|
275
307
|
return `W/"${file.size}-${file.lastModified}"`
|
|
276
308
|
}
|
|
277
309
|
|
|
278
|
-
|
|
279
|
-
|
|
310
|
+
interface ResponseHeaderValues {
|
|
311
|
+
contentType?: string
|
|
312
|
+
contentLength?: number
|
|
313
|
+
contentRange?: ContentRangeInit
|
|
314
|
+
etag?: string
|
|
315
|
+
lastModified?: number
|
|
316
|
+
cacheControl?: string
|
|
317
|
+
acceptRanges?: 'bytes'
|
|
280
318
|
}
|
|
281
319
|
|
|
282
|
-
function
|
|
283
|
-
let
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
320
|
+
function buildResponseHeaders(values: ResponseHeaderValues): Headers {
|
|
321
|
+
let headers = new Headers()
|
|
322
|
+
|
|
323
|
+
if (values.contentType) {
|
|
324
|
+
headers.set('Content-Type', values.contentType)
|
|
325
|
+
}
|
|
326
|
+
if (values.contentLength != null) {
|
|
327
|
+
headers.set('Content-Length', String(values.contentLength))
|
|
328
|
+
}
|
|
329
|
+
if (values.contentRange) {
|
|
330
|
+
let str = ContentRange.from(values.contentRange).toString()
|
|
331
|
+
if (str) headers.set('Content-Range', str)
|
|
332
|
+
}
|
|
333
|
+
if (values.etag) {
|
|
334
|
+
headers.set('ETag', values.etag)
|
|
335
|
+
}
|
|
336
|
+
if (values.lastModified != null) {
|
|
337
|
+
headers.set('Last-Modified', new Date(values.lastModified).toUTCString())
|
|
288
338
|
}
|
|
289
|
-
|
|
339
|
+
if (values.cacheControl) {
|
|
340
|
+
headers.set('Cache-Control', values.cacheControl)
|
|
341
|
+
}
|
|
342
|
+
if (values.acceptRanges) {
|
|
343
|
+
headers.set('Accept-Ranges', values.acceptRanges)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return headers
|
|
290
347
|
}
|
|
291
348
|
|
|
292
349
|
/**
|
|
@@ -296,9 +353,9 @@ function omitNullableValues<T extends Record<string, any>>(headers: T): OmitNull
|
|
|
296
353
|
* @param digestOption Web Crypto algorithm name or custom digest function
|
|
297
354
|
* @returns The computed digest as a hex string
|
|
298
355
|
*/
|
|
299
|
-
async function computeDigest(
|
|
300
|
-
file:
|
|
301
|
-
digestOption: AlgorithmIdentifier | FileDigestFunction
|
|
356
|
+
async function computeDigest<file extends FileLike>(
|
|
357
|
+
file: file,
|
|
358
|
+
digestOption: AlgorithmIdentifier | FileDigestFunction<file>,
|
|
302
359
|
): Promise<string> {
|
|
303
360
|
return typeof digestOption === 'function'
|
|
304
361
|
? await digestOption(file)
|
|
@@ -315,7 +372,10 @@ async function computeDigest(
|
|
|
315
372
|
* @param algorithm Web Crypto API algorithm name (default: 'SHA-256')
|
|
316
373
|
* @returns The hash as a hex string
|
|
317
374
|
*/
|
|
318
|
-
async function hashFile
|
|
375
|
+
async function hashFile<F extends FileLike>(
|
|
376
|
+
file: F,
|
|
377
|
+
algorithm: AlgorithmIdentifier = 'SHA-256',
|
|
378
|
+
): Promise<string> {
|
|
319
379
|
let buffer = await file.arrayBuffer()
|
|
320
380
|
let hashBuffer = await crypto.subtle.digest(algorithm, buffer)
|
|
321
381
|
return Array.from(new Uint8Array(hashBuffer))
|
|
@@ -326,6 +386,9 @@ async function hashFile(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'):
|
|
|
326
386
|
/**
|
|
327
387
|
* Removes milliseconds from a timestamp, returning seconds.
|
|
328
388
|
* HTTP dates only have second precision, so this is useful for date comparisons.
|
|
389
|
+
*
|
|
390
|
+
* @param time The timestamp or Date to truncate
|
|
391
|
+
* @returns The timestamp in seconds (milliseconds removed)
|
|
329
392
|
*/
|
|
330
393
|
function removeMilliseconds(time: number | Date): number {
|
|
331
394
|
let timestamp = time instanceof Date ? time.getTime() : time
|
package/src/lib/redirect.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
|
|
3
3
|
*
|
|
4
|
+
* @alias redirect
|
|
4
5
|
* @param location The location to redirect to
|
|
5
6
|
* @param init The `ResponseInit` object for the response, or a status code
|
|
6
7
|
* @returns A `Response` object with a redirect header
|
package/src/redirect.ts
CHANGED