@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 +21 -0
- package/README.md +175 -0
- package/dist/file.d.ts +2 -0
- package/dist/file.d.ts.map +1 -0
- package/dist/file.js +1 -0
- package/dist/html.d.ts +2 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +1 -0
- package/dist/lib/file.d.ts +80 -0
- package/dist/lib/file.d.ts.map +1 -0
- package/dist/lib/file.js +202 -0
- package/dist/lib/html.d.ts +13 -0
- package/dist/lib/html.d.ts.map +1 -0
- package/dist/lib/html.js +81 -0
- package/dist/lib/redirect.d.ts +9 -0
- package/dist/lib/redirect.d.ts.map +1 -0
- package/dist/lib/redirect.js +19 -0
- package/dist/redirect.d.ts +2 -0
- package/dist/redirect.d.ts.map +1 -0
- package/dist/redirect.js +1 -0
- package/package.json +58 -0
- package/src/file.ts +5 -0
- package/src/html.ts +1 -0
- package/src/lib/file.ts +318 -0
- package/src/lib/html.ts +97 -0
- package/src/lib/redirect.ts +24 -0
- package/src/redirect.ts +1 -0
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 @@
|
|
|
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 @@
|
|
|
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"}
|
package/dist/lib/file.js
ADDED
|
@@ -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"}
|
package/dist/lib/html.js
ADDED
|
@@ -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 @@
|
|
|
1
|
+
{"version":3,"file":"redirect.d.ts","sourceRoot":"","sources":["../src/redirect.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAA"}
|
package/dist/redirect.js
ADDED
|
@@ -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
package/src/html.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createHtmlResponse } from './lib/html.ts'
|
package/src/lib/file.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/html.ts
ADDED
|
@@ -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
|
+
}
|
package/src/redirect.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createRedirectResponse } from './lib/redirect.ts'
|