@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.
Files changed (59) hide show
  1. package/README.md +22 -1
  2. package/dist/_utils.d.ts +6 -1
  3. package/dist/_utils.d.ts.map +1 -1
  4. package/dist/_utils.js +39 -28
  5. package/dist/_utils.js.map +1 -1
  6. package/dist/cache-control.d.ts.map +1 -1
  7. package/dist/cache-control.js +31 -15
  8. package/dist/cache-control.js.map +1 -1
  9. package/dist/cache.d.ts +1 -1
  10. package/dist/cache.d.ts.map +1 -1
  11. package/dist/cache.js +2 -3
  12. package/dist/cache.js.map +1 -1
  13. package/dist/compression.d.ts +10 -10
  14. package/dist/compression.d.ts.map +1 -1
  15. package/dist/compression.js +191 -70
  16. package/dist/compression.js.map +1 -1
  17. package/dist/cors.d.ts.map +1 -1
  18. package/dist/cors.js +27 -16
  19. package/dist/cors.js.map +1 -1
  20. package/dist/etag.d.ts +4 -2
  21. package/dist/etag.d.ts.map +1 -1
  22. package/dist/etag.js +135 -63
  23. package/dist/etag.js.map +1 -1
  24. package/dist/language.d.ts.map +1 -1
  25. package/dist/language.js +14 -15
  26. package/dist/language.js.map +1 -1
  27. package/dist/logger.d.ts.map +1 -1
  28. package/dist/logger.js +34 -16
  29. package/dist/logger.js.map +1 -1
  30. package/dist/powered-by.d.ts.map +1 -1
  31. package/dist/powered-by.js +8 -5
  32. package/dist/powered-by.js.map +1 -1
  33. package/dist/pretty-json.d.ts +5 -2
  34. package/dist/pretty-json.d.ts.map +1 -1
  35. package/dist/pretty-json.js +85 -64
  36. package/dist/pretty-json.js.map +1 -1
  37. package/dist/rate-limit.d.ts.map +1 -1
  38. package/dist/rate-limit.js +76 -19
  39. package/dist/rate-limit.js.map +1 -1
  40. package/dist/request-id.js +1 -1
  41. package/dist/request-id.js.map +1 -1
  42. package/dist/security-headers.d.ts +8 -4
  43. package/dist/security-headers.d.ts.map +1 -1
  44. package/dist/security-headers.js +18 -16
  45. package/dist/security-headers.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/_utils.ts +39 -24
  48. package/src/cache-control.ts +27 -14
  49. package/src/cache.ts +2 -4
  50. package/src/compression.ts +202 -62
  51. package/src/cors.ts +23 -14
  52. package/src/etag.ts +141 -59
  53. package/src/language.ts +12 -13
  54. package/src/logger.ts +24 -6
  55. package/src/powered-by.ts +8 -4
  56. package/src/pretty-json.ts +95 -70
  57. package/src/rate-limit.ts +86 -20
  58. package/src/request-id.ts +1 -1
  59. package/src/security-headers.ts +17 -15
@@ -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") // compressed length is unknown up front → chunked
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) headers.set("vary", "Accept-Encoding")
34
- else if (!vary.toLowerCase().split(",").includes("accept-encoding"))
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
- return headers
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` (streaming, no
42
- * full-body buffering), so it runs on every nifra runtime including the edge. gzip is the one encoding
43
- * `CompressionStream` guarantees everywhere; brotli isn't part of the standard, so it's not offered.
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
- * The body is **peeked** up to `threshold` bytes to enforce the size floor: a response that ends below
46
- * the threshold is sent uncompressed (gzip's ~20-byte overhead would enlarge it), and only larger
47
- * bodies are compressed - the buffered prefix is replayed and the remainder streamed. (Runtimes rarely
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 (204/304/HEAD),
51
- * `Range` responses (206 / `Content-Range`), `Cache-Control: no-transform`, and non-compressible types.
52
- * Adds `Vary: Accept-Encoding`.
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.onResponse(async (res, req) => {
63
- const body = res.body
64
- if (body === null) return res // 204/304/HEAD - nothing to compress
65
- if (!(req.headers.get("accept-encoding") ?? "").toLowerCase().includes("gzip")) return res
66
- if (res.headers.has("content-encoding")) return res // already encoded
67
- if (res.status === 206 || res.headers.has("content-range")) return res // ranges pass through
68
- if ((res.headers.get("cache-control") ?? "").includes("no-transform")) return res
69
- if (!isCompressible(res.headers.get("content-type") ?? "")) return res
70
- const declared = res.headers.get("content-length")
71
- if (declared !== null && Number(declared) < threshold) return res // fast path when length is known
72
-
73
- // Peek up to `threshold` bytes to decide whether compressing is worth it.
74
- const reader = body.getReader()
75
- const buffered: Uint8Array[] = []
76
- let total = 0
77
- while (total < threshold) {
78
- const { done, value } = await reader.read()
79
- if (done) {
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 onResponse (runs on every response).
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
- onResponse(res, req) {
77
- const allowOrigin = resolveAllowOrigin(origin, req.headers.get("origin"))
78
- if (allowOrigin === null) return res
79
- // Mutate in place on the common (mutable-headers) response; clone only for an immutable one.
80
- return withHeaders(res, (headers) => {
81
- headers.set("Access-Control-Allow-Origin", allowOrigin)
82
- if (allowOrigin !== "*") headers.append("Vary", "Origin")
83
- if (credentials) headers.set("Access-Control-Allow-Credentials", "true")
84
- if (exposedHeaders !== undefined) {
85
- headers.set("Access-Control-Expose-Headers", exposedHeaders)
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. It reads and rebuilds small bodies only; larger responses pass through unchanged.
24
- * Idempotent.
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 prefix = (options.weak ?? true) ? "W/" : ""
103
+ const weak = options.weak ?? true
104
+ const prefix = weak ? "W/" : ""
28
105
  const maxBytes = options.maxBytes ?? 1_000_000
29
- if (!Number.isInteger(maxBytes) || maxBytes < 0) {
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.onResponse(async (res, req) => {
34
- if (req.method !== "GET" || res.status !== 200 || res.body === null) return res
35
- const declared = parseLength(res.headers.get("content-length"))
36
- if (declared !== undefined && declared > maxBytes) return res
37
- const body = await readBytesCapped(res, maxBytes)
38
- if (body === null) return res
39
- const tag = `${prefix}"${fnv1a(body)}"`
40
- const headers = new Headers(res.headers)
41
- headers.set("ETag", tag)
42
- if (matchesIfNoneMatch(req.headers.get("if-none-match"), tag)) {
43
- // A 304 carries no body - drop the body-describing headers so strict intermediaries don't see a
44
- // null body with a non-zero Content-Length.
45
- headers.delete("content-length")
46
- headers.delete("content-type")
47
- return new Response(null, { status: 304, headers })
48
- }
49
- return new Response(body, { status: res.status, statusText: res.statusText, headers })
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
- const matches = new WeakMap<Request, LanguageMatch>()
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.req.headers.get("accept-language"), supported, defaultLanguage)
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
- .onResponse((res, req) => {
99
- if (!emitHeader) return res
100
- const match = matches.get(req)
101
- if (match === undefined) return res
102
- matches.delete(req)
103
- if (res.headers.has("content-language")) return res
104
- const headers = new Headers(res.headers)
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
- .onRequest((req) => {
30
+ app.use({
31
+ onRequest(req) {
29
32
  starts.set(req, performance.now())
30
33
  return undefined
31
- })
32
- .onResponse((res, req) => {
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
  }