@remix-run/response 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Shopify Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,175 @@
1
+ # response
2
+
3
+ Response helpers for the web Fetch API. `response` provides a collection of helper functions for creating common HTTP responses with proper headers and semantics.
4
+
5
+ Basically, these are all the static response helpers we wish existed on the `Response` API, but don't (yet!).
6
+
7
+ ## Features
8
+
9
+ - **Web Standards Compliant:** Built on the standard `Response` API, works in any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers)
10
+ - [**File Responses:**](#file-responses) Full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support
11
+ - [**HTML Responses:**](#html-responses) Automatic DOCTYPE prepending and proper Content-Type headers
12
+ - [**Redirect Responses:**](#redirect-responses) Simple redirect creation with customizable status codes
13
+
14
+ ## Installation
15
+
16
+ ```sh
17
+ npm install @remix-run/response
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ This package provides no default export. Instead, import the specific helper you need:
23
+
24
+ ```ts
25
+ import { createFileResponse } from '@remix-run/response/file'
26
+ import { createHtmlResponse } from '@remix-run/response/html'
27
+ import { createRedirectResponse } from '@remix-run/response/redirect'
28
+ ```
29
+
30
+ ### File Responses
31
+
32
+ The `createFileResponse` helper creates a response for serving files with full HTTP semantics:
33
+
34
+ ```ts
35
+ import { createFileResponse } from '@remix-run/response/file'
36
+ import { openFile } from '@remix-run/fs'
37
+
38
+ let file = await openFile('./public/image.jpg')
39
+ let response = await createFileResponse(file, request, {
40
+ cacheControl: 'public, max-age=3600',
41
+ })
42
+ ```
43
+
44
+ #### Features
45
+
46
+ - **Content-Type** and **Content-Length** headers
47
+ - **ETag** generation (weak or strong)
48
+ - **Last-Modified** headers
49
+ - **Cache-Control** headers
50
+ - **Conditional requests** (`If-None-Match`, `If-Modified-Since`, `If-Match`, `If-Unmodified-Since`)
51
+ - **Range requests** for partial content (`206 Partial Content`)
52
+ - **HEAD** request support
53
+
54
+ #### Options
55
+
56
+ ```ts
57
+ await createFileResponse(file, request, {
58
+ // Cache-Control header value.
59
+ // Defaults to `undefined` (no Cache-Control header).
60
+ cacheControl: 'public, max-age=3600',
61
+
62
+ // ETag generation strategy:
63
+ // - 'weak': Generates weak ETags based on file size and mtime (default)
64
+ // - 'strong': Generates strong ETags by hashing file content
65
+ // - false: Disables ETag generation
66
+ etag: 'weak',
67
+
68
+ // Hash algorithm for strong ETags (Web Crypto API algorithm names).
69
+ // Only used when etag: 'strong'.
70
+ // Defaults to 'SHA-256'.
71
+ digest: 'SHA-256',
72
+
73
+ // Whether to generate Last-Modified headers.
74
+ // Defaults to `true`.
75
+ lastModified: true,
76
+
77
+ // Whether to support HTTP Range requests for partial content.
78
+ // Defaults to `true`.
79
+ acceptRanges: true,
80
+ })
81
+ ```
82
+
83
+ #### Strong ETags and Content Hashing
84
+
85
+ For assets that require strong validation (e.g., to support [`If-Match`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Match) preconditions or [`If-Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Range) with [`Range` requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range)), configure strong ETag generation:
86
+
87
+ ```ts
88
+ return createFileResponse(file, request, {
89
+ etag: 'strong',
90
+ })
91
+ ```
92
+
93
+ By default, strong ETags are generated using the Web Crypto API with the `'SHA-256'` algorithm. You can customize this:
94
+
95
+ ```ts
96
+ return createFileResponse(file, request, {
97
+ etag: 'strong',
98
+ // Specify a different hash algorithm
99
+ digest: 'SHA-512',
100
+ })
101
+ ```
102
+
103
+ For large files or custom hashing requirements, provide a custom digest function:
104
+
105
+ ```ts
106
+ await createFileResponse(file, request, {
107
+ etag: 'strong',
108
+ async digest(file) {
109
+ // Custom streaming hash for large files
110
+ let { createHash } = await import('node:crypto')
111
+ let hash = createHash('sha256')
112
+ for await (let chunk of file.stream()) {
113
+ hash.update(chunk)
114
+ }
115
+ return hash.digest('hex')
116
+ },
117
+ })
118
+ ```
119
+
120
+ ### HTML Responses
121
+
122
+ The `createHtmlResponse` helper creates HTML responses with proper `Content-Type` and DOCTYPE handling:
123
+
124
+ ```ts
125
+ import { createHtmlResponse } from '@remix-run/response/html'
126
+
127
+ let response = createHtmlResponse('<h1>Hello, World!</h1>')
128
+ // Content-Type: text/html; charset=UTF-8
129
+ // Body: <!DOCTYPE html><h1>Hello, World!</h1>
130
+ ```
131
+
132
+ The helper automatically prepends `<!DOCTYPE html>` if not already present. It works with strings, `SafeHtml` [from `@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template), Blobs/Files, ArrayBuffers, and ReadableStreams.
133
+
134
+ ```ts
135
+ import { html } from '@remix-run/html-template'
136
+ import { createHtmlResponse } from '@remix-run/response/html'
137
+
138
+ let name = '<script>alert(1)</script>'
139
+ let response = createHtmlResponse(html`<h1>Hello, ${name}!</h1>`)
140
+ // Safely escaped HTML
141
+ ```
142
+
143
+ ### Redirect Responses
144
+
145
+ The `createRedirectResponse` helper creates redirect responses. The main improvements over the native `Response.redirect` API are:
146
+
147
+ - Accepts a relative `location` instead of a full URL. This isn't technically spec-compliant, but it's so widespread that many applications use relative redirects regularly without issues.
148
+ - Accepts a `ResponseInit` object as the second argument, allowing you to set additional headers and status code.
149
+
150
+ ```ts
151
+ import { createRedirectResponse } from '@remix-run/response/redirect'
152
+
153
+ // Default 302 redirect
154
+ let response = createRedirectResponse('/login')
155
+
156
+ // Custom status code
157
+ let response = createRedirectResponse('/new-page', 301)
158
+
159
+ // With additional headers
160
+ let response = createRedirectResponse('/dashboard', {
161
+ status: 303,
162
+ headers: { 'X-Redirect-Reason': 'authentication' },
163
+ })
164
+ ```
165
+
166
+ ## Related Packages
167
+
168
+ - [`@remix-run/headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Type-safe HTTP header manipulation
169
+ - [`@remix-run/html-template`](https://github.com/remix-run/remix/tree/main/packages/html-template) - Safe HTML templating with automatic escaping
170
+ - [`@remix-run/fs`](https://github.com/remix-run/remix/tree/main/packages/fs) - File system utilities including `openFile`
171
+ - [`@remix-run/fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Build HTTP routers using the web fetch API
172
+
173
+ ## License
174
+
175
+ See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
package/dist/file.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createFileResponse, type FileDigestFunction, type FileResponseOptions, } from './lib/file.ts';
2
+ //# sourceMappingURL=file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,eAAe,CAAA"}
package/dist/file.js ADDED
@@ -0,0 +1 @@
1
+ export { createFileResponse, } from "./lib/file.js";
package/dist/html.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { createHtmlResponse } from './lib/html.ts';
2
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../src/html.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA"}
package/dist/html.js ADDED
@@ -0,0 +1 @@
1
+ export { createHtmlResponse } from "./lib/html.js";
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Custom function for computing file digests.
3
+ *
4
+ * @param file The file to hash
5
+ * @returns The computed digest as a string
6
+ *
7
+ * @example
8
+ * async (file) => {
9
+ * let buffer = await file.arrayBuffer()
10
+ * return customHash(buffer)
11
+ * }
12
+ */
13
+ export type FileDigestFunction = (file: File) => Promise<string>;
14
+ export interface FileResponseOptions {
15
+ /**
16
+ * Cache-Control header value. If not provided, no Cache-Control header will be set.
17
+ *
18
+ * @example 'public, max-age=31536000, immutable' // for hashed assets
19
+ * @example 'public, max-age=3600' // 1 hour
20
+ * @example 'no-cache' // always revalidate
21
+ */
22
+ cacheControl?: string;
23
+ /**
24
+ * ETag generation strategy.
25
+ *
26
+ * - `'weak'`: Generates weak ETags based on file size and last modified time (`W/"<size>-<mtime>"`)
27
+ * - `'strong'`: Generates strong ETags by hashing file content (requires digest computation)
28
+ * - `false`: Disables ETag generation
29
+ *
30
+ * @default 'weak'
31
+ */
32
+ etag?: false | 'weak' | 'strong';
33
+ /**
34
+ * Hash algorithm or custom digest function for strong ETags.
35
+ *
36
+ * - String: Web Crypto API algorithm name ('SHA-256', 'SHA-384', 'SHA-512', 'SHA-1').
37
+ * Note: Using strong ETags will buffer the entire file into memory before hashing.
38
+ * Consider using weak ETags (default) or a custom digest function for large files.
39
+ * - Function: Custom digest computation that receives a File and returns the digest string
40
+ *
41
+ * Only used when `etag: 'strong'`. Ignored for weak ETags.
42
+ *
43
+ * @default 'SHA-256'
44
+ * @example async (file) => await customHash(file)
45
+ */
46
+ digest?: AlgorithmIdentifier | FileDigestFunction;
47
+ /**
48
+ * Whether to include `Last-Modified` headers.
49
+ *
50
+ * @default true
51
+ */
52
+ lastModified?: boolean;
53
+ /**
54
+ * Whether to support HTTP `Range` requests for partial content.
55
+ *
56
+ * When enabled, includes `Accept-Ranges` header and handles `Range` requests
57
+ * with 206 Partial Content responses.
58
+ *
59
+ * @default true
60
+ */
61
+ acceptRanges?: boolean;
62
+ }
63
+ /**
64
+ * Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
65
+ * with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
66
+ *
67
+ * @param file The file to send
68
+ * @param request The request object
69
+ * @param options (optional) configuration options
70
+ * @returns A `Response` object containing the file
71
+ *
72
+ * @example
73
+ * import { createFileResponse } from '@remix-run/response/file'
74
+ * let file = openFile('./public/image.jpg')
75
+ * return createFileResponse(file, request, {
76
+ * cacheControl: 'public, max-age=3600'
77
+ * })
78
+ */
79
+ export declare function createFileResponse(file: File, request: Request, options?: FileResponseOptions): Promise<Response>;
80
+ //# sourceMappingURL=file.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../../src/lib/file.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;AAEhE,MAAM,WAAW,mBAAmB;IAClC;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;;;OAQG;IACH,IAAI,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,QAAQ,CAAA;IAChC;;;;;;;;;;;;OAYG;IACH,MAAM,CAAC,EAAE,mBAAmB,GAAG,kBAAkB,CAAA;IACjD;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,IAAI,EACV,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,QAAQ,CAAC,CA0KnB"}
@@ -0,0 +1,202 @@
1
+ import SuperHeaders from '@remix-run/headers';
2
+ /**
3
+ * Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
4
+ * with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
5
+ *
6
+ * @param file The file to send
7
+ * @param request The request object
8
+ * @param options (optional) configuration options
9
+ * @returns A `Response` object containing the file
10
+ *
11
+ * @example
12
+ * import { createFileResponse } from '@remix-run/response/file'
13
+ * let file = openFile('./public/image.jpg')
14
+ * return createFileResponse(file, request, {
15
+ * cacheControl: 'public, max-age=3600'
16
+ * })
17
+ */
18
+ export async function createFileResponse(file, request, options = {}) {
19
+ let { cacheControl, etag: etagStrategy = 'weak', digest: digestOption = 'SHA-256', lastModified: lastModifiedEnabled = true, acceptRanges: acceptRangesEnabled = true, } = options;
20
+ let headers = new SuperHeaders(request.headers);
21
+ let contentType = file.type;
22
+ let contentLength = file.size;
23
+ let etag;
24
+ if (etagStrategy === 'weak') {
25
+ etag = generateWeakETag(file);
26
+ }
27
+ else if (etagStrategy === 'strong') {
28
+ let digest = await computeDigest(file, digestOption);
29
+ etag = `"${digest}"`;
30
+ }
31
+ let lastModified;
32
+ if (lastModifiedEnabled) {
33
+ lastModified = file.lastModified;
34
+ }
35
+ let acceptRanges;
36
+ if (acceptRangesEnabled) {
37
+ acceptRanges = 'bytes';
38
+ }
39
+ let hasIfMatch = headers.has('If-Match');
40
+ // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match
41
+ if (etag && hasIfMatch && !headers.ifMatch.matches(etag)) {
42
+ return new Response('Precondition Failed', {
43
+ status: 412,
44
+ headers: new SuperHeaders(omitNullableValues({
45
+ etag,
46
+ lastModified,
47
+ acceptRanges,
48
+ })),
49
+ });
50
+ }
51
+ // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since
52
+ if (lastModified && !hasIfMatch) {
53
+ let ifUnmodifiedSince = headers.ifUnmodifiedSince;
54
+ if (ifUnmodifiedSince != null) {
55
+ if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) {
56
+ return new Response('Precondition Failed', {
57
+ status: 412,
58
+ headers: new SuperHeaders(omitNullableValues({
59
+ etag,
60
+ lastModified,
61
+ acceptRanges,
62
+ })),
63
+ });
64
+ }
65
+ }
66
+ }
67
+ // If-None-Match support: https://httpwg.org/specs/rfc9110.html#field.if-none-match
68
+ // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since
69
+ if (etag || lastModified) {
70
+ let shouldReturnNotModified = false;
71
+ if (etag && headers.ifNoneMatch.matches(etag)) {
72
+ shouldReturnNotModified = true;
73
+ }
74
+ else if (lastModified && headers.ifNoneMatch.tags.length === 0) {
75
+ let ifModifiedSince = headers.ifModifiedSince;
76
+ if (ifModifiedSince != null) {
77
+ if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) {
78
+ shouldReturnNotModified = true;
79
+ }
80
+ }
81
+ }
82
+ if (shouldReturnNotModified) {
83
+ return new Response(null, {
84
+ status: 304,
85
+ headers: new SuperHeaders(omitNullableValues({
86
+ etag,
87
+ lastModified,
88
+ acceptRanges,
89
+ })),
90
+ });
91
+ }
92
+ }
93
+ // Range support: https://httpwg.org/specs/rfc9110.html#field.range
94
+ // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
95
+ if (acceptRanges && request.method === 'GET' && headers.has('Range')) {
96
+ let range = headers.range;
97
+ // Check if the Range header was sent but parsing resulted in no valid ranges (malformed)
98
+ if (range.ranges.length === 0) {
99
+ return new Response('Bad Request', {
100
+ status: 400,
101
+ });
102
+ }
103
+ // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
104
+ if (headers.ifRange.matches({
105
+ etag,
106
+ lastModified,
107
+ })) {
108
+ if (!range.canSatisfy(file.size)) {
109
+ return new Response('Range Not Satisfiable', {
110
+ status: 416,
111
+ headers: new SuperHeaders({
112
+ contentRange: { unit: 'bytes', size: file.size },
113
+ }),
114
+ });
115
+ }
116
+ let normalizedRanges = range.normalize(file.size);
117
+ // We only support single ranges (not multipart)
118
+ if (normalizedRanges.length > 1) {
119
+ return new Response('Range Not Satisfiable', {
120
+ status: 416,
121
+ headers: new SuperHeaders({
122
+ contentRange: { unit: 'bytes', size: file.size },
123
+ }),
124
+ });
125
+ }
126
+ let { start, end } = normalizedRanges[0];
127
+ let { size } = file;
128
+ return new Response(file.slice(start, end + 1), {
129
+ status: 206,
130
+ headers: new SuperHeaders(omitNullableValues({
131
+ contentType,
132
+ contentLength: end - start + 1,
133
+ contentRange: { unit: 'bytes', start, end, size },
134
+ etag,
135
+ lastModified,
136
+ cacheControl,
137
+ acceptRanges,
138
+ })),
139
+ });
140
+ }
141
+ }
142
+ return new Response(request.method === 'HEAD' ? null : file, {
143
+ status: 200,
144
+ headers: new SuperHeaders(omitNullableValues({
145
+ contentType,
146
+ contentLength,
147
+ etag,
148
+ lastModified,
149
+ cacheControl,
150
+ acceptRanges,
151
+ })),
152
+ });
153
+ }
154
+ function generateWeakETag(file) {
155
+ return `W/"${file.size}-${file.lastModified}"`;
156
+ }
157
+ function omitNullableValues(headers) {
158
+ let result = {};
159
+ for (let key in headers) {
160
+ if (headers[key] != null) {
161
+ result[key] = headers[key];
162
+ }
163
+ }
164
+ return result;
165
+ }
166
+ /**
167
+ * Computes a digest (hash) for a file.
168
+ *
169
+ * @param file The file to hash
170
+ * @param digestOption Web Crypto algorithm name or custom digest function
171
+ * @returns The computed digest as a hex string
172
+ */
173
+ async function computeDigest(file, digestOption) {
174
+ return typeof digestOption === 'function'
175
+ ? await digestOption(file)
176
+ : await hashFile(file, digestOption);
177
+ }
178
+ /**
179
+ * Hashes a file using Web Crypto API.
180
+ *
181
+ * Note: This loads the entire file into memory before hashing. For large files,
182
+ * consider using weak ETags (default) or providing a custom digest function.
183
+ *
184
+ * @param file The file to hash
185
+ * @param algorithm Web Crypto API algorithm name (default: 'SHA-256')
186
+ * @returns The hash as a hex string
187
+ */
188
+ async function hashFile(file, algorithm = 'SHA-256') {
189
+ let buffer = await file.arrayBuffer();
190
+ let hashBuffer = await crypto.subtle.digest(algorithm, buffer);
191
+ return Array.from(new Uint8Array(hashBuffer))
192
+ .map((b) => b.toString(16).padStart(2, '0'))
193
+ .join('');
194
+ }
195
+ /**
196
+ * Removes milliseconds from a timestamp, returning seconds.
197
+ * HTTP dates only have second precision, so this is useful for date comparisons.
198
+ */
199
+ function removeMilliseconds(time) {
200
+ let timestamp = time instanceof Date ? time.getTime() : time;
201
+ return Math.floor(timestamp / 1000);
202
+ }
@@ -0,0 +1,13 @@
1
+ import { type SafeHtml } from '@remix-run/html-template';
2
+ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Array>;
3
+ /**
4
+ * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
5
+ * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
6
+ *
7
+ * @param body The body of the response.
8
+ * @param init (optional) The `ResponseInit` object for the response.
9
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
10
+ */
11
+ export declare function createHtmlResponse(body: HtmlBody, init?: ResponseInit): Response;
12
+ export {};
13
+ //# sourceMappingURL=html.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html.d.ts","sourceRoot":"","sources":["../../src/lib/html.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,QAAQ,EAAE,MAAM,0BAA0B,CAAA;AAIpE,KAAK,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,IAAI,GAAG,YAAY,GAAG,cAAc,CAAC,UAAU,CAAC,CAAA;AAEpF;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,QAAQ,EAAE,IAAI,CAAC,EAAE,YAAY,GAAG,QAAQ,CAShF"}
@@ -0,0 +1,81 @@
1
+ import { isSafeHtml } from '@remix-run/html-template';
2
+ const DOCTYPE = '<!DOCTYPE html>';
3
+ /**
4
+ * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
5
+ * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
6
+ *
7
+ * @param body The body of the response.
8
+ * @param init (optional) The `ResponseInit` object for the response.
9
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
10
+ */
11
+ export function createHtmlResponse(body, init) {
12
+ let payload = ensureDoctype(body);
13
+ let headers = new Headers(init?.headers);
14
+ if (!headers.has('Content-Type')) {
15
+ headers.set('Content-Type', 'text/html; charset=UTF-8');
16
+ }
17
+ return new Response(payload, { ...init, headers });
18
+ }
19
+ function ensureDoctype(body) {
20
+ if (isSafeHtml(body)) {
21
+ let str = String(body);
22
+ return startsWithDoctype(str) ? str : DOCTYPE + str;
23
+ }
24
+ if (typeof body === 'string') {
25
+ return startsWithDoctype(body) ? body : DOCTYPE + body;
26
+ }
27
+ if (body instanceof Blob) {
28
+ return prependDoctypeToStream(body.stream());
29
+ }
30
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
31
+ let text = new TextDecoder().decode(body);
32
+ return startsWithDoctype(text) ? text : DOCTYPE + text;
33
+ }
34
+ if (body instanceof ReadableStream) {
35
+ return prependDoctypeToStream(body);
36
+ }
37
+ return body;
38
+ }
39
+ function startsWithDoctype(str) {
40
+ return /^\s*<!doctype html/i.test(str);
41
+ }
42
+ function prependDoctypeToStream(stream) {
43
+ let doctypeBytes = new TextEncoder().encode(DOCTYPE);
44
+ let reader = stream.getReader();
45
+ return new ReadableStream({
46
+ async start(controller) {
47
+ try {
48
+ // Read first chunk to check for DOCTYPE
49
+ let firstChunk = await reader.read();
50
+ if (firstChunk.done) {
51
+ // Empty stream, just add DOCTYPE
52
+ controller.enqueue(doctypeBytes);
53
+ controller.close();
54
+ return;
55
+ }
56
+ // Check if the first chunk starts with DOCTYPE
57
+ let text = new TextDecoder().decode(firstChunk.value, { stream: true });
58
+ if (startsWithDoctype(text)) {
59
+ // Already has DOCTYPE, pass through
60
+ controller.enqueue(firstChunk.value);
61
+ }
62
+ else {
63
+ // Prepend DOCTYPE
64
+ controller.enqueue(doctypeBytes);
65
+ controller.enqueue(firstChunk.value);
66
+ }
67
+ // Pass through remaining chunks
68
+ while (true) {
69
+ let { done, value } = await reader.read();
70
+ if (done)
71
+ break;
72
+ controller.enqueue(value);
73
+ }
74
+ controller.close();
75
+ }
76
+ catch (error) {
77
+ controller.error(error);
78
+ }
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
+ *
4
+ * @param location The location to redirect to
5
+ * @param init (optional) The `ResponseInit` object for the response, or a status code
6
+ * @returns A `Response` object with a redirect header
7
+ */
8
+ export declare function createRedirectResponse(location: string | URL, init?: ResponseInit | number): Response;
9
+ //# sourceMappingURL=redirect.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
+ *
4
+ * @param location The location to redirect to
5
+ * @param init (optional) The `ResponseInit` object for the response, or a status code
6
+ * @returns A `Response` object with a redirect header
7
+ */
8
+ export function createRedirectResponse(location, init) {
9
+ let status = 302;
10
+ if (typeof init === 'number') {
11
+ status = init;
12
+ init = undefined;
13
+ }
14
+ let headers = new Headers(init?.headers);
15
+ if (!headers.has('Location')) {
16
+ headers.set('Location', typeof location === 'string' ? location : location.toString());
17
+ }
18
+ return new Response(null, { status, ...init, headers });
19
+ }
@@ -0,0 +1,2 @@
1
+ export { createRedirectResponse } from './lib/redirect.ts';
2
+ //# sourceMappingURL=redirect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../src/redirect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
@@ -0,0 +1 @@
1
+ export { createRedirectResponse } from "./lib/redirect.js";
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@remix-run/response",
3
+ "version": "0.0.0",
4
+ "description": "Response helpers for the web Fetch API",
5
+ "author": "Michael Jackson <mjijackson@gmail.com>",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/remix-run/remix.git",
10
+ "directory": "packages/response"
11
+ },
12
+ "homepage": "https://github.com/remix-run/remix/tree/main/packages/response#readme",
13
+ "files": [
14
+ "LICENSE",
15
+ "README.md",
16
+ "dist",
17
+ "src",
18
+ "!src/**/*.test.ts"
19
+ ],
20
+ "type": "module",
21
+ "exports": {
22
+ "./file": {
23
+ "types": "./dist/file.d.ts",
24
+ "default": "./dist/file.js"
25
+ },
26
+ "./html": {
27
+ "types": "./dist/html.d.ts",
28
+ "default": "./dist/html.js"
29
+ },
30
+ "./redirect": {
31
+ "types": "./dist/redirect.d.ts",
32
+ "default": "./dist/redirect.js"
33
+ },
34
+ "./package.json": "./package.json"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.6.0",
38
+ "typescript": "^5.9.3"
39
+ },
40
+ "peerDependencies": {
41
+ "@remix-run/headers": "^0.17.1",
42
+ "@remix-run/html-template": "^0.3.0"
43
+ },
44
+ "keywords": [
45
+ "fetch",
46
+ "response",
47
+ "http",
48
+ "html",
49
+ "file",
50
+ "redirect"
51
+ ],
52
+ "scripts": {
53
+ "build": "tsc -p tsconfig.build.json",
54
+ "clean": "git clean -fdX",
55
+ "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'",
56
+ "typecheck": "tsc --noEmit"
57
+ }
58
+ }
package/src/file.ts ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ createFileResponse,
3
+ type FileDigestFunction,
4
+ type FileResponseOptions,
5
+ } from './lib/file.ts'
package/src/html.ts ADDED
@@ -0,0 +1 @@
1
+ export { createHtmlResponse } from './lib/html.ts'
@@ -0,0 +1,318 @@
1
+ import SuperHeaders from '@remix-run/headers'
2
+
3
+ /**
4
+ * Custom function for computing file digests.
5
+ *
6
+ * @param file The file to hash
7
+ * @returns The computed digest as a string
8
+ *
9
+ * @example
10
+ * async (file) => {
11
+ * let buffer = await file.arrayBuffer()
12
+ * return customHash(buffer)
13
+ * }
14
+ */
15
+ export type FileDigestFunction = (file: File) => Promise<string>
16
+
17
+ export interface FileResponseOptions {
18
+ /**
19
+ * Cache-Control header value. If not provided, no Cache-Control header will be set.
20
+ *
21
+ * @example 'public, max-age=31536000, immutable' // for hashed assets
22
+ * @example 'public, max-age=3600' // 1 hour
23
+ * @example 'no-cache' // always revalidate
24
+ */
25
+ cacheControl?: string
26
+ /**
27
+ * ETag generation strategy.
28
+ *
29
+ * - `'weak'`: Generates weak ETags based on file size and last modified time (`W/"<size>-<mtime>"`)
30
+ * - `'strong'`: Generates strong ETags by hashing file content (requires digest computation)
31
+ * - `false`: Disables ETag generation
32
+ *
33
+ * @default 'weak'
34
+ */
35
+ etag?: false | 'weak' | 'strong'
36
+ /**
37
+ * Hash algorithm or custom digest function for strong ETags.
38
+ *
39
+ * - String: Web Crypto API algorithm name ('SHA-256', 'SHA-384', 'SHA-512', 'SHA-1').
40
+ * Note: Using strong ETags will buffer the entire file into memory before hashing.
41
+ * 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
43
+ *
44
+ * Only used when `etag: 'strong'`. Ignored for weak ETags.
45
+ *
46
+ * @default 'SHA-256'
47
+ * @example async (file) => await customHash(file)
48
+ */
49
+ digest?: AlgorithmIdentifier | FileDigestFunction
50
+ /**
51
+ * Whether to include `Last-Modified` headers.
52
+ *
53
+ * @default true
54
+ */
55
+ lastModified?: boolean
56
+ /**
57
+ * Whether to support HTTP `Range` requests for partial content.
58
+ *
59
+ * When enabled, includes `Accept-Ranges` header and handles `Range` requests
60
+ * with 206 Partial Content responses.
61
+ *
62
+ * @default true
63
+ */
64
+ acceptRanges?: boolean
65
+ }
66
+
67
+ /**
68
+ * Creates a file [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
69
+ * with full HTTP semantics including ETags, Last-Modified, conditional requests, and Range support.
70
+ *
71
+ * @param file The file to send
72
+ * @param request The request object
73
+ * @param options (optional) configuration options
74
+ * @returns A `Response` object containing the file
75
+ *
76
+ * @example
77
+ * import { createFileResponse } from '@remix-run/response/file'
78
+ * let file = openFile('./public/image.jpg')
79
+ * return createFileResponse(file, request, {
80
+ * cacheControl: 'public, max-age=3600'
81
+ * })
82
+ */
83
+ export async function createFileResponse(
84
+ file: File,
85
+ request: Request,
86
+ options: FileResponseOptions = {},
87
+ ): Promise<Response> {
88
+ let {
89
+ cacheControl,
90
+ etag: etagStrategy = 'weak',
91
+ digest: digestOption = 'SHA-256',
92
+ lastModified: lastModifiedEnabled = true,
93
+ acceptRanges: acceptRangesEnabled = true,
94
+ } = options
95
+
96
+ let headers = new SuperHeaders(request.headers)
97
+
98
+ let contentType = file.type
99
+ let contentLength = file.size
100
+
101
+ let etag: string | undefined
102
+ if (etagStrategy === 'weak') {
103
+ etag = generateWeakETag(file)
104
+ } else if (etagStrategy === 'strong') {
105
+ let digest = await computeDigest(file, digestOption)
106
+ etag = `"${digest}"`
107
+ }
108
+
109
+ let lastModified: number | undefined
110
+ if (lastModifiedEnabled) {
111
+ lastModified = file.lastModified
112
+ }
113
+
114
+ let acceptRanges: 'bytes' | undefined
115
+ if (acceptRangesEnabled) {
116
+ acceptRanges = 'bytes'
117
+ }
118
+
119
+ let hasIfMatch = headers.has('If-Match')
120
+
121
+ // If-Match support: https://httpwg.org/specs/rfc9110.html#field.if-match
122
+ if (etag && hasIfMatch && !headers.ifMatch.matches(etag)) {
123
+ return new Response('Precondition Failed', {
124
+ status: 412,
125
+ headers: new SuperHeaders(
126
+ omitNullableValues({
127
+ etag,
128
+ lastModified,
129
+ acceptRanges,
130
+ }),
131
+ ),
132
+ })
133
+ }
134
+
135
+ // If-Unmodified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-unmodified-since
136
+ if (lastModified && !hasIfMatch) {
137
+ let ifUnmodifiedSince = headers.ifUnmodifiedSince
138
+ if (ifUnmodifiedSince != null) {
139
+ if (removeMilliseconds(lastModified) > removeMilliseconds(ifUnmodifiedSince)) {
140
+ return new Response('Precondition Failed', {
141
+ status: 412,
142
+ headers: new SuperHeaders(
143
+ omitNullableValues({
144
+ etag,
145
+ lastModified,
146
+ acceptRanges,
147
+ }),
148
+ ),
149
+ })
150
+ }
151
+ }
152
+ }
153
+
154
+ // If-None-Match support: https://httpwg.org/specs/rfc9110.html#field.if-none-match
155
+ // If-Modified-Since support: https://httpwg.org/specs/rfc9110.html#field.if-modified-since
156
+ if (etag || lastModified) {
157
+ let shouldReturnNotModified = false
158
+
159
+ if (etag && headers.ifNoneMatch.matches(etag)) {
160
+ shouldReturnNotModified = true
161
+ } else if (lastModified && headers.ifNoneMatch.tags.length === 0) {
162
+ let ifModifiedSince = headers.ifModifiedSince
163
+ if (ifModifiedSince != null) {
164
+ if (removeMilliseconds(lastModified) <= removeMilliseconds(ifModifiedSince)) {
165
+ shouldReturnNotModified = true
166
+ }
167
+ }
168
+ }
169
+
170
+ if (shouldReturnNotModified) {
171
+ return new Response(null, {
172
+ status: 304,
173
+ headers: new SuperHeaders(
174
+ omitNullableValues({
175
+ etag,
176
+ lastModified,
177
+ acceptRanges,
178
+ }),
179
+ ),
180
+ })
181
+ }
182
+ }
183
+
184
+ // Range support: https://httpwg.org/specs/rfc9110.html#field.range
185
+ // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
186
+ if (acceptRanges && request.method === 'GET' && headers.has('Range')) {
187
+ let range = headers.range
188
+
189
+ // Check if the Range header was sent but parsing resulted in no valid ranges (malformed)
190
+ if (range.ranges.length === 0) {
191
+ return new Response('Bad Request', {
192
+ status: 400,
193
+ })
194
+ }
195
+
196
+ // If-Range support: https://httpwg.org/specs/rfc9110.html#field.if-range
197
+ if (
198
+ headers.ifRange.matches({
199
+ etag,
200
+ lastModified,
201
+ })
202
+ ) {
203
+ if (!range.canSatisfy(file.size)) {
204
+ return new Response('Range Not Satisfiable', {
205
+ status: 416,
206
+ headers: new SuperHeaders({
207
+ contentRange: { unit: 'bytes', size: file.size },
208
+ }),
209
+ })
210
+ }
211
+
212
+ let normalizedRanges = range.normalize(file.size)
213
+
214
+ // We only support single ranges (not multipart)
215
+ if (normalizedRanges.length > 1) {
216
+ return new Response('Range Not Satisfiable', {
217
+ status: 416,
218
+ headers: new SuperHeaders({
219
+ contentRange: { unit: 'bytes', size: file.size },
220
+ }),
221
+ })
222
+ }
223
+
224
+ let { start, end } = normalizedRanges[0]
225
+ let { size } = file
226
+
227
+ return new Response(file.slice(start, end + 1), {
228
+ status: 206,
229
+ headers: new SuperHeaders(
230
+ omitNullableValues({
231
+ contentType,
232
+ contentLength: end - start + 1,
233
+ contentRange: { unit: 'bytes', start, end, size },
234
+ etag,
235
+ lastModified,
236
+ cacheControl,
237
+ acceptRanges,
238
+ }),
239
+ ),
240
+ })
241
+ }
242
+ }
243
+
244
+ return new Response(request.method === 'HEAD' ? null : file, {
245
+ status: 200,
246
+ headers: new SuperHeaders(
247
+ omitNullableValues({
248
+ contentType,
249
+ contentLength,
250
+ etag,
251
+ lastModified,
252
+ cacheControl,
253
+ acceptRanges,
254
+ }),
255
+ ),
256
+ })
257
+ }
258
+
259
+ function generateWeakETag(file: File): string {
260
+ return `W/"${file.size}-${file.lastModified}"`
261
+ }
262
+
263
+ type OmitNullableValues<T> = {
264
+ [K in keyof T as T[K] extends null | undefined ? never : K]: NonNullable<T[K]>
265
+ }
266
+
267
+ function omitNullableValues<T extends Record<string, any>>(headers: T): OmitNullableValues<T> {
268
+ let result: any = {}
269
+ for (let key in headers) {
270
+ if (headers[key] != null) {
271
+ result[key] = headers[key]
272
+ }
273
+ }
274
+ return result
275
+ }
276
+
277
+ /**
278
+ * Computes a digest (hash) for a file.
279
+ *
280
+ * @param file The file to hash
281
+ * @param digestOption Web Crypto algorithm name or custom digest function
282
+ * @returns The computed digest as a hex string
283
+ */
284
+ async function computeDigest(
285
+ file: File,
286
+ digestOption: AlgorithmIdentifier | FileDigestFunction,
287
+ ): Promise<string> {
288
+ return typeof digestOption === 'function'
289
+ ? await digestOption(file)
290
+ : await hashFile(file, digestOption)
291
+ }
292
+
293
+ /**
294
+ * Hashes a file using Web Crypto API.
295
+ *
296
+ * Note: This loads the entire file into memory before hashing. For large files,
297
+ * consider using weak ETags (default) or providing a custom digest function.
298
+ *
299
+ * @param file The file to hash
300
+ * @param algorithm Web Crypto API algorithm name (default: 'SHA-256')
301
+ * @returns The hash as a hex string
302
+ */
303
+ async function hashFile(file: File, algorithm: AlgorithmIdentifier = 'SHA-256'): Promise<string> {
304
+ let buffer = await file.arrayBuffer()
305
+ let hashBuffer = await crypto.subtle.digest(algorithm, buffer)
306
+ return Array.from(new Uint8Array(hashBuffer))
307
+ .map((b) => b.toString(16).padStart(2, '0'))
308
+ .join('')
309
+ }
310
+
311
+ /**
312
+ * Removes milliseconds from a timestamp, returning seconds.
313
+ * HTTP dates only have second precision, so this is useful for date comparisons.
314
+ */
315
+ function removeMilliseconds(time: number | Date): number {
316
+ let timestamp = time instanceof Date ? time.getTime() : time
317
+ return Math.floor(timestamp / 1000)
318
+ }
@@ -0,0 +1,97 @@
1
+ import { isSafeHtml, type SafeHtml } from '@remix-run/html-template'
2
+
3
+ const DOCTYPE = '<!DOCTYPE html>'
4
+
5
+ type HtmlBody = string | SafeHtml | Blob | BufferSource | ReadableStream<Uint8Array>
6
+
7
+ /**
8
+ * Creates an HTML [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response)
9
+ * that ensures the response has a valid DOCTYPE and appropriate `Content-Type` header.
10
+ *
11
+ * @param body The body of the response.
12
+ * @param init (optional) The `ResponseInit` object for the response.
13
+ * @returns A `Response` object with a HTML body and the appropriate `Content-Type` header.
14
+ */
15
+ export function createHtmlResponse(body: HtmlBody, init?: ResponseInit): Response {
16
+ let payload: BodyInit = ensureDoctype(body)
17
+
18
+ let headers = new Headers(init?.headers)
19
+ if (!headers.has('Content-Type')) {
20
+ headers.set('Content-Type', 'text/html; charset=UTF-8')
21
+ }
22
+
23
+ return new Response(payload, { ...init, headers })
24
+ }
25
+
26
+ function ensureDoctype(body: HtmlBody): BodyInit {
27
+ if (isSafeHtml(body)) {
28
+ let str = String(body)
29
+ return startsWithDoctype(str) ? str : DOCTYPE + str
30
+ }
31
+
32
+ if (typeof body === 'string') {
33
+ return startsWithDoctype(body) ? body : DOCTYPE + body
34
+ }
35
+
36
+ if (body instanceof Blob) {
37
+ return prependDoctypeToStream(body.stream())
38
+ }
39
+
40
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
41
+ let text = new TextDecoder().decode(body)
42
+ return startsWithDoctype(text) ? text : DOCTYPE + text
43
+ }
44
+
45
+ if (body instanceof ReadableStream) {
46
+ return prependDoctypeToStream(body)
47
+ }
48
+
49
+ return body
50
+ }
51
+
52
+ function startsWithDoctype(str: string): boolean {
53
+ return /^\s*<!doctype html/i.test(str)
54
+ }
55
+
56
+ function prependDoctypeToStream(stream: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
57
+ let doctypeBytes = new TextEncoder().encode(DOCTYPE)
58
+ let reader = stream.getReader()
59
+
60
+ return new ReadableStream({
61
+ async start(controller) {
62
+ try {
63
+ // Read first chunk to check for DOCTYPE
64
+ let firstChunk = await reader.read()
65
+
66
+ if (firstChunk.done) {
67
+ // Empty stream, just add DOCTYPE
68
+ controller.enqueue(doctypeBytes)
69
+ controller.close()
70
+ return
71
+ }
72
+
73
+ // Check if the first chunk starts with DOCTYPE
74
+ let text = new TextDecoder().decode(firstChunk.value, { stream: true })
75
+ if (startsWithDoctype(text)) {
76
+ // Already has DOCTYPE, pass through
77
+ controller.enqueue(firstChunk.value)
78
+ } else {
79
+ // Prepend DOCTYPE
80
+ controller.enqueue(doctypeBytes)
81
+ controller.enqueue(firstChunk.value)
82
+ }
83
+
84
+ // Pass through remaining chunks
85
+ while (true) {
86
+ let { done, value } = await reader.read()
87
+ if (done) break
88
+ controller.enqueue(value)
89
+ }
90
+
91
+ controller.close()
92
+ } catch (error) {
93
+ controller.error(error)
94
+ }
95
+ },
96
+ })
97
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Creates a redirect [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
3
+ *
4
+ * @param location The location to redirect to
5
+ * @param init (optional) The `ResponseInit` object for the response, or a status code
6
+ * @returns A `Response` object with a redirect header
7
+ */
8
+ export function createRedirectResponse(
9
+ location: string | URL,
10
+ init?: ResponseInit | number,
11
+ ): Response {
12
+ let status = 302
13
+ if (typeof init === 'number') {
14
+ status = init
15
+ init = undefined
16
+ }
17
+
18
+ let headers = new Headers(init?.headers)
19
+ if (!headers.has('Location')) {
20
+ headers.set('Location', typeof location === 'string' ? location : location.toString())
21
+ }
22
+
23
+ return new Response(null, { status, ...init, headers })
24
+ }
@@ -0,0 +1 @@
1
+ export { createRedirectResponse } from './lib/redirect.ts'