@nifrajs/middleware 2.8.2 → 2.9.1
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 +22 -1
- package/dist/_utils.d.ts +6 -1
- package/dist/_utils.d.ts.map +1 -1
- package/dist/_utils.js +39 -28
- package/dist/_utils.js.map +1 -1
- package/dist/cache-control.d.ts.map +1 -1
- package/dist/cache-control.js +31 -15
- package/dist/cache-control.js.map +1 -1
- package/dist/cache.d.ts +1 -1
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +2 -3
- package/dist/cache.js.map +1 -1
- package/dist/compression.d.ts +10 -10
- package/dist/compression.d.ts.map +1 -1
- package/dist/compression.js +191 -70
- package/dist/compression.js.map +1 -1
- package/dist/cors.d.ts.map +1 -1
- package/dist/cors.js +27 -16
- package/dist/cors.js.map +1 -1
- package/dist/etag.d.ts +4 -2
- package/dist/etag.d.ts.map +1 -1
- package/dist/etag.js +135 -63
- package/dist/etag.js.map +1 -1
- package/dist/language.d.ts.map +1 -1
- package/dist/language.js +14 -15
- package/dist/language.js.map +1 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +34 -16
- package/dist/logger.js.map +1 -1
- package/dist/powered-by.d.ts.map +1 -1
- package/dist/powered-by.js +8 -5
- package/dist/powered-by.js.map +1 -1
- package/dist/pretty-json.d.ts +5 -2
- package/dist/pretty-json.d.ts.map +1 -1
- package/dist/pretty-json.js +85 -64
- package/dist/pretty-json.js.map +1 -1
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/rate-limit.js +76 -19
- package/dist/rate-limit.js.map +1 -1
- package/dist/request-id.js +1 -1
- package/dist/request-id.js.map +1 -1
- package/dist/security-headers.d.ts +8 -4
- package/dist/security-headers.d.ts.map +1 -1
- package/dist/security-headers.js +18 -16
- package/dist/security-headers.js.map +1 -1
- package/package.json +3 -3
- package/src/_utils.ts +39 -24
- package/src/cache-control.ts +27 -14
- package/src/cache.ts +2 -4
- package/src/compression.ts +202 -62
- package/src/cors.ts +23 -14
- package/src/etag.ts +141 -59
- package/src/language.ts +12 -13
- package/src/logger.ts +24 -6
- package/src/powered-by.ts +8 -4
- package/src/pretty-json.ts +95 -70
- package/src/rate-limit.ts +86 -20
- package/src/request-id.ts +1 -1
- package/src/security-headers.ts +17 -15
package/src/compression.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definePlugin } from "@nifrajs/core/server"
|
|
1
|
+
import { definePlugin, type ResponseHeadersView } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
export interface CompressionOptions {
|
|
4
4
|
/** Don't compress bodies smaller than this many bytes. Default `1024`. Enforced by peeking the body
|
|
@@ -15,6 +15,31 @@ const COMPRESSIBLE =
|
|
|
15
15
|
|
|
16
16
|
const defaultCompressible = (contentType: string): boolean => COMPRESSIBLE.test(contentType)
|
|
17
17
|
|
|
18
|
+
/** Honor explicit q=0 exclusions; a client that says `gzip;q=0` must not receive gzip. */
|
|
19
|
+
function acceptsGzip(value: string | null): boolean {
|
|
20
|
+
if (value === null) return false
|
|
21
|
+
let wildcard: number | undefined
|
|
22
|
+
let gzip: number | undefined
|
|
23
|
+
for (const item of value.split(",")) {
|
|
24
|
+
const parts = item.trim().split(";")
|
|
25
|
+
const coding = parts[0]?.trim().toLowerCase()
|
|
26
|
+
if (coding !== "gzip" && coding !== "*") continue
|
|
27
|
+
let quality = 1
|
|
28
|
+
for (let i = 1; i < parts.length; i++) {
|
|
29
|
+
const [name, raw] = parts[i]!.trim().split("=", 2)
|
|
30
|
+
if (name?.toLowerCase() === "q") {
|
|
31
|
+
const parsed = Number(raw)
|
|
32
|
+
quality = Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : 0
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (coding === "gzip") gzip = quality
|
|
36
|
+
else wildcard = quality
|
|
37
|
+
}
|
|
38
|
+
return (gzip ?? wildcard ?? 0) > 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const GZIP_ENCODER = new TextEncoder()
|
|
42
|
+
|
|
18
43
|
const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
|
|
19
44
|
const out = new Uint8Array(total)
|
|
20
45
|
let offset = 0
|
|
@@ -28,28 +53,169 @@ const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
|
|
|
28
53
|
const gzipHeaders = (source: Headers): Headers => {
|
|
29
54
|
const headers = new Headers(source)
|
|
30
55
|
headers.set("content-encoding", "gzip")
|
|
31
|
-
headers.delete("content-length")
|
|
56
|
+
headers.delete("content-length")
|
|
57
|
+
addGzipVary(headers)
|
|
58
|
+
return headers
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function addGzipVary(headers: ResponseHeadersView): void {
|
|
32
62
|
const vary = headers.get("vary")
|
|
33
|
-
if (vary === null)
|
|
34
|
-
|
|
63
|
+
if (vary === null) {
|
|
64
|
+
headers.set("vary", "Accept-Encoding")
|
|
65
|
+
} else if (
|
|
66
|
+
!vary
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.split(",")
|
|
69
|
+
.some((part) => part.trim() === "accept-encoding")
|
|
70
|
+
) {
|
|
35
71
|
headers.set("vary", `${vary}, Accept-Encoding`)
|
|
36
|
-
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hasNoTransform(value: string | null): boolean {
|
|
76
|
+
return (
|
|
77
|
+
value
|
|
78
|
+
?.split(",")
|
|
79
|
+
.some(
|
|
80
|
+
(directive) => directive.trim().split(";", 1)[0]?.trim().toLowerCase() === "no-transform",
|
|
81
|
+
) ?? false
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface RawReader {
|
|
86
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
|
|
87
|
+
cancel(reason?: unknown): Promise<void>
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Gzip a resident buffer through the Web-standard `CompressionStream` (available everywhere). */
|
|
91
|
+
async function gzipBytes(bytes: Uint8Array): Promise<Uint8Array> {
|
|
92
|
+
// No explicit generic: the chunk type infers from `enqueue`, which keeps the stream assignable
|
|
93
|
+
// to `CompressionStream`'s typing across the Bun/DOM lib variants.
|
|
94
|
+
const source = new ReadableStream({
|
|
95
|
+
start(controller) {
|
|
96
|
+
controller.enqueue(bytes)
|
|
97
|
+
controller.close()
|
|
98
|
+
},
|
|
99
|
+
})
|
|
100
|
+
const compressed = await new Response(
|
|
101
|
+
source.pipeThrough(new CompressionStream("gzip")),
|
|
102
|
+
).arrayBuffer()
|
|
103
|
+
return new Uint8Array(compressed)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stream-safe fallback for handler-returned/proxied Responses. Peek only enough to enforce the
|
|
107
|
+
* threshold, then replay the prefix through CompressionStream without buffering the remainder. */
|
|
108
|
+
function compressRawResponse(
|
|
109
|
+
response: Response,
|
|
110
|
+
req: Request,
|
|
111
|
+
threshold: number,
|
|
112
|
+
isCompressible: (contentType: string) => boolean,
|
|
113
|
+
): Response | Promise<Response> {
|
|
114
|
+
const body = response.body
|
|
115
|
+
if (body === null) return response
|
|
116
|
+
if (response.status === 204 || response.status === 205 || response.status === 304) {
|
|
117
|
+
return response
|
|
118
|
+
}
|
|
119
|
+
if (!acceptsGzip(req.headers.get("accept-encoding"))) return response
|
|
120
|
+
if (response.headers.has("content-encoding")) return response
|
|
121
|
+
if (response.status === 206 || response.headers.has("content-range")) return response
|
|
122
|
+
if (hasNoTransform(response.headers.get("cache-control"))) return response
|
|
123
|
+
if (!isCompressible(response.headers.get("content-type") ?? "")) return response
|
|
124
|
+
const declared = response.headers.get("content-length")
|
|
125
|
+
if (declared !== null && Number(declared) < threshold) return response
|
|
126
|
+
|
|
127
|
+
return compressRawBody(response, body, threshold)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function compressRawBody(
|
|
131
|
+
response: Response,
|
|
132
|
+
body: ReadableStream<Uint8Array>,
|
|
133
|
+
threshold: number,
|
|
134
|
+
): Promise<Response> {
|
|
135
|
+
const reader = body.getReader() as unknown as RawReader
|
|
136
|
+
const buffered: Uint8Array[] = []
|
|
137
|
+
let total = 0
|
|
138
|
+
try {
|
|
139
|
+
while (total < threshold) {
|
|
140
|
+
const { done, value } = await reader.read()
|
|
141
|
+
if (done) {
|
|
142
|
+
return new Response(concat(buffered, total), {
|
|
143
|
+
status: response.status,
|
|
144
|
+
statusText: response.statusText,
|
|
145
|
+
headers: response.headers,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
if (value === undefined) return replayRawResponse(response, buffered, reader)
|
|
149
|
+
buffered.push(value)
|
|
150
|
+
total += value.byteLength
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
return replayRawResponse(response, buffered, reader)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const source = new ReadableStream({
|
|
157
|
+
start(controller) {
|
|
158
|
+
for (const chunk of buffered) controller.enqueue(chunk)
|
|
159
|
+
},
|
|
160
|
+
async pull(controller) {
|
|
161
|
+
try {
|
|
162
|
+
const { done, value } = await reader.read()
|
|
163
|
+
if (done || value === undefined) controller.close()
|
|
164
|
+
else controller.enqueue(value)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
controller.error(error)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
cancel: (reason) => reader.cancel(reason),
|
|
170
|
+
})
|
|
171
|
+
return new Response(source.pipeThrough(new CompressionStream("gzip")), {
|
|
172
|
+
status: response.status,
|
|
173
|
+
statusText: response.statusText,
|
|
174
|
+
headers: gzipHeaders(response.headers),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function replayRawResponse(
|
|
179
|
+
response: Response,
|
|
180
|
+
chunks: readonly Uint8Array[],
|
|
181
|
+
reader: RawReader,
|
|
182
|
+
): Response {
|
|
183
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
184
|
+
start(controller) {
|
|
185
|
+
for (const chunk of chunks) controller.enqueue(chunk)
|
|
186
|
+
},
|
|
187
|
+
async pull(controller) {
|
|
188
|
+
try {
|
|
189
|
+
const { done, value } = await reader.read()
|
|
190
|
+
if (done || value === undefined) controller.close()
|
|
191
|
+
else controller.enqueue(value)
|
|
192
|
+
} catch (error) {
|
|
193
|
+
controller.error(error)
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
cancel: (reason) => reader.cancel(reason),
|
|
197
|
+
})
|
|
198
|
+
return new Response(stream, {
|
|
199
|
+
status: response.status,
|
|
200
|
+
statusText: response.statusText,
|
|
201
|
+
headers: response.headers,
|
|
202
|
+
})
|
|
37
203
|
}
|
|
38
204
|
|
|
39
205
|
/**
|
|
40
206
|
* Transparently **gzip** responses when the client sends `Accept-Encoding: gzip` and the body is a
|
|
41
|
-
* compressible type larger than `threshold`. Uses the Web-standard `CompressionStream
|
|
42
|
-
*
|
|
43
|
-
*
|
|
207
|
+
* compressible type larger than `threshold`. Uses the Web-standard `CompressionStream`, so it runs
|
|
208
|
+
* on every nifra runtime including the edge; gzip is the one encoding `CompressionStream`
|
|
209
|
+
* guarantees everywhere, so brotli isn't offered.
|
|
44
210
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* set `Content-Length` on a constructed `Response`, so the header alone can't gate this.)
|
|
211
|
+
* Built on the portable `onResponseBody` tier for framework-serialized bytes, with a stream-safe raw
|
|
212
|
+
* fallback for handler-returned/proxied Responses. The common JSON path stays on Node's direct
|
|
213
|
+
* writer; raw streams are compressed incrementally without being fully buffered.
|
|
49
214
|
*
|
|
50
|
-
* Skips: clients that don't accept gzip, already-encoded responses, bodyless responses
|
|
51
|
-
* `Range` responses (206 / `Content-Range`), `Cache-Control: no-transform`,
|
|
52
|
-
*
|
|
215
|
+
* Skips: clients that don't accept gzip, already-encoded responses, bodyless responses,
|
|
216
|
+
* `Range` responses (206 / `Content-Range`), `Cache-Control: no-transform`, non-compressible
|
|
217
|
+
* types, and bodies below `threshold` (gzip's ~20-byte overhead would enlarge them). Adds
|
|
218
|
+
* `Vary: Accept-Encoding`.
|
|
53
219
|
*
|
|
54
220
|
* ```ts
|
|
55
221
|
* app.use(compression())
|
|
@@ -57,55 +223,29 @@ const gzipHeaders = (source: Headers): Headers => {
|
|
|
57
223
|
*/
|
|
58
224
|
export function compression(options: CompressionOptions = {}) {
|
|
59
225
|
const threshold = options.threshold ?? 1024
|
|
226
|
+
if (!Number.isSafeInteger(threshold) || threshold < 0) {
|
|
227
|
+
throw new Error("compression: threshold must be a non-negative safe integer")
|
|
228
|
+
}
|
|
60
229
|
const isCompressible = options.compressible ?? defaultCompressible
|
|
61
230
|
return definePlugin("compression", (app) =>
|
|
62
|
-
app.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// Body ended below the threshold - too small to be worth gzipping. Emit as-is.
|
|
81
|
-
return new Response(concat(buffered, total), {
|
|
82
|
-
status: res.status,
|
|
83
|
-
statusText: res.statusText,
|
|
84
|
-
headers: res.headers,
|
|
85
|
-
})
|
|
86
|
-
}
|
|
87
|
-
buffered.push(value)
|
|
88
|
-
total += value.byteLength
|
|
89
|
-
}
|
|
90
|
-
// Over the threshold: replay the buffered prefix, then stream the rest - all through gzip.
|
|
91
|
-
// (No explicit generic: it lets the chunk type infer from the non-shared reader so the stream
|
|
92
|
-
// matches `CompressionStream`'s `Uint8Array` typing.)
|
|
93
|
-
const source = new ReadableStream({
|
|
94
|
-
start(controller) {
|
|
95
|
-
for (const chunk of buffered) controller.enqueue(chunk)
|
|
96
|
-
},
|
|
97
|
-
async pull(controller) {
|
|
98
|
-
const { done, value } = await reader.read()
|
|
99
|
-
if (done) controller.close()
|
|
100
|
-
else controller.enqueue(value)
|
|
101
|
-
},
|
|
102
|
-
cancel: (reason) => reader.cancel(reason),
|
|
103
|
-
})
|
|
104
|
-
return new Response(source.pipeThrough(new CompressionStream("gzip")), {
|
|
105
|
-
status: res.status,
|
|
106
|
-
statusText: res.statusText,
|
|
107
|
-
headers: gzipHeaders(res.headers),
|
|
108
|
-
})
|
|
231
|
+
app.use({
|
|
232
|
+
onResponseBody(body, headers, req, status) {
|
|
233
|
+
if (!acceptsGzip(req.header("accept-encoding"))) return undefined
|
|
234
|
+
if (headers.has("content-encoding")) return undefined
|
|
235
|
+
if (status === 206 || headers.has("content-range")) return undefined
|
|
236
|
+
if (hasNoTransform(headers.get("cache-control"))) return undefined
|
|
237
|
+
if (!isCompressible(headers.get("content-type") ?? "")) return undefined
|
|
238
|
+
const bytes = typeof body === "string" ? GZIP_ENCODER.encode(body) : body
|
|
239
|
+
if (bytes.byteLength < threshold) return undefined
|
|
240
|
+
return gzipBytes(bytes).then((compressed) => {
|
|
241
|
+
headers.set("content-encoding", "gzip")
|
|
242
|
+
headers.delete("content-length")
|
|
243
|
+
addGzipVary(headers)
|
|
244
|
+
return compressed
|
|
245
|
+
})
|
|
246
|
+
},
|
|
247
|
+
onResponseRaw: (response, req) =>
|
|
248
|
+
compressRawResponse(response, req, threshold, isCompressible),
|
|
109
249
|
}),
|
|
110
250
|
)
|
|
111
251
|
}
|
package/src/cors.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Middleware } from "@nifrajs/core/server"
|
|
2
|
-
import { withHeaders } from "./_utils.ts"
|
|
3
2
|
|
|
4
3
|
export interface CorsOptions {
|
|
5
4
|
/** Allowed origin(s): `"*"`, an exact origin, a list, or a predicate. Default `"*"`. */
|
|
@@ -70,21 +69,31 @@ export function cors(options: CorsOptions = {}): Middleware {
|
|
|
70
69
|
const requested = allowedHeaders ?? req.headers.get("access-control-request-headers")
|
|
71
70
|
if (requested) headers.set("Access-Control-Allow-Headers", requested)
|
|
72
71
|
if (maxAge !== undefined) headers.set("Access-Control-Max-Age", String(maxAge))
|
|
73
|
-
// Allow-Origin / Allow-Credentials are added by
|
|
72
|
+
// Allow-Origin / Allow-Credentials are added by onResponseHeaders (runs on every response).
|
|
74
73
|
return new Response(null, { status: 204, headers })
|
|
75
74
|
},
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
onNodeRequest(req) {
|
|
76
|
+
const isPreflight =
|
|
77
|
+
req.method === "OPTIONS" && req.header("access-control-request-method") !== null
|
|
78
|
+
if (!isPreflight) return undefined
|
|
79
|
+
const headers: Record<string, string> = {
|
|
80
|
+
"Access-Control-Allow-Methods": methods,
|
|
81
|
+
}
|
|
82
|
+
const requested = allowedHeaders ?? req.header("access-control-request-headers")
|
|
83
|
+
if (requested) headers["Access-Control-Allow-Headers"] = requested
|
|
84
|
+
if (maxAge !== undefined) headers["Access-Control-Max-Age"] = String(maxAge)
|
|
85
|
+
return new Response(null, { status: 204, headers })
|
|
86
|
+
},
|
|
87
|
+
onResponseHeaders(headers, req) {
|
|
88
|
+
const allowOrigin = resolveAllowOrigin(origin, req.header("origin"))
|
|
89
|
+
if (allowOrigin === null) return
|
|
90
|
+
headers.set("access-control-allow-origin", allowOrigin)
|
|
91
|
+
if (allowOrigin !== "*") {
|
|
92
|
+
const vary = headers.get("vary")
|
|
93
|
+
headers.set("vary", vary === null || vary === "" ? "Origin" : `${vary}, Origin`)
|
|
94
|
+
}
|
|
95
|
+
if (credentials) headers.set("access-control-allow-credentials", "true")
|
|
96
|
+
if (exposedHeaders !== undefined) headers.set("access-control-expose-headers", exposedHeaders)
|
|
88
97
|
},
|
|
89
98
|
}
|
|
90
99
|
}
|
package/src/etag.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definePlugin } from "@nifrajs/core/server"
|
|
1
|
+
import { definePlugin, type ResponseBodyReplacement } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
/** 32-bit FNV-1a over bytes → hex. A fast, dependency-free content fingerprint for ETags - not crypto. */
|
|
4
4
|
function fnv1a(bytes: Uint8Array): string {
|
|
@@ -17,40 +17,158 @@ export interface ETagOptions {
|
|
|
17
17
|
readonly maxBytes?: number
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
const ETAG_ENCODER = new TextEncoder()
|
|
21
|
+
|
|
22
|
+
async function sha256Tag(bytes: Uint8Array): Promise<string> {
|
|
23
|
+
const input = new Uint8Array(bytes.byteLength)
|
|
24
|
+
input.set(bytes)
|
|
25
|
+
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", input))
|
|
26
|
+
let hex = ""
|
|
27
|
+
for (const byte of digest) hex += byte.toString(16).padStart(2, "0")
|
|
28
|
+
return hex
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
|
|
32
|
+
const out = new Uint8Array(total)
|
|
33
|
+
let offset = 0
|
|
34
|
+
for (const chunk of chunks) {
|
|
35
|
+
out.set(chunk, offset)
|
|
36
|
+
offset += chunk.byteLength
|
|
37
|
+
}
|
|
38
|
+
return out
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ReadBytes = { readonly bytes: Uint8Array } | { readonly response: Response }
|
|
42
|
+
|
|
43
|
+
interface ChunkReader {
|
|
44
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
|
|
45
|
+
cancel(reason?: unknown): Promise<void>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read a raw response without losing a streamed body when the cap is exceeded or the upstream
|
|
49
|
+
* fails. The already-read prefix is replayed through the same reader instead of cancelling a clone,
|
|
50
|
+
* which can stall the original body on some runtimes. */
|
|
51
|
+
async function readBytesCapped(res: Response, maxBytes: number): Promise<ReadBytes> {
|
|
52
|
+
const body = res.body
|
|
53
|
+
if (body === null) return { bytes: new Uint8Array() }
|
|
54
|
+
const reader = body.getReader() as unknown as ChunkReader
|
|
55
|
+
const chunks: Uint8Array[] = []
|
|
56
|
+
let total = 0
|
|
57
|
+
try {
|
|
58
|
+
for (;;) {
|
|
59
|
+
const { done, value } = await reader.read()
|
|
60
|
+
if (done) return { bytes: concat(chunks, total) }
|
|
61
|
+
if (value === undefined) return { response: replayFrom(res, chunks, reader) }
|
|
62
|
+
chunks.push(value)
|
|
63
|
+
total += value.byteLength
|
|
64
|
+
if (total > maxBytes) return { response: replayFrom(res, chunks, reader) }
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
return { response: replayFrom(res, chunks, reader) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkReader): Response {
|
|
72
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
73
|
+
start(controller) {
|
|
74
|
+
for (const chunk of chunks) controller.enqueue(chunk)
|
|
75
|
+
},
|
|
76
|
+
async pull(controller) {
|
|
77
|
+
try {
|
|
78
|
+
const { done, value } = await reader.read()
|
|
79
|
+
if (done || value === undefined) controller.close()
|
|
80
|
+
else controller.enqueue(value)
|
|
81
|
+
} catch (error) {
|
|
82
|
+
controller.error(error)
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
cancel: (reason) => reader.cancel(reason),
|
|
86
|
+
})
|
|
87
|
+
return new Response(stream, {
|
|
88
|
+
status: res.status,
|
|
89
|
+
statusText: res.statusText,
|
|
90
|
+
headers: res.headers,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
20
94
|
/**
|
|
21
95
|
* A {@link definePlugin} plugin that adds a content-hash `ETag` to `GET` `200` responses and returns
|
|
22
96
|
* **`304 Not Modified`** when the client's `If-None-Match` matches - saving bandwidth on unchanged
|
|
23
|
-
* responses.
|
|
24
|
-
*
|
|
97
|
+
* responses. Built on the portable `onResponseBody` tier: the hook receives the final
|
|
98
|
+
* framework-serialized bytes on every runtime with nothing drained, so the middleware stays on
|
|
99
|
+
* Node's direct writer. Handler-returned raw `Response`s (streams, proxied fetches) use a capped,
|
|
100
|
+
* replaying fallback so oversized bodies remain intact. Idempotent.
|
|
25
101
|
*/
|
|
26
102
|
export function etag(options: ETagOptions = {}) {
|
|
27
|
-
const
|
|
103
|
+
const weak = options.weak ?? true
|
|
104
|
+
const prefix = weak ? "W/" : ""
|
|
28
105
|
const maxBytes = options.maxBytes ?? 1_000_000
|
|
29
|
-
if (!Number.
|
|
30
|
-
throw new Error("etag: maxBytes must be a non-negative integer")
|
|
106
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
|
107
|
+
throw new Error("etag: maxBytes must be a non-negative safe integer")
|
|
31
108
|
}
|
|
32
109
|
return definePlugin("etag", (app) =>
|
|
33
|
-
app.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
110
|
+
app.use({
|
|
111
|
+
onResponseBody(body, headers, req, status) {
|
|
112
|
+
if (req.method !== "GET" || status !== 200) return undefined
|
|
113
|
+
const bytes = typeof body === "string" ? ETAG_ENCODER.encode(body) : body
|
|
114
|
+
if (bytes.byteLength > maxBytes) return undefined
|
|
115
|
+
const finish = (fingerprint: string): ResponseBodyReplacement | undefined => {
|
|
116
|
+
const tag = prefix + String.fromCharCode(34) + fingerprint + String.fromCharCode(34)
|
|
117
|
+
headers.set("etag", tag)
|
|
118
|
+
if (matchesIfNoneMatch(req.header("if-none-match"), tag)) {
|
|
119
|
+
// A 304 carries no body - drop the body-describing headers so strict intermediaries
|
|
120
|
+
// do not see a null body with a non-zero Content-Length.
|
|
121
|
+
headers.delete("content-length")
|
|
122
|
+
headers.delete("content-type")
|
|
123
|
+
return { body: null, status: 304 }
|
|
124
|
+
}
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
if (weak) return finish(fnv1a(bytes))
|
|
128
|
+
return sha256Tag(bytes).then(finish)
|
|
129
|
+
},
|
|
130
|
+
onResponseRaw(response, req) {
|
|
131
|
+
if (req.method !== "GET" || response.status !== 200 || response.body === null) {
|
|
132
|
+
return response
|
|
133
|
+
}
|
|
134
|
+
const declared = response.headers.get("content-length")
|
|
135
|
+
if (declared !== null && /^(?:0|[1-9]\d*)$/.test(declared) && Number(declared) > maxBytes) {
|
|
136
|
+
return response
|
|
137
|
+
}
|
|
138
|
+
return etagRawResponse(response, req, weak, prefix, maxBytes)
|
|
139
|
+
},
|
|
50
140
|
}),
|
|
51
141
|
)
|
|
52
142
|
}
|
|
53
143
|
|
|
144
|
+
async function etagRawResponse(
|
|
145
|
+
response: Response,
|
|
146
|
+
req: Request,
|
|
147
|
+
weak: boolean,
|
|
148
|
+
prefix: string,
|
|
149
|
+
maxBytes: number,
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
const read = await readBytesCapped(response, maxBytes)
|
|
152
|
+
if ("response" in read) return read.response
|
|
153
|
+
const fingerprint = weak ? fnv1a(read.bytes) : await sha256Tag(read.bytes)
|
|
154
|
+
const tag = prefix + String.fromCharCode(34) + fingerprint + String.fromCharCode(34)
|
|
155
|
+
const headers = new Headers(response.headers)
|
|
156
|
+
headers.set("etag", tag)
|
|
157
|
+
if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
|
|
158
|
+
headers.delete("content-length")
|
|
159
|
+
headers.delete("content-type")
|
|
160
|
+
return new Response(null, { status: 304, headers })
|
|
161
|
+
}
|
|
162
|
+
// The body was consumed and rebuilt from bytes. Re-derive framing at the serving adapter
|
|
163
|
+
// instead of trusting a stale upstream declaration.
|
|
164
|
+
headers.delete("content-length")
|
|
165
|
+
return new Response(read.bytes, {
|
|
166
|
+
status: response.status,
|
|
167
|
+
statusText: response.statusText,
|
|
168
|
+
headers,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
54
172
|
function matchesIfNoneMatch(value: string | null, tag: string): boolean {
|
|
55
173
|
if (value === null) return false
|
|
56
174
|
const normalizedTag = weakComparableTag(tag)
|
|
@@ -65,39 +183,3 @@ function matchesIfNoneMatch(value: string | null, tag: string): boolean {
|
|
|
65
183
|
function weakComparableTag(tag: string): string {
|
|
66
184
|
return tag.startsWith("W/") ? tag.slice(2) : tag
|
|
67
185
|
}
|
|
68
|
-
|
|
69
|
-
function parseLength(value: string | null): number | undefined {
|
|
70
|
-
if (value === null) return undefined
|
|
71
|
-
if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
|
|
72
|
-
return Number(value)
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array | null> {
|
|
76
|
-
const body = res.clone().body
|
|
77
|
-
if (body === null) return new Uint8Array()
|
|
78
|
-
const reader = body.getReader()
|
|
79
|
-
const chunks: Uint8Array[] = []
|
|
80
|
-
let total = 0
|
|
81
|
-
try {
|
|
82
|
-
for (;;) {
|
|
83
|
-
const { done, value } = await reader.read()
|
|
84
|
-
if (done) {
|
|
85
|
-
const out = new Uint8Array(total)
|
|
86
|
-
let offset = 0
|
|
87
|
-
for (const chunk of chunks) {
|
|
88
|
-
out.set(chunk, offset)
|
|
89
|
-
offset += chunk.byteLength
|
|
90
|
-
}
|
|
91
|
-
return out
|
|
92
|
-
}
|
|
93
|
-
total += value.byteLength
|
|
94
|
-
if (total > maxBytes) {
|
|
95
|
-
await reader.cancel()
|
|
96
|
-
return null
|
|
97
|
-
}
|
|
98
|
-
chunks.push(value)
|
|
99
|
-
}
|
|
100
|
-
} catch {
|
|
101
|
-
return null
|
|
102
|
-
}
|
|
103
|
-
}
|
package/src/language.ts
CHANGED
|
@@ -86,24 +86,23 @@ export function language<const L extends readonly string[]>(options: LanguageOpt
|
|
|
86
86
|
throw new Error("language: defaultLanguage must be in supported")
|
|
87
87
|
}
|
|
88
88
|
const emitHeader = options.header !== false
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
// The match is a pure function of the Accept-Language header, so the response hooks recompute it
|
|
90
|
+
// instead of pairing state through a WeakMap - that is what lets the Node twin exist at all (it
|
|
91
|
+
// has no `Request` identity to key on), keeps both hooks trivially identical, and lets the derive
|
|
92
|
+
// read the header without materializing a Web `Request` on the Node adapter.
|
|
91
93
|
return definePlugin("language", (app) =>
|
|
92
94
|
app
|
|
93
95
|
.derive((c) => {
|
|
94
|
-
const match = pickLanguage(c.
|
|
95
|
-
matches.set(c.req, match)
|
|
96
|
+
const match = pickLanguage(c.header("accept-language"), supported, defaultLanguage)
|
|
96
97
|
return { language: match.language, languageMatch: match }
|
|
97
98
|
})
|
|
98
|
-
.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
headers.set("content-language", match.language)
|
|
106
|
-
return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
|
|
99
|
+
.use({
|
|
100
|
+
onResponseHeaders(headers, req) {
|
|
101
|
+
if (!emitHeader) return
|
|
102
|
+
if (headers.has("content-language")) return
|
|
103
|
+
const match = pickLanguage(req.header("accept-language"), supported, defaultLanguage)
|
|
104
|
+
headers.set("content-language", match.language)
|
|
105
|
+
},
|
|
107
106
|
}),
|
|
108
107
|
)
|
|
109
108
|
}
|
package/src/logger.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { definePlugin } from "@nifrajs/core/server"
|
|
1
|
+
import { definePlugin, pathnameOf } from "@nifrajs/core/server"
|
|
2
2
|
|
|
3
3
|
/** Structured fields logged per request. */
|
|
4
4
|
export interface RequestLogFields {
|
|
@@ -23,13 +23,20 @@ export function logger(options: LoggerOptions = {}) {
|
|
|
23
23
|
// A request logger's whole job is to log; the default writes JSON to stdout, routable via `log`.
|
|
24
24
|
const sink = options.log ?? ((fields: RequestLogFields) => console.log(JSON.stringify(fields)))
|
|
25
25
|
const starts = new WeakMap<Request, number>()
|
|
26
|
+
// Twin-side starts, keyed by the NodeRequestContext identity (the same object reaches the request
|
|
27
|
+
// and response twins), so Node logging never has to materialize a Web `Request`.
|
|
28
|
+
const nativeStarts = new WeakMap<object, number>()
|
|
26
29
|
return definePlugin("logger", (app) =>
|
|
27
|
-
app
|
|
28
|
-
|
|
30
|
+
app.use({
|
|
31
|
+
onRequest(req) {
|
|
29
32
|
starts.set(req, performance.now())
|
|
30
33
|
return undefined
|
|
31
|
-
}
|
|
32
|
-
|
|
34
|
+
},
|
|
35
|
+
onNodeRequest(req) {
|
|
36
|
+
nativeStarts.set(req, performance.now())
|
|
37
|
+
return undefined
|
|
38
|
+
},
|
|
39
|
+
onResponse(res, req) {
|
|
33
40
|
const start = starts.get(req)
|
|
34
41
|
starts.delete(req)
|
|
35
42
|
sink({
|
|
@@ -39,6 +46,17 @@ export function logger(options: LoggerOptions = {}) {
|
|
|
39
46
|
ms: start === undefined ? 0 : Math.round(performance.now() - start),
|
|
40
47
|
})
|
|
41
48
|
return res
|
|
42
|
-
}
|
|
49
|
+
},
|
|
50
|
+
onNodeResponse(res, req) {
|
|
51
|
+
const start = nativeStarts.get(req)
|
|
52
|
+
nativeStarts.delete(req)
|
|
53
|
+
sink({
|
|
54
|
+
method: req.method,
|
|
55
|
+
path: pathnameOf(req.url),
|
|
56
|
+
status: res.status,
|
|
57
|
+
ms: start === undefined ? 0 : Math.round(performance.now() - start),
|
|
58
|
+
})
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
43
61
|
)
|
|
44
62
|
}
|