@nifrajs/middleware 2.9.1 → 2.10.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 +10 -0
- package/dist/conditional.d.ts +9 -0
- package/dist/conditional.d.ts.map +1 -0
- package/dist/conditional.js +62 -0
- package/dist/conditional.js.map +1 -0
- package/dist/content-negotiation.d.ts +15 -0
- package/dist/content-negotiation.d.ts.map +1 -0
- package/dist/content-negotiation.js +130 -0
- package/dist/content-negotiation.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/multipart.d.ts +20 -0
- package/dist/multipart.d.ts.map +1 -0
- package/dist/multipart.js +132 -0
- package/dist/multipart.js.map +1 -0
- package/dist/problem-details.d.ts +17 -0
- package/dist/problem-details.d.ts.map +1 -0
- package/dist/problem-details.js +161 -0
- package/dist/problem-details.js.map +1 -0
- package/dist/range.d.ts +19 -0
- package/dist/range.d.ts.map +1 -0
- package/dist/range.js +144 -0
- package/dist/range.js.map +1 -0
- package/package.json +3 -3
- package/src/conditional.ts +70 -0
- package/src/content-negotiation.ts +139 -0
- package/src/index.ts +24 -0
- package/src/multipart.ts +166 -0
- package/src/problem-details.ts +208 -0
- package/src/range.ts +176 -0
package/src/multipart.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
export type MultipartBody =
|
|
2
|
+
| string
|
|
3
|
+
| Uint8Array
|
|
4
|
+
| ArrayBuffer
|
|
5
|
+
| ArrayBufferView
|
|
6
|
+
| Blob
|
|
7
|
+
| ReadableStream<Uint8Array>
|
|
8
|
+
|
|
9
|
+
export type MultipartHeadersInit =
|
|
10
|
+
| Headers
|
|
11
|
+
| Readonly<Record<string, string>>
|
|
12
|
+
| ReadonlyArray<readonly [string, string]>
|
|
13
|
+
|
|
14
|
+
export interface MultipartPart {
|
|
15
|
+
readonly headers?: MultipartHeadersInit
|
|
16
|
+
readonly body: MultipartBody
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MultipartResponseOptions {
|
|
20
|
+
/** Boundary token. If omitted, a cryptographically random boundary is generated. */
|
|
21
|
+
readonly boundary?: string
|
|
22
|
+
/** Multipart subtype. Defaults to `mixed`; use `form-data` when parts carry form names. */
|
|
23
|
+
readonly subtype?: "mixed" | "form-data"
|
|
24
|
+
/** HTTP response status. Defaults to 200. */
|
|
25
|
+
readonly status?: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const TEXT = new TextEncoder()
|
|
29
|
+
const BOUNDARY = /^[A-Za-z0-9'()+_,./:=? -]{1,70}$/
|
|
30
|
+
|
|
31
|
+
interface ActiveReader {
|
|
32
|
+
current: ReadableStreamDefaultReader<Uint8Array> | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateBoundary(value: string): string {
|
|
36
|
+
if (!BOUNDARY.test(value) || value.endsWith(" ")) {
|
|
37
|
+
throw new TypeError("multipartResponse: boundary must be a valid RFC token")
|
|
38
|
+
}
|
|
39
|
+
return value
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function headersOf(init: MultipartHeadersInit | undefined): Headers {
|
|
43
|
+
const headers = new Headers()
|
|
44
|
+
if (init instanceof Headers) {
|
|
45
|
+
for (const [name, value] of init) headers.set(name, value)
|
|
46
|
+
} else if (Array.isArray(init)) {
|
|
47
|
+
for (const [name, value] of init) headers.append(name, value)
|
|
48
|
+
} else if (init !== undefined) {
|
|
49
|
+
for (const [name, value] of Object.entries(init)) headers.append(name, value)
|
|
50
|
+
}
|
|
51
|
+
for (const [name, value] of headers) {
|
|
52
|
+
if (hasHeaderControlCharacter(name) || hasHeaderControlCharacter(value)) {
|
|
53
|
+
throw new TypeError("multipartResponse: part headers cannot contain control characters")
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return headers
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasHeaderControlCharacter(value: string): boolean {
|
|
60
|
+
for (let index = 0; index < value.length; index++) {
|
|
61
|
+
const code = value.charCodeAt(index)
|
|
62
|
+
// HTAB is the only C0 control permitted in an HTTP field value. Reject the rest, including
|
|
63
|
+
// DEL, so a caller cannot smuggle non-printing bytes into a multipart header line.
|
|
64
|
+
if ((code <= 31 && code !== 9) || code === 127) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function* bodyChunks(
|
|
70
|
+
body: MultipartBody,
|
|
71
|
+
activeReader: ActiveReader,
|
|
72
|
+
): AsyncGenerator<Uint8Array> {
|
|
73
|
+
if (typeof body === "string") {
|
|
74
|
+
yield TEXT.encode(body)
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (body instanceof Uint8Array) {
|
|
78
|
+
yield body
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (body instanceof ArrayBuffer) {
|
|
82
|
+
yield new Uint8Array(body)
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (ArrayBuffer.isView(body)) {
|
|
86
|
+
yield new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const stream = body instanceof Blob ? body.stream() : body
|
|
90
|
+
const reader = stream.getReader() as ReadableStreamDefaultReader<Uint8Array>
|
|
91
|
+
activeReader.current = reader
|
|
92
|
+
let completed = false
|
|
93
|
+
try {
|
|
94
|
+
while (true) {
|
|
95
|
+
const result = await reader.read()
|
|
96
|
+
if (result.done) {
|
|
97
|
+
completed = true
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
yield result.value
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
if (activeReader.current === reader) activeReader.current = undefined
|
|
104
|
+
if (!completed) await reader.cancel().catch(() => {})
|
|
105
|
+
reader.releaseLock()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function* encodeParts(
|
|
110
|
+
parts: Iterable<MultipartPart> | AsyncIterable<MultipartPart>,
|
|
111
|
+
boundary: string,
|
|
112
|
+
activeReader: ActiveReader,
|
|
113
|
+
): AsyncGenerator<Uint8Array> {
|
|
114
|
+
for await (const part of parts) {
|
|
115
|
+
if (part === null || typeof part !== "object") {
|
|
116
|
+
throw new TypeError("multipartResponse: every part must be an object")
|
|
117
|
+
}
|
|
118
|
+
yield TEXT.encode(`--${boundary}\r\n`)
|
|
119
|
+
for (const [name, value] of headersOf(part.headers)) {
|
|
120
|
+
yield TEXT.encode(`${name}: ${value}\r\n`)
|
|
121
|
+
}
|
|
122
|
+
yield TEXT.encode("\r\n")
|
|
123
|
+
yield* bodyChunks(part.body, activeReader)
|
|
124
|
+
yield TEXT.encode("\r\n")
|
|
125
|
+
}
|
|
126
|
+
yield TEXT.encode(`--${boundary}--\r\n`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Stream a multipart response without buffering the parts. The part headers and bodies are emitted
|
|
131
|
+
* as supplied; this helper deliberately does not persist, inspect, or serialize payloads.
|
|
132
|
+
*/
|
|
133
|
+
export function multipartResponse(
|
|
134
|
+
parts: Iterable<MultipartPart> | AsyncIterable<MultipartPart>,
|
|
135
|
+
options: MultipartResponseOptions = {},
|
|
136
|
+
): Response {
|
|
137
|
+
const boundary = validateBoundary(
|
|
138
|
+
options.boundary ?? `nifra-${crypto.randomUUID().replaceAll("-", "")}`,
|
|
139
|
+
)
|
|
140
|
+
const subtype = options.subtype ?? "mixed"
|
|
141
|
+
const activeReader: ActiveReader = { current: undefined }
|
|
142
|
+
const iterator = encodeParts(parts, boundary, activeReader)[Symbol.asyncIterator]()
|
|
143
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
144
|
+
async pull(controller) {
|
|
145
|
+
try {
|
|
146
|
+
const result = await iterator.next()
|
|
147
|
+
if (result.done) controller.close()
|
|
148
|
+
else controller.enqueue(result.value)
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const reader = activeReader.current
|
|
151
|
+
if (reader !== undefined) await reader.cancel(error).catch(() => {})
|
|
152
|
+
await iterator.return?.(error)
|
|
153
|
+
controller.error(error)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
async cancel(reason) {
|
|
157
|
+
const reader = activeReader.current
|
|
158
|
+
if (reader !== undefined) await reader.cancel(reason).catch(() => {})
|
|
159
|
+
await iterator.return?.(reason)
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
return new Response(stream, {
|
|
163
|
+
status: options.status ?? 200,
|
|
164
|
+
headers: { "content-type": `multipart/${subtype}; boundary=${boundary}` },
|
|
165
|
+
})
|
|
166
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { definePlugin, pathnameOf } from "@nifrajs/core/server"
|
|
2
|
+
|
|
3
|
+
const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
|
|
4
|
+
const DEFAULT_MAX_BYTES = 64 * 1024
|
|
5
|
+
|
|
6
|
+
const STATUS_TITLES: Readonly<Record<number, string>> = {
|
|
7
|
+
400: "Bad Request",
|
|
8
|
+
401: "Unauthorized",
|
|
9
|
+
403: "Forbidden",
|
|
10
|
+
404: "Not Found",
|
|
11
|
+
405: "Method Not Allowed",
|
|
12
|
+
406: "Not Acceptable",
|
|
13
|
+
408: "Request Timeout",
|
|
14
|
+
409: "Conflict",
|
|
15
|
+
410: "Gone",
|
|
16
|
+
411: "Length Required",
|
|
17
|
+
412: "Precondition Failed",
|
|
18
|
+
413: "Content Too Large",
|
|
19
|
+
415: "Unsupported Media Type",
|
|
20
|
+
422: "Unprocessable Content",
|
|
21
|
+
429: "Too Many Requests",
|
|
22
|
+
500: "Internal Server Error",
|
|
23
|
+
501: "Not Implemented",
|
|
24
|
+
502: "Bad Gateway",
|
|
25
|
+
503: "Service Unavailable",
|
|
26
|
+
504: "Gateway Timeout",
|
|
27
|
+
507: "Insufficient Storage",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ProblemDetailsOptions {
|
|
31
|
+
/** Include only the request pathname as `instance`. Query strings are never included. Default false. */
|
|
32
|
+
readonly includeInstance?: boolean
|
|
33
|
+
/** Prefix for code-specific problem types. Default `about:blank`. */
|
|
34
|
+
readonly typeBase?: string
|
|
35
|
+
/** Maximum JSON error body inspected. Default 64 KiB. */
|
|
36
|
+
readonly maxBytes?: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ErrorEnvelope {
|
|
40
|
+
readonly error: string
|
|
41
|
+
readonly issues?: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return typeof value === "object" && value !== null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readEnvelope(
|
|
49
|
+
body: string | Uint8Array,
|
|
50
|
+
contentType: string | null,
|
|
51
|
+
maxBytes: number,
|
|
52
|
+
): ErrorEnvelope | undefined {
|
|
53
|
+
if (contentType === null || !JSON_TYPE.test(contentType)) return undefined
|
|
54
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body
|
|
55
|
+
if (bytes.byteLength > maxBytes) return undefined
|
|
56
|
+
let parsed: unknown
|
|
57
|
+
try {
|
|
58
|
+
parsed = JSON.parse(new TextDecoder().decode(bytes))
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
if (!isRecord(parsed) || parsed.ok !== false || typeof parsed.error !== "string") {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
error: parsed.error,
|
|
67
|
+
...(Object.hasOwn(parsed, "issues") ? { issues: parsed.issues } : {}),
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function readCappedBody(
|
|
72
|
+
body: ReadableStream<Uint8Array>,
|
|
73
|
+
maxBytes: number,
|
|
74
|
+
): Promise<Uint8Array | undefined> {
|
|
75
|
+
const reader = body.getReader()
|
|
76
|
+
const chunks: Uint8Array[] = []
|
|
77
|
+
let size = 0
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
while (true) {
|
|
81
|
+
const result = await reader.read()
|
|
82
|
+
if (result.done) {
|
|
83
|
+
const bytes = new Uint8Array(size)
|
|
84
|
+
let offset = 0
|
|
85
|
+
for (const chunk of chunks) {
|
|
86
|
+
bytes.set(chunk, offset)
|
|
87
|
+
offset += chunk.byteLength
|
|
88
|
+
}
|
|
89
|
+
return bytes
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (size + result.value.byteLength > maxBytes) {
|
|
93
|
+
void reader.cancel().catch(() => {})
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
chunks.push(result.value)
|
|
98
|
+
size += result.value.byteLength
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
void reader.cancel().catch(() => {})
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function problemType(typeBase: string | undefined, code: string): string {
|
|
107
|
+
if (typeBase === undefined) return "about:blank"
|
|
108
|
+
return `${typeBase.replace(/\/+$/, "")}/${encodeURIComponent(code)}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function problemDocument(
|
|
112
|
+
status: number,
|
|
113
|
+
envelope: ErrorEnvelope,
|
|
114
|
+
typeBase: string | undefined,
|
|
115
|
+
instance: string | undefined,
|
|
116
|
+
): Record<string, unknown> {
|
|
117
|
+
const document: Record<string, unknown> = {
|
|
118
|
+
type: problemType(typeBase, envelope.error),
|
|
119
|
+
title: STATUS_TITLES[status] ?? "HTTP Error",
|
|
120
|
+
status,
|
|
121
|
+
code: envelope.error,
|
|
122
|
+
}
|
|
123
|
+
if (envelope.issues !== undefined) document.issues = envelope.issues
|
|
124
|
+
if (instance !== undefined) document.instance = instance
|
|
125
|
+
return document
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isAlreadyProblemDetails(contentType: string | null): boolean {
|
|
129
|
+
return contentType?.toLowerCase().startsWith("application/problem+json") === true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Opt-in RFC 9457 formatting for Nifra's structured error envelope.
|
|
134
|
+
*
|
|
135
|
+
* The default `{ ok: false, error }` response remains unchanged. This plugin only converts that
|
|
136
|
+
* framework envelope, so custom JSON errors and already-formatted problem documents pass through.
|
|
137
|
+
* Raw JSON error Responses are supported through a capped clone; encoded responses are never drained.
|
|
138
|
+
*/
|
|
139
|
+
export function problemDetails(options: ProblemDetailsOptions = {}) {
|
|
140
|
+
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
|
|
141
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
142
|
+
throw new Error("problemDetails: maxBytes must be a non-negative safe integer")
|
|
143
|
+
}
|
|
144
|
+
const instance = options.includeInstance === true
|
|
145
|
+
const typeBase = options.typeBase
|
|
146
|
+
|
|
147
|
+
return definePlugin("problemDetails", (app) =>
|
|
148
|
+
app.use({
|
|
149
|
+
onResponseBody(body, headers, req, status) {
|
|
150
|
+
if (
|
|
151
|
+
status < 400 ||
|
|
152
|
+
status >= 600 ||
|
|
153
|
+
headers.has("content-encoding") ||
|
|
154
|
+
isAlreadyProblemDetails(headers.get("content-type"))
|
|
155
|
+
) {
|
|
156
|
+
return undefined
|
|
157
|
+
}
|
|
158
|
+
const envelope = readEnvelope(body, headers.get("content-type"), maxBytes)
|
|
159
|
+
if (envelope === undefined) return undefined
|
|
160
|
+
const document = problemDocument(
|
|
161
|
+
status,
|
|
162
|
+
envelope,
|
|
163
|
+
typeBase,
|
|
164
|
+
instance ? pathnameOf(req.url) : undefined,
|
|
165
|
+
)
|
|
166
|
+
headers.set("content-type", "application/problem+json")
|
|
167
|
+
headers.delete("content-length")
|
|
168
|
+
return JSON.stringify(document)
|
|
169
|
+
},
|
|
170
|
+
async onResponseRaw(response, req) {
|
|
171
|
+
if (
|
|
172
|
+
response.status < 400 ||
|
|
173
|
+
response.status >= 600 ||
|
|
174
|
+
response.body === null ||
|
|
175
|
+
response.headers.has("content-encoding") ||
|
|
176
|
+
isAlreadyProblemDetails(response.headers.get("content-type"))
|
|
177
|
+
) {
|
|
178
|
+
return response
|
|
179
|
+
}
|
|
180
|
+
let bytes: Uint8Array | undefined
|
|
181
|
+
try {
|
|
182
|
+
const clone = response.clone()
|
|
183
|
+
if (clone.body === null) return response
|
|
184
|
+
bytes = await readCappedBody(clone.body, maxBytes)
|
|
185
|
+
} catch {
|
|
186
|
+
return response
|
|
187
|
+
}
|
|
188
|
+
if (bytes === undefined) return response
|
|
189
|
+
const envelope = readEnvelope(bytes, response.headers.get("content-type"), maxBytes)
|
|
190
|
+
if (envelope === undefined) return response
|
|
191
|
+
const headers = new Headers(response.headers)
|
|
192
|
+
headers.set("content-type", "application/problem+json")
|
|
193
|
+
headers.delete("content-length")
|
|
194
|
+
return new Response(
|
|
195
|
+
JSON.stringify(
|
|
196
|
+
problemDocument(
|
|
197
|
+
response.status,
|
|
198
|
+
envelope,
|
|
199
|
+
typeBase,
|
|
200
|
+
instance ? pathnameOf(req.url) : undefined,
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
{ status: response.status, statusText: response.statusText, headers },
|
|
204
|
+
)
|
|
205
|
+
},
|
|
206
|
+
}),
|
|
207
|
+
)
|
|
208
|
+
}
|
package/src/range.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { type ByteRange, type ByteRangeResult, parseByteRange } from "@nifrajs/core/range"
|
|
2
|
+
|
|
3
|
+
// The parser lives in core because `@nifrajs/web`'s `public/` handler needs the identical answer for
|
|
4
|
+
// files on disk, and web cannot depend on this package. Re-exported here so the middleware surface
|
|
5
|
+
// stays one import.
|
|
6
|
+
export { type ByteRange, type ByteRangeResult, parseByteRange }
|
|
7
|
+
|
|
8
|
+
export interface RangeResponseOptions {
|
|
9
|
+
/** Media type for the complete representation and each single-range response. */
|
|
10
|
+
readonly contentType?: string
|
|
11
|
+
/** Entity tag used for conditional requests and `If-Range`. */
|
|
12
|
+
readonly etag?: string
|
|
13
|
+
/** Validator used for conditional requests and date-form `If-Range`. */
|
|
14
|
+
readonly lastModified?: Date
|
|
15
|
+
/** Optional cache policy copied to the response. */
|
|
16
|
+
readonly cacheControl?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const TEXT = new TextEncoder()
|
|
20
|
+
|
|
21
|
+
function bytesOf(body: string | ArrayBuffer | ArrayBufferView | Uint8Array): Uint8Array {
|
|
22
|
+
if (typeof body === "string") return TEXT.encode(body)
|
|
23
|
+
if (body instanceof Uint8Array) return body
|
|
24
|
+
if (body instanceof ArrayBuffer) return new Uint8Array(body)
|
|
25
|
+
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function httpDate(date: Date): string {
|
|
29
|
+
const time = date.getTime()
|
|
30
|
+
if (!Number.isFinite(time))
|
|
31
|
+
throw new RangeError("rangeResponse: lastModified must be a valid Date")
|
|
32
|
+
return new Date(time).toUTCString()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validators(options: RangeResponseOptions): Headers {
|
|
36
|
+
const headers = new Headers()
|
|
37
|
+
if (options.contentType !== undefined) headers.set("content-type", options.contentType)
|
|
38
|
+
if (options.etag !== undefined) headers.set("etag", options.etag)
|
|
39
|
+
if (options.lastModified !== undefined)
|
|
40
|
+
headers.set("last-modified", httpDate(options.lastModified))
|
|
41
|
+
if (options.cacheControl !== undefined) headers.set("cache-control", options.cacheControl)
|
|
42
|
+
return headers
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emptyResponse(status: number, headers: Headers): Response {
|
|
46
|
+
headers.delete("content-length")
|
|
47
|
+
headers.delete("content-type")
|
|
48
|
+
return new Response(null, { status, headers })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function matchesIfNoneMatch(value: string, etag: string): boolean {
|
|
52
|
+
const normalized = etag.startsWith("W/") ? etag.slice(2) : etag
|
|
53
|
+
return value.split(",").some((candidate) => {
|
|
54
|
+
const item = candidate.trim()
|
|
55
|
+
if (item === "*") return true
|
|
56
|
+
const comparable = item.startsWith("W/") ? item.slice(2) : item
|
|
57
|
+
return comparable === normalized
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ifRangeMatches(value: string | null, options: RangeResponseOptions): boolean {
|
|
62
|
+
if (value === null) return true
|
|
63
|
+
const item = value.trim()
|
|
64
|
+
if (options.etag !== undefined) return !item.startsWith("W/") && item === options.etag
|
|
65
|
+
if (options.lastModified === undefined) return false
|
|
66
|
+
const time = Date.parse(item)
|
|
67
|
+
return (
|
|
68
|
+
Number.isFinite(time) &&
|
|
69
|
+
Math.floor(time / 1000) >= Math.floor(options.lastModified.getTime() / 1000)
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shouldReturnNotModified(request: Request, options: RangeResponseOptions): boolean {
|
|
74
|
+
if (request.method !== "GET" && request.method !== "HEAD") return false
|
|
75
|
+
const ifNoneMatch = request.headers.get("if-none-match")
|
|
76
|
+
// RFC conditional precedence: an If-None-Match header suppresses date evaluation even when this
|
|
77
|
+
// representation has no ETag to compare. A non-matching tag must not be upgraded to a 304 by an
|
|
78
|
+
// older If-Modified-Since value.
|
|
79
|
+
if (ifNoneMatch !== null) {
|
|
80
|
+
return options.etag !== undefined && matchesIfNoneMatch(ifNoneMatch, options.etag)
|
|
81
|
+
}
|
|
82
|
+
// If-Modified-Since remains a valid fallback when the request did not send If-None-Match.
|
|
83
|
+
const lastModified = options.lastModified?.getTime()
|
|
84
|
+
const ifModifiedSince = request.headers.get("if-modified-since")
|
|
85
|
+
return (
|
|
86
|
+
lastModified !== undefined &&
|
|
87
|
+
Number.isFinite(lastModified) &&
|
|
88
|
+
ifModifiedSince !== null &&
|
|
89
|
+
Number.isFinite(Date.parse(ifModifiedSince)) &&
|
|
90
|
+
Math.floor(Date.parse(ifModifiedSince) / 1000) >= Math.floor(lastModified / 1000)
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function responseBody(bytes: Uint8Array, request: Request): Uint8Array | null {
|
|
95
|
+
return request.method === "HEAD" ? null : bytes
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function multipartBytes(
|
|
99
|
+
bytes: Uint8Array,
|
|
100
|
+
ranges: readonly ByteRange[],
|
|
101
|
+
size: number,
|
|
102
|
+
contentType: string,
|
|
103
|
+
boundary: string,
|
|
104
|
+
): Uint8Array {
|
|
105
|
+
const chunks: Uint8Array[] = []
|
|
106
|
+
let total = 0
|
|
107
|
+
const add = (chunk: Uint8Array): void => {
|
|
108
|
+
chunks.push(chunk)
|
|
109
|
+
total += chunk.byteLength
|
|
110
|
+
}
|
|
111
|
+
for (const range of ranges) {
|
|
112
|
+
add(TEXT.encode(`--${boundary}\r\n`))
|
|
113
|
+
add(TEXT.encode(`Content-Range: bytes ${range.start}-${range.end}/${size}\r\n`))
|
|
114
|
+
add(TEXT.encode(`Content-Type: ${contentType}\r\n\r\n`))
|
|
115
|
+
add(bytes.subarray(range.start, range.end + 1))
|
|
116
|
+
add(TEXT.encode("\r\n"))
|
|
117
|
+
}
|
|
118
|
+
add(TEXT.encode(`--${boundary}--\r\n`))
|
|
119
|
+
const body = new Uint8Array(total)
|
|
120
|
+
let offset = 0
|
|
121
|
+
for (const chunk of chunks) {
|
|
122
|
+
body.set(chunk, offset)
|
|
123
|
+
offset += chunk.byteLength
|
|
124
|
+
}
|
|
125
|
+
return body
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Create a standards-shaped byte representation response with Range, If-Range, and conditional
|
|
130
|
+
* validator handling. The input is already caller-owned memory; this helper does not read or retain
|
|
131
|
+
* external state. Multiple ranges use `multipart/byteranges` and adjacent ranges are coalesced.
|
|
132
|
+
*/
|
|
133
|
+
export function rangeResponse(
|
|
134
|
+
request: Request,
|
|
135
|
+
body: string | ArrayBuffer | ArrayBufferView | Uint8Array,
|
|
136
|
+
options: RangeResponseOptions = {},
|
|
137
|
+
): Response {
|
|
138
|
+
const bytes = bytesOf(body)
|
|
139
|
+
const headers = validators(options)
|
|
140
|
+
headers.set("accept-ranges", "bytes")
|
|
141
|
+
|
|
142
|
+
if (shouldReturnNotModified(request, options)) return emptyResponse(304, headers)
|
|
143
|
+
|
|
144
|
+
const requestedRange =
|
|
145
|
+
request.method === "GET" || request.method === "HEAD" ? request.headers.get("range") : null
|
|
146
|
+
const range =
|
|
147
|
+
requestedRange !== null && ifRangeMatches(request.headers.get("if-range"), options)
|
|
148
|
+
? parseByteRange(requestedRange, bytes.byteLength)
|
|
149
|
+
: { kind: "none" as const }
|
|
150
|
+
|
|
151
|
+
if (range.kind === "unsatisfiable") {
|
|
152
|
+
headers.set("content-range", `bytes */${bytes.byteLength}`)
|
|
153
|
+
headers.set("content-length", "0")
|
|
154
|
+
return new Response(null, { status: 416, headers })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (range.kind === "none") {
|
|
158
|
+
headers.set("content-length", String(bytes.byteLength))
|
|
159
|
+
return new Response(responseBody(bytes, request), { status: 200, headers })
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const contentType = options.contentType ?? "application/octet-stream"
|
|
163
|
+
if (range.ranges.length === 1) {
|
|
164
|
+
const selected = range.ranges[0]!
|
|
165
|
+
const selectedBytes = bytes.subarray(selected.start, selected.end + 1)
|
|
166
|
+
headers.set("content-range", `bytes ${selected.start}-${selected.end}/${bytes.byteLength}`)
|
|
167
|
+
headers.set("content-length", String(selectedBytes.byteLength))
|
|
168
|
+
return new Response(responseBody(selectedBytes, request), { status: 206, headers })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const boundary = `nifra-${crypto.randomUUID().replaceAll("-", "")}`
|
|
172
|
+
const selectedBytes = multipartBytes(bytes, range.ranges, bytes.byteLength, contentType, boundary)
|
|
173
|
+
headers.set("content-type", `multipart/byteranges; boundary=${boundary}`)
|
|
174
|
+
headers.set("content-length", String(selectedBytes.byteLength))
|
|
175
|
+
return new Response(responseBody(selectedBytes, request), { status: 206, headers })
|
|
176
|
+
}
|