@nifrajs/middleware 2.8.2 → 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 (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
package/src/powered-by.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 PoweredByOptions {
5
4
  /** Header name. Default `"x-powered-by"`. */
@@ -21,11 +20,16 @@ export function poweredBy(options: PoweredByOptions = {}): Middleware {
21
20
  if (header.trim() === "") throw new Error("poweredBy: header is empty")
22
21
  if (/[\r\n]/.test(value)) throw new Error("poweredBy: value contains a newline")
23
22
 
23
+ // Respecting an existing value IS the static tier's contract (declared headers are defaults), so
24
+ // the default configuration declares the header instead of writing it per response and leaves the
25
+ // app on its fused/native lanes. Overwriting is a decision about the response, so it stays a hook.
26
+ if (respectExisting) {
27
+ return { name: "powered-by", responseHeaders: { [header]: value } }
28
+ }
24
29
  return {
25
30
  name: "powered-by",
26
- onResponse(res) {
27
- if (respectExisting && res.headers.has(header)) return res
28
- return withHeaders(res, (headers) => headers.set(header, value))
31
+ onResponseHeaders(headers) {
32
+ headers.set(header, value)
29
33
  },
30
34
  }
31
35
  }
@@ -1,4 +1,4 @@
1
- import { definePlugin } from "@nifrajs/core/server"
1
+ import { definePlugin, type NodeRequestContext } from "@nifrajs/core/server"
2
2
 
3
3
  export interface PrettyJsonOptions {
4
4
  /** JSON indentation spaces. Default `2`. */
@@ -9,8 +9,10 @@ export interface PrettyJsonOptions {
9
9
  readonly newline?: boolean
10
10
  /** Optional query toggle. When set, pretty printing runs only when the parameter is present. */
11
11
  readonly query?: string | false
12
- /** Only pretty-print matching requests. Default `true`. */
13
- readonly enabled?: boolean | ((request: Request) => boolean)
12
+ /** Only pretty-print matching requests (the predicate receives the portable request view -
13
+ * `{ method, url, header(name) }` - so the check runs without materializing a `Request`).
14
+ * Default `true`. */
15
+ readonly enabled?: boolean | ((request: NodeRequestContext) => boolean)
14
16
  }
15
17
 
16
18
  const JSON_TYPE = /^(?:application\/json|[^/]+\/[^;]+\+json)(?:\s*;|$)/i
@@ -19,25 +21,21 @@ function isJson(contentType: string): boolean {
19
21
  return JSON_TYPE.test(contentType)
20
22
  }
21
23
 
22
- function parseLength(value: string | null): number | undefined {
23
- if (value === null) return undefined
24
- if (!/^(?:0|[1-9]\d*)$/.test(value)) return undefined
25
- return Number(value)
26
- }
27
-
28
- /**
29
- * The two reader methods this file uses, structurally.
30
- *
31
- * Named by use rather than imported: `lib.dom`'s `ReadableStreamDefaultReader` and Bun's augmented one
32
- * are not mutually assignable (Bun adds `readMany`), and `ReturnType<…getReader>` resolves to the BYOB
33
- * overload, whose `read` requires an argument. A structural shape sidesteps all of it without a cast.
34
- */
35
24
  interface ChunkReader {
36
25
  read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array }>
37
26
  cancel(reason?: unknown): Promise<void>
38
27
  }
39
28
 
40
- /** Re-emit the bytes already pulled, then the untouched remainder of the same reader. */
29
+ const concat = (chunks: readonly Uint8Array[], total: number): Uint8Array => {
30
+ const out = new Uint8Array(total)
31
+ let offset = 0
32
+ for (const chunk of chunks) {
33
+ out.set(chunk, offset)
34
+ offset += chunk.byteLength
35
+ }
36
+ return out
37
+ }
38
+
41
39
  function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkReader): Response {
42
40
  const stream = new ReadableStream<Uint8Array>({
43
41
  start(controller) {
@@ -46,12 +44,9 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
46
44
  async pull(controller) {
47
45
  try {
48
46
  const { done, value } = await reader.read()
49
- // No value and not done cannot happen for a byte stream, but it means the same thing here:
50
- // there is nothing further to emit.
51
47
  if (done || value === undefined) controller.close()
52
48
  else controller.enqueue(value)
53
49
  } catch (error) {
54
- // The upstream body failed; surface that to the client rather than truncating silently.
55
50
  controller.error(error)
56
51
  }
57
52
  },
@@ -64,37 +59,26 @@ function replayFrom(res: Response, chunks: readonly Uint8Array[], reader: ChunkR
64
59
  })
65
60
  }
66
61
 
67
- type Peeked = { readonly text: string } | { readonly response: Response }
62
+ type Peeked =
63
+ | { readonly text: string; readonly bytes: Uint8Array }
64
+ | { readonly response: Response }
68
65
 
69
- /**
70
- * Read up to `maxBytes` of a response, without disturbing it when the answer is "pass through".
71
- *
72
- * `Response.clone()` is the obvious way to peek and is a trap here. Cancelling the clone's reader once
73
- * the cap is exceeded also stalls the ORIGINAL body in Bun, so an oversized streamed response hung the
74
- * client instead of passing through - a hang, from a middleware whose entire job is cosmetic. (A clone
75
- * read to completion is fine; only the cancel breaks it, which is why every buffered case passed and
76
- * only a streamed one failed.) Abandoning the clone rather than cancelling it trades the hang for
77
- * buffering the whole oversized body, which is the exact cost the cap exists to avoid.
78
- *
79
- * So the body is read directly and, when it proves too large, replayed: the bytes already pulled are
80
- * re-emitted ahead of the rest of the same reader. No tee, nothing buffered past the cap, and the
81
- * response the client receives is byte-for-byte the one the handler produced.
82
- */
83
66
  async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
84
67
  const body = res.body
85
68
  if (body === null) return { response: res }
86
- const reader = body.getReader()
69
+ const reader = body.getReader() as unknown as ChunkReader
87
70
  const chunks: Uint8Array[] = []
88
71
  let total = 0
89
72
  try {
90
73
  while (true) {
91
74
  const { done, value } = await reader.read()
92
75
  if (done) {
93
- const decoder = new TextDecoder()
76
+ const decoder = new TextDecoder("utf-8", { fatal: true })
94
77
  let text = ""
95
78
  for (const chunk of chunks) text += decoder.decode(chunk, { stream: true })
96
- return { text: text + decoder.decode() }
79
+ return { text: text + decoder.decode(), bytes: concat(chunks, total) }
97
80
  }
81
+ if (value === undefined) return { response: replayFrom(res, chunks, reader) }
98
82
  chunks.push(value)
99
83
  total += value.byteLength
100
84
  if (total > maxBytes) return { response: replayFrom(res, chunks, reader) }
@@ -104,6 +88,10 @@ async function peekText(res: Response, maxBytes: number): Promise<Peeked> {
104
88
  }
105
89
  }
106
90
 
91
+ function requestView(request: Request): NodeRequestContext {
92
+ return { method: request.method, url: request.url, header: (name) => request.headers.get(name) }
93
+ }
94
+
107
95
  /**
108
96
  * Pretty-print JSON responses for debugging and developer-facing APIs. It only touches JSON content,
109
97
  * skips encoded responses, caps inspection size, and leaves invalid JSON untouched.
@@ -114,8 +102,8 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
114
102
  throw new Error("prettyJson: spaces must be an integer from 0 to 10")
115
103
  }
116
104
  const maxBytes = options.maxBytes ?? 1_000_000
117
- if (!Number.isInteger(maxBytes) || maxBytes < 0) {
118
- throw new Error("prettyJson: maxBytes must be a non-negative integer")
105
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
106
+ throw new Error("prettyJson: maxBytes must be a non-negative safe integer")
119
107
  }
120
108
  const newline = options.newline !== false
121
109
  const query = options.query ?? false
@@ -123,37 +111,74 @@ export function prettyJson(options: PrettyJsonOptions = {}) {
123
111
  const enabled = options.enabled ?? true
124
112
  const isEnabled = typeof enabled === "function" ? enabled : enabled ? () => true : () => false
125
113
 
114
+ const decoder = new TextDecoder()
115
+ const encoder = new TextEncoder()
126
116
  return definePlugin("prettyJson", (app) =>
127
- app.onResponse(async (res, req) => {
128
- if (!isEnabled(req)) return res
129
- if (query !== false && !new URL(req.url).searchParams.has(query)) return res
130
- if (res.body === null || res.headers.has("content-encoding")) return res
131
- if (!isJson(res.headers.get("content-type") ?? "")) return res
132
- const declared = parseLength(res.headers.get("content-length"))
133
- if (declared !== undefined && declared > maxBytes) return res
134
-
135
- const peeked = await peekText(res, maxBytes)
136
- if ("response" in peeked) return peeked.response
137
- let parsed: unknown
138
- try {
139
- parsed = JSON.parse(peeked.text)
140
- } catch {
141
- // The body was read to decide that, so `res` can no longer be returned as-is. Rebuild it from
142
- // the text: re-encoding UTF-8 reproduces the original bytes, so `content-length` still holds.
143
- return new Response(peeked.text, {
144
- status: res.status,
145
- statusText: res.statusText,
146
- headers: res.headers,
147
- })
148
- }
149
-
150
- const headers = new Headers(res.headers)
151
- headers.delete("content-length")
152
- return new Response(`${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`, {
153
- status: res.status,
154
- statusText: res.statusText,
155
- headers,
156
- })
117
+ app.use({
118
+ onResponseBody(body, headers, req) {
119
+ if (!isEnabled(req)) return undefined
120
+ if (query !== false && !new URL(req.url).searchParams.has(query)) return undefined
121
+ if (headers.has("content-encoding")) return undefined
122
+ if (!isJson(headers.get("content-type") ?? "")) return undefined
123
+ const text = typeof body === "string" ? body : decoder.decode(body)
124
+ if (
125
+ (typeof body === "string" ? encoder.encode(body).byteLength : body.byteLength) > maxBytes
126
+ ) {
127
+ return undefined
128
+ }
129
+ let parsed: unknown
130
+ try {
131
+ parsed = JSON.parse(text)
132
+ } catch {
133
+ return undefined
134
+ }
135
+ return `${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`
136
+ },
137
+ onResponseRaw(response, req) {
138
+ if (!isEnabled(requestView(req))) return response
139
+ if (query !== false && !new URL(req.url).searchParams.has(query)) return response
140
+ if (
141
+ response.body === null ||
142
+ response.status === 204 ||
143
+ response.status === 205 ||
144
+ response.status === 304 ||
145
+ response.headers.has("content-encoding")
146
+ )
147
+ return response
148
+ if (!isJson(response.headers.get("content-type") ?? "")) return response
149
+ const declared = response.headers.get("content-length")
150
+ if (declared !== null && /^(?:0|[1-9]\d*)$/.test(declared) && Number(declared) > maxBytes) {
151
+ return response
152
+ }
153
+ return prettyRawResponse(response, maxBytes, spaces, newline)
154
+ },
157
155
  }),
158
156
  )
159
157
  }
158
+
159
+ async function prettyRawResponse(
160
+ response: Response,
161
+ maxBytes: number,
162
+ spaces: number,
163
+ newline: boolean,
164
+ ): Promise<Response> {
165
+ const peeked = await peekText(response, maxBytes)
166
+ if ("response" in peeked) return peeked.response
167
+ let parsed: unknown
168
+ try {
169
+ parsed = JSON.parse(peeked.text)
170
+ } catch {
171
+ return new Response(peeked.bytes, {
172
+ status: response.status,
173
+ statusText: response.statusText,
174
+ headers: response.headers,
175
+ })
176
+ }
177
+ const headers = new Headers(response.headers)
178
+ headers.delete("content-length")
179
+ return new Response(`${JSON.stringify(parsed, null, spaces)}${newline ? "\n" : ""}`, {
180
+ status: response.status,
181
+ statusText: response.statusText,
182
+ headers,
183
+ })
184
+ }
package/src/rate-limit.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
- import type { Middleware } from "@nifrajs/core/server"
2
+ import type { Middleware, NodeRequestContext, NodeResponseContext } from "@nifrajs/core/server"
3
+ import { withHeaders, withNodeHeaders } from "./_utils.ts"
3
4
 
4
5
  export interface RateLimitResult {
5
6
  /** Hits recorded in the current window, including this one. */
@@ -184,6 +185,28 @@ function defaultKey(
184
185
  return allowGlobalKey ? "global" : null
185
186
  }
186
187
 
188
+ /** {@link defaultKey} against the allocation-light native request view - same logic, same order. */
189
+ function nativeKey(
190
+ req: NodeRequestContext,
191
+ trustedProxies: number,
192
+ header: string | undefined,
193
+ allowGlobalKey: boolean,
194
+ ): string | null {
195
+ if (header !== undefined) {
196
+ const ip = req.header(header)
197
+ if (ip !== null && ip.trim() !== "") return ip.trim()
198
+ }
199
+ if (trustedProxies > 0) {
200
+ const xff = req.header("x-forwarded-for")
201
+ if (xff !== null) {
202
+ const parts = xff.split(",")
203
+ const ip = parts[parts.length - trustedProxies]?.trim()
204
+ if (ip !== undefined && ip !== "") return ip
205
+ }
206
+ }
207
+ return allowGlobalKey ? "global" : null
208
+ }
209
+
187
210
  /**
188
211
  * Rate limiting as a {@link Middleware}. Runs in `onRequest` (before routing, so it
189
212
  * also covers 404s); over the limit → `429` + `Retry-After`. Every response carries
@@ -216,35 +239,78 @@ export function rateLimit(options: RateLimitOptions): Middleware {
216
239
  const keyOf =
217
240
  options.key ?? ((req: Request) => defaultKey(req, trustedProxies, header, allowGlobalKey))
218
241
  const quota = new WeakMap<Request, { remaining: number; resetSeconds: number }>()
242
+ // State for the Node twins, keyed by the NodeRequestContext identity (the same object is passed
243
+ // to the request and response twins - the core identity contract that replaces `Request` keying).
244
+ const nativeQuota = new WeakMap<object, { remaining: number; resetSeconds: number }>()
219
245
 
220
- const middleware: Middleware = {
221
- name: "rate-limit",
222
- async onRequest(req) {
223
- const key = keyOf(req)
224
- if (typeof key !== "string" || key.trim() === "") {
225
- return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
226
- }
227
- const { count, resetAt } = await store.hit(key, windowMs)
228
- const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
229
- quota.set(req, { remaining: Math.max(0, max - count), resetSeconds })
230
- if (count > max) {
231
- return new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
246
+ interface QuotaInfo {
247
+ readonly remaining: number
248
+ readonly resetSeconds: number
249
+ }
250
+
251
+ const hitStore = async (
252
+ key: string | null,
253
+ ): Promise<{ info: QuotaInfo; reject: Response | undefined } | Response> => {
254
+ if (typeof key !== "string" || key.trim() === "") {
255
+ return Response.json({ ok: false, error: "rate_limit_key_unavailable" }, { status: 500 })
256
+ }
257
+ const { count, resetAt } = await store.hit(key, windowMs)
258
+ const resetSeconds = Math.max(0, Math.ceil((resetAt - Date.now()) / 1000))
259
+ const info = { remaining: Math.max(0, max - count), resetSeconds }
260
+ if (count > max) {
261
+ return {
262
+ info,
263
+ reject: new Response(JSON.stringify({ ok: false, error: "rate_limited" }), {
232
264
  status: 429,
233
265
  headers: { "content-type": "application/json", "retry-after": String(resetSeconds) },
234
- })
266
+ }),
235
267
  }
236
- return undefined
268
+ }
269
+ return { info, reject: undefined }
270
+ }
271
+
272
+ const middleware: Middleware = {
273
+ name: "rate-limit",
274
+ async onRequest(req) {
275
+ const outcome = await hitStore(keyOf(req))
276
+ if (outcome instanceof Response) return outcome
277
+ quota.set(req, outcome.info)
278
+ return outcome.reject
237
279
  },
238
280
  onResponse(res, req) {
239
281
  const info = quota.get(req)
240
282
  if (info === undefined) return res
241
283
  quota.delete(req)
242
- const headers = new Headers(res.headers)
243
- headers.set("RateLimit-Limit", String(max))
244
- headers.set("RateLimit-Remaining", String(info.remaining))
245
- headers.set("RateLimit-Reset", String(info.resetSeconds))
246
- return new Response(res.body, { status: res.status, statusText: res.statusText, headers })
284
+ return withHeaders(res, (headers) => {
285
+ headers.set("RateLimit-Limit", String(max))
286
+ headers.set("RateLimit-Remaining", String(info.remaining))
287
+ headers.set("RateLimit-Reset", String(info.resetSeconds))
288
+ })
247
289
  },
290
+ // Node twins exist only for the built-in header/XFF key derivation - a custom `key` callback
291
+ // takes a real `Request`, which is exactly the object the native lane avoids building.
292
+ ...(options.key === undefined
293
+ ? {
294
+ onNodeRequest: async (req: NodeRequestContext) => {
295
+ const outcome = await hitStore(nativeKey(req, trustedProxies, header, allowGlobalKey))
296
+ if (outcome instanceof Response) return outcome
297
+ nativeQuota.set(req, outcome.info)
298
+ return outcome.reject
299
+ },
300
+ onNodeResponse: (res: NodeResponseContext, req: NodeRequestContext) => {
301
+ const info = nativeQuota.get(req)
302
+ if (info === undefined) return
303
+ nativeQuota.delete(req)
304
+ withNodeHeaders(res, (headers) => {
305
+ // Lowercase on purpose: the Web path's `Headers` lowercases names on the wire, so
306
+ // this is byte-identical output.
307
+ headers["ratelimit-limit"] = String(max)
308
+ headers["ratelimit-remaining"] = String(info.remaining)
309
+ headers["ratelimit-reset"] = String(info.resetSeconds)
310
+ })
311
+ },
312
+ }
313
+ : {}),
248
314
  }
249
315
  return withRouteAssurance(middleware, {
250
316
  id: NIFRA_ASSURANCE.RATE_LIMITED,
package/src/request-id.ts CHANGED
@@ -22,7 +22,7 @@ export function requestId(options: RequestIdOptions = {}) {
22
22
  const generate = options.generate ?? (() => crypto.randomUUID())
23
23
  return definePlugin("requestId", (app) =>
24
24
  app.derive((c) => {
25
- const id = c.req.headers.get(header) ?? generate()
25
+ const id = c.header(header) ?? generate()
26
26
  c.set.headers[header] = id // echo on the response (route handlers)
27
27
  return { requestId: id }
28
28
  }),
@@ -1,6 +1,5 @@
1
1
  import { NIFRA_ASSURANCE, withRouteAssurance } from "@nifrajs/core/assurance"
2
2
  import type { Middleware } from "@nifrajs/core/server"
3
- import { withHeaders } from "./_utils.ts"
4
3
 
5
4
  export interface SecurityHeadersOptions {
6
5
  /** `Strict-Transport-Security`. Off by default - opt in once you're sure you're HTTPS-only. */
@@ -18,10 +17,14 @@ export interface SecurityHeadersOptions {
18
17
  }
19
18
 
20
19
  /**
21
- * A safe-by-default set of response security headers (`onResponse`, so they cover
22
- * errors and 404s too): `X-Content-Type-Options: nosniff`, `X-Frame-Options`, and
23
- * `Referrer-Policy` always; `Strict-Transport-Security` and `Content-Security-Policy`
24
- * only when configured (both are environment-/app-specific).
20
+ * A safe-by-default set of response security headers, covering errors and 404s too:
21
+ * `X-Content-Type-Options: nosniff`, `X-Frame-Options`, and `Referrer-Policy` always;
22
+ * `Strict-Transport-Security` and `Content-Security-Policy` only when configured (both are
23
+ * environment-/app-specific).
24
+ *
25
+ * Every value is fixed at construction, so these are declared statically rather than written by a
26
+ * response hook: an app whose response middleware is only this keeps the fused native lanes. A route
27
+ * that sets one of these names itself keeps its own value.
25
28
  */
26
29
  export function securityHeaders(options: SecurityHeadersOptions = {}): Middleware {
27
30
  const frameOptions = options.frameOptions ?? "DENY"
@@ -36,19 +39,18 @@ export function securityHeaders(options: SecurityHeadersOptions = {}): Middlewar
36
39
  hstsValue = parts.join("; ")
37
40
  }
38
41
 
42
+ const declared: Record<string, string> = {
43
+ "x-content-type-options": "nosniff",
44
+ "x-frame-options": frameOptions,
45
+ "referrer-policy": referrerPolicy,
46
+ }
47
+ if (hstsValue !== undefined) declared["strict-transport-security"] = hstsValue
48
+ if (csp !== undefined) declared["content-security-policy"] = csp
49
+
39
50
  return withRouteAssurance<Middleware>(
40
51
  {
41
52
  name: "security-headers",
42
- // Mutate in place on the common (mutable-headers) response; clone only for an immutable one
43
- // (see withHeaders - the old always-clone was ~3% of a realistic request).
44
- onResponse: (res) =>
45
- withHeaders(res, (headers) => {
46
- headers.set("X-Content-Type-Options", "nosniff")
47
- headers.set("X-Frame-Options", frameOptions)
48
- headers.set("Referrer-Policy", referrerPolicy)
49
- if (hstsValue !== undefined) headers.set("Strict-Transport-Security", hstsValue)
50
- if (csp !== undefined) headers.set("Content-Security-Policy", csp)
51
- }),
53
+ responseHeaders: declared,
52
54
  },
53
55
  {
54
56
  id: NIFRA_ASSURANCE.SECURITY_HEADERS,