@nifrajs/middleware 2.8.1 → 2.9.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.
Files changed (66) 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/idempotency.d.ts.map +1 -1
  25. package/dist/idempotency.js +10 -1
  26. package/dist/idempotency.js.map +1 -1
  27. package/dist/jwt.js +1 -1
  28. package/dist/jwt.js.map +1 -1
  29. package/dist/language.d.ts.map +1 -1
  30. package/dist/language.js +14 -15
  31. package/dist/language.js.map +1 -1
  32. package/dist/logger.d.ts.map +1 -1
  33. package/dist/logger.js +34 -16
  34. package/dist/logger.js.map +1 -1
  35. package/dist/powered-by.d.ts.map +1 -1
  36. package/dist/powered-by.js +8 -5
  37. package/dist/powered-by.js.map +1 -1
  38. package/dist/pretty-json.d.ts +5 -2
  39. package/dist/pretty-json.d.ts.map +1 -1
  40. package/dist/pretty-json.js +85 -64
  41. package/dist/pretty-json.js.map +1 -1
  42. package/dist/rate-limit.d.ts.map +1 -1
  43. package/dist/rate-limit.js +76 -19
  44. package/dist/rate-limit.js.map +1 -1
  45. package/dist/request-id.js +1 -1
  46. package/dist/request-id.js.map +1 -1
  47. package/dist/security-headers.d.ts +8 -4
  48. package/dist/security-headers.d.ts.map +1 -1
  49. package/dist/security-headers.js +18 -16
  50. package/dist/security-headers.js.map +1 -1
  51. package/package.json +3 -3
  52. package/src/_utils.ts +39 -24
  53. package/src/cache-control.ts +27 -14
  54. package/src/cache.ts +2 -4
  55. package/src/compression.ts +202 -62
  56. package/src/cors.ts +23 -14
  57. package/src/etag.ts +141 -59
  58. package/src/idempotency.ts +11 -1
  59. package/src/jwt.ts +1 -1
  60. package/src/language.ts +12 -13
  61. package/src/logger.ts +24 -6
  62. package/src/powered-by.ts +8 -4
  63. package/src/pretty-json.ts +95 -70
  64. package/src/rate-limit.ts +86 -20
  65. package/src/request-id.ts +1 -1
  66. package/src/security-headers.ts +17 -15
@@ -1,4 +1,5 @@
1
- import { definePlugin } from "@nifrajs/core/server"
1
+ import { definePlugin, type Middleware } from "@nifrajs/core/server"
2
+ import { withHeaders } from "./_utils.ts"
2
3
 
3
4
  export interface CacheControlOptions {
4
5
  /** Methods whose responses get the header. Default `["GET", "HEAD"]`. */
@@ -29,17 +30,29 @@ export function cacheControl(
29
30
  const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()))
30
31
  const statusOk = options.status ?? ((status: number) => status >= 200 && status < 300)
31
32
  const respectExisting = options.respectExisting !== false
32
- const resolve = typeof value === "function" ? value : () => value
33
- return definePlugin("cacheControl", (app) =>
34
- app.onResponse((res, req) => {
35
- if (!methods.has(req.method)) return res
36
- if (!statusOk(res.status)) return res
37
- if (respectExisting && res.headers.has("cache-control")) return res
38
- const directive = resolve(req)
39
- if (directive === undefined) return res
40
- const headers = new Headers(res.headers)
41
- headers.set("cache-control", directive)
42
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
43
- }),
44
- )
33
+ return definePlugin("cacheControl", (app) => {
34
+ // A fixed directive needs nothing from the request beyond the method, so it ships as one
35
+ // portable header hook. A dynamic directive's resolver takes a real `Request`, which only the
36
+ // Web response walk carries - it keeps the full `onResponse` contract (and its cost).
37
+ const middleware: Middleware =
38
+ typeof value === "string"
39
+ ? {
40
+ onResponseHeaders(headers, req, status) {
41
+ if (!methods.has(req.method) || !statusOk(status)) return
42
+ if (respectExisting && headers.has("cache-control")) return
43
+ headers.set("cache-control", value)
44
+ },
45
+ }
46
+ : {
47
+ onResponse(res, req) {
48
+ if (!methods.has(req.method)) return res
49
+ if (!statusOk(res.status)) return res
50
+ if (respectExisting && res.headers.has("cache-control")) return res
51
+ const directive = value(req)
52
+ if (directive === undefined) return res
53
+ return withHeaders(res, (headers) => headers.set("cache-control", directive))
54
+ },
55
+ }
56
+ return app.use(middleware)
57
+ })
45
58
  }
package/src/cache.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Middleware } from "@nifrajs/core/server"
2
- import type { MaybePromise } from "./_utils.ts"
2
+ import { type MaybePromise, withHeaders } from "./_utils.ts"
3
3
 
4
4
  export interface CachedResponse {
5
5
  readonly status: number
@@ -174,9 +174,7 @@ function withStatusHeader(
174
174
  status: "HIT" | "MISS" | "BYPASS",
175
175
  ): Response {
176
176
  if (header === false) return res
177
- const headers = new Headers(res.headers)
178
- headers.set(header, status)
179
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
177
+ return withHeaders(res, (headers) => headers.set(header, status))
180
178
  }
181
179
 
182
180
  /**
@@ -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
- }