@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.
@@ -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
+ }