@remix-run/response 0.2.0 → 0.3.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 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 { openFile } from '@remix-run/fs'
38
+ import { openLazyFile } from '@remix-run/fs'
39
39
 
40
- let file = await openFile('./public/image.jpg')
41
- let response = await createFileResponse(file, request, {
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
  ```
@@ -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,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"}
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"}
@@ -1,5 +1,5 @@
1
1
  import { constants, createBrotliCompress, createDeflate, createGzip, } from 'node:zlib';
2
- import { AcceptEncoding, SuperHeaders } from '@remix-run/headers';
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 SuperHeaders(response.headers);
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
- responseHeaders.contentEncoding != null ||
44
+ contentEncodingHeader != null ||
40
45
  // Content-Length below threshold
41
- (responseHeaders.contentLength != null && responseHeaders.contentLength < threshold) ||
46
+ (contentLength != null && contentLength < threshold) ||
42
47
  // Cache-Control: no-transform
43
- responseHeaders.cacheControl.noTransform ||
48
+ cacheControl.noTransform ||
44
49
  // Response advertising range support
45
- responseHeaders.acceptRanges === 'bytes' ||
50
+ acceptRangesHeader === 'bytes' ||
46
51
  // Partial content responses
47
52
  response.status === 206) {
48
53
  return response;
49
54
  }
50
- let acceptEncoding = new AcceptEncoding(acceptEncodingHeader);
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.contentEncoding = encoding;
87
- headers.acceptRanges = 'none';
88
- headers.contentLength = null;
89
- headers.vary.add('Accept-Encoding');
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
- if (headers.etag && !headers.etag.startsWith('W/')) {
92
- headers.etag = `W/${headers.etag}`;
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 contentType = response.headers.get('Content-Type');
107
- let mediaType = contentType?.split(';')[0].trim();
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;
@@ -1,8 +1,29 @@
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
  *
4
25
  * @param file The file to hash
5
- * @return The computed digest as a string
26
+ * @returns The computed digest as a string
6
27
  *
7
28
  * @example
8
29
  * async (file) => {
@@ -10,11 +31,11 @@
10
31
  * return customHash(buffer)
11
32
  * }
12
33
  */
13
- export type FileDigestFunction = (file: File) => Promise<string>;
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 File and returns the digest string
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
- * @param file The file to send
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
- * @return A `Response` object containing the file
102
+ * @returns A `Response` object containing the file
80
103
  *
81
104
  * @example
82
105
  * import { createFileResponse } from '@remix-run/response/file'
83
- * let file = openFile('./public/image.jpg')
84
- * return createFileResponse(file, request, {
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: File, request: Request, options?: FileResponseOptions): Promise<Response>;
113
+ export declare function createFileResponse<file extends FileLike>(file: file, request: Request, options?: FileResponseOptions<file>): Promise<Response>;
89
114
  //# sourceMappingURL=file.d.ts.map
@@ -1 +1 @@
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"}
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,25 +1,29 @@
1
- import SuperHeaders from '@remix-run/headers';
2
- import { isCompressibleMimeType } from '@remix-run/mime';
1
+ import { ContentRange, IfMatch, IfNoneMatch, IfRange, Range, } from '@remix-run/headers';
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
- * @param file The file to send
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
- * @return A `Response` object containing the file
12
+ * @returns A `Response` object containing the file
11
13
  *
12
14
  * @example
13
15
  * import { createFileResponse } from '@remix-run/response/file'
14
- * let file = openFile('./public/image.jpg')
15
- * return createFileResponse(file, request, {
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 = new SuperHeaders(request.headers);
22
- let contentType = file.type;
25
+ let headers = request.headers;
26
+ let contentType = mimeTypeToContentType(file.type);
23
27
  let contentLength = file.size;
24
28
  let etag;
25
29
  if (etagStrategy === 'weak') {
@@ -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 && !headers.ifMatch.matches(etag)) {
46
- return new Response('Precondition Failed', {
47
- status: 412,
48
- headers: new SuperHeaders(omitNullableValues({
49
- etag,
50
- lastModified,
51
- acceptRanges,
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 ifUnmodifiedSince = headers.ifUnmodifiedSince;
58
- if (ifUnmodifiedSince != null) {
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: new SuperHeaders(omitNullableValues({
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
- if (etag && headers.ifNoneMatch.matches(etag)) {
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 && headers.ifNoneMatch.tags.length === 0) {
79
- let ifModifiedSince = headers.ifModifiedSince;
80
- if (ifModifiedSince != null) {
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: new SuperHeaders(omitNullableValues({
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
- if (headers.ifRange.matches({
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: new SuperHeaders({
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: new SuperHeaders({
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: new SuperHeaders(omitNullableValues({
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: new SuperHeaders(omitNullableValues({
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 omitNullableValues(headers) {
162
- let result = {};
163
- for (let key in headers) {
164
- if (headers[key] != null) {
165
- result[key] = headers[key];
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
- return result;
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;
@@ -6,7 +6,7 @@ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Ar
6
6
  *
7
7
  * @param body The body of the response
8
8
  * @param init The `ResponseInit` object for the response
9
- * @return A `Response` object with a HTML body and the appropriate `Content-Type` header
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
@@ -6,7 +6,7 @@ const DOCTYPE = '<!DOCTYPE html>';
6
6
  *
7
7
  * @param body The body of the response
8
8
  * @param init The `ResponseInit` object for the response
9
- * @return A `Response` object with a HTML body and the appropriate `Content-Type` header
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);
@@ -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;;;;;;GAMG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,GAAG,GAAG,EACtB,IAAI,CAAC,EAAE,YAAY,GAAG,MAAM,GAC3B,QAAQ,CAaV"}
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"}
@@ -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,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
@@ -1 +1 @@
1
- {"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../src/redirect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
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 } from "./lib/redirect.js";
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.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Response helpers for the web Fetch API",
5
5
  "author": "Michael Jackson <mjijackson@gmail.com>",
6
6
  "license": "MIT",
@@ -39,13 +39,14 @@
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/node": "^24.6.0",
42
- "typescript": "^5.9.3",
43
- "@remix-run/mime": "0.1.0"
42
+ "@typescript/native-preview": "7.0.0-dev.20251125.1",
43
+ "@remix-run/lazy-file": "5.0.0",
44
+ "@remix-run/mime": "0.3.0"
44
45
  },
45
46
  "peerDependencies": {
46
- "@remix-run/headers": "^0.18.0",
47
+ "@remix-run/headers": "^0.19.0",
47
48
  "@remix-run/html-template": "^0.3.0",
48
- "@remix-run/mime": "^0.1.0"
49
+ "@remix-run/mime": "^0.3.0"
49
50
  },
50
51
  "keywords": [
51
52
  "fetch",
@@ -56,9 +57,9 @@
56
57
  "redirect"
57
58
  ],
58
59
  "scripts": {
59
- "build": "tsc -p tsconfig.build.json",
60
+ "build": "tsgo -p tsconfig.build.json",
60
61
  "clean": "git clean -fdX",
61
- "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
62
- "typecheck": "tsc --noEmit"
62
+ "test": "node --disable-warning=ExperimentalWarning --test",
63
+ "typecheck": "tsgo --noEmit"
63
64
  }
64
65
  }
@@ -9,7 +9,7 @@ import {
9
9
  } from 'node:zlib'
10
10
  import type { BrotliOptions, ZlibOptions } from 'node:zlib'
11
11
 
12
- import { AcceptEncoding, SuperHeaders } from '@remix-run/headers'
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 SuperHeaders(response.headers)
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
- responseHeaders.contentEncoding != null ||
101
+ contentEncodingHeader != null ||
96
102
  // Content-Length below threshold
97
- (responseHeaders.contentLength != null && responseHeaders.contentLength < threshold) ||
103
+ (contentLength != null && contentLength < threshold) ||
98
104
  // Cache-Control: no-transform
99
- responseHeaders.cacheControl.noTransform ||
105
+ cacheControl.noTransform ||
100
106
  // Response advertising range support
101
- responseHeaders.acceptRanges === 'bytes' ||
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 = new AcceptEncoding(acceptEncodingHeader)
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: SuperHeaders, encoding: string): void {
159
- headers.contentEncoding = encoding
160
- headers.acceptRanges = 'none'
161
- headers.contentLength = null
162
- headers.vary.add('Accept-Encoding')
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
- if (headers.etag && !headers.etag.startsWith('W/')) {
166
- headers.etag = `W/${headers.etag}`
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: SuperHeaders,
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 contentType = response.headers.get('Content-Type')
190
- let mediaType = contentType?.split(';')[0].trim()
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,11 +1,42 @@
1
- import SuperHeaders from '@remix-run/headers'
2
- import { isCompressibleMimeType } from '@remix-run/mime'
1
+ import {
2
+ type ContentRangeInit,
3
+ ContentRange,
4
+ IfMatch,
5
+ IfNoneMatch,
6
+ IfRange,
7
+ Range,
8
+ } from '@remix-run/headers'
9
+ import { isCompressibleMimeType, mimeTypeToContentType } from '@remix-run/mime'
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
+ }
3
34
 
4
35
  /**
5
36
  * Custom function for computing file digests.
6
37
  *
7
38
  * @param file The file to hash
8
- * @return The computed digest as a string
39
+ * @returns The computed digest as a string
9
40
  *
10
41
  * @example
11
42
  * async (file) => {
@@ -13,12 +44,12 @@ import { isCompressibleMimeType } from '@remix-run/mime'
13
44
  * return customHash(buffer)
14
45
  * }
15
46
  */
16
- export type FileDigestFunction = (file: File) => Promise<string>
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 File and returns the digest string
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
- * @param file The file to send
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
- * @return A `Response` object containing the file
117
+ * @returns A `Response` object containing the file
85
118
  *
86
119
  * @example
87
120
  * import { createFileResponse } from '@remix-run/response/file'
88
- * let file = openFile('./public/image.jpg')
89
- * return createFileResponse(file, request, {
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: 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,9 +138,9 @@ export async function createFileResponse(
103
138
  acceptRanges: acceptRangesOption,
104
139
  } = options
105
140
 
106
- let headers = new SuperHeaders(request.headers)
141
+ let headers = request.headers
107
142
 
108
- let contentType = file.type
143
+ let contentType = mimeTypeToContentType(file.type)
109
144
  let contentLength = file.size
110
145
 
111
146
  let etag: string | undefined
@@ -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 && !headers.ifMatch.matches(etag)) {
138
- return new Response('Precondition Failed', {
139
- status: 412,
140
- headers: new SuperHeaders(
141
- omitNullableValues({
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 ifUnmodifiedSince = headers.ifUnmodifiedSince
153
- if (ifUnmodifiedSince != null) {
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: new SuperHeaders(
158
- omitNullableValues({
159
- etag,
160
- lastModified,
161
- acceptRanges,
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 && headers.ifNoneMatch.matches(etag)) {
210
+ if (etag && ifNoneMatch.matches(etag)) {
175
211
  shouldReturnNotModified = true
176
- } else if (lastModified && headers.ifNoneMatch.tags.length === 0) {
177
- let ifModifiedSince = headers.ifModifiedSince
178
- if (ifModifiedSince != null) {
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: new SuperHeaders(
189
- omitNullableValues({
190
- etag,
191
- lastModified,
192
- acceptRanges,
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
- headers.ifRange.matches({
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: new SuperHeaders({
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: new SuperHeaders({
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: new SuperHeaders(
245
- omitNullableValues({
246
- contentType,
247
- contentLength: end - start + 1,
248
- contentRange: { unit: 'bytes', start, end, size },
249
- etag,
250
- lastModified,
251
- cacheControl,
252
- acceptRanges,
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: new SuperHeaders(
262
- omitNullableValues({
263
- contentType,
264
- contentLength,
265
- etag,
266
- lastModified,
267
- cacheControl,
268
- acceptRanges,
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: File): string {
306
+ function generateWeakETag(file: FileLike): string {
275
307
  return `W/"${file.size}-${file.lastModified}"`
276
308
  }
277
309
 
278
- type OmitNullableValues<T> = {
279
- [K in keyof T as T[K] extends null | undefined ? never : K]: NonNullable<T[K]>
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 omitNullableValues<T extends Record<string, any>>(headers: T): OmitNullableValues<T> {
283
- let result: any = {}
284
- for (let key in headers) {
285
- if (headers[key] != null) {
286
- result[key] = headers[key]
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
- return result
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: 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(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'): Promise<string> {
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/html.ts CHANGED
@@ -10,7 +10,7 @@ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Ar
10
10
  *
11
11
  * @param body The body of the response
12
12
  * @param init The `ResponseInit` object for the response
13
- * @return A `Response` object with a HTML body and the appropriate `Content-Type` header
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)
@@ -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
@@ -1 +1,4 @@
1
- export { createRedirectResponse } from './lib/redirect.ts'
1
+ export {
2
+ createRedirectResponse,
3
+ createRedirectResponse as redirect, // shorthand
4
+ } from './lib/redirect.ts'