@nifrajs/core 5.4.0 → 5.5.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 (89) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal/route-lanes.d.ts.map +1 -1
  5. package/dist/internal/route-lanes.js +2 -1
  6. package/dist/internal/route-lanes.js.map +1 -1
  7. package/dist/internal/route-program.d.ts +5 -0
  8. package/dist/internal/route-program.d.ts.map +1 -1
  9. package/dist/internal/route-program.js +36 -15
  10. package/dist/internal/route-program.js.map +1 -1
  11. package/dist/router/router.d.ts +3 -2
  12. package/dist/router/router.d.ts.map +1 -1
  13. package/dist/router/router.js +22 -6
  14. package/dist/router/router.js.map +1 -1
  15. package/dist/security/baseline.d.ts.map +1 -1
  16. package/dist/security/baseline.js +4 -3
  17. package/dist/security/baseline.js.map +1 -1
  18. package/dist/server/body-lane.d.ts.map +1 -1
  19. package/dist/server/body-lane.js +4 -3
  20. package/dist/server/body-lane.js.map +1 -1
  21. package/dist/server/body.d.ts +2 -0
  22. package/dist/server/body.d.ts.map +1 -1
  23. package/dist/server/body.js +5 -0
  24. package/dist/server/body.js.map +1 -1
  25. package/dist/server/cookies.d.ts.map +1 -1
  26. package/dist/server/cookies.js +9 -2
  27. package/dist/server/cookies.js.map +1 -1
  28. package/dist/server/core-copies.js +1 -1
  29. package/dist/server/idempotency-lane.d.ts +11 -12
  30. package/dist/server/idempotency-lane.d.ts.map +1 -1
  31. package/dist/server/idempotency-lane.js +301 -151
  32. package/dist/server/idempotency-lane.js.map +1 -1
  33. package/dist/server/methods.d.ts +3 -2
  34. package/dist/server/methods.d.ts.map +1 -1
  35. package/dist/server/methods.js.map +1 -1
  36. package/dist/server/multipart.d.ts.map +1 -1
  37. package/dist/server/multipart.js +42 -13
  38. package/dist/server/multipart.js.map +1 -1
  39. package/dist/server/node-outcome.d.ts.map +1 -1
  40. package/dist/server/node-outcome.js +2 -2
  41. package/dist/server/node-outcome.js.map +1 -1
  42. package/dist/server/query.d.ts +1 -1
  43. package/dist/server/query.d.ts.map +1 -1
  44. package/dist/server/query.js +13 -5
  45. package/dist/server/query.js.map +1 -1
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/request-context.js +3 -3
  48. package/dist/server/request-context.js.map +1 -1
  49. package/dist/server/respond.d.ts +17 -0
  50. package/dist/server/respond.d.ts.map +1 -1
  51. package/dist/server/respond.js +81 -3
  52. package/dist/server/respond.js.map +1 -1
  53. package/dist/server/runtime-core.d.ts +15 -0
  54. package/dist/server/runtime-core.d.ts.map +1 -1
  55. package/dist/server/runtime-core.js +41 -1
  56. package/dist/server/runtime-core.js.map +1 -1
  57. package/dist/server/server.d.ts +14 -5
  58. package/dist/server/server.d.ts.map +1 -1
  59. package/dist/server/server.js +179 -59
  60. package/dist/server/server.js.map +1 -1
  61. package/dist/server/transport-lane.d.ts +2 -2
  62. package/dist/server/transport-lane.d.ts.map +1 -1
  63. package/dist/server/transport-lane.js +20 -20
  64. package/dist/server/transport-lane.js.map +1 -1
  65. package/dist/static-file.d.ts +29 -6
  66. package/dist/static-file.d.ts.map +1 -1
  67. package/dist/static-file.js +43 -12
  68. package/dist/static-file.js.map +1 -1
  69. package/package.json +1 -1
  70. package/src/index.ts +1 -1
  71. package/src/internal/route-lanes.ts +18 -16
  72. package/src/internal/route-program.ts +76 -12
  73. package/src/router/router.ts +26 -5
  74. package/src/security/baseline.ts +4 -3
  75. package/src/server/body-lane.ts +4 -4
  76. package/src/server/body.ts +6 -0
  77. package/src/server/cookies.ts +7 -2
  78. package/src/server/core-copies.ts +1 -1
  79. package/src/server/idempotency-lane.ts +343 -209
  80. package/src/server/methods.ts +3 -2
  81. package/src/server/multipart.ts +62 -14
  82. package/src/server/node-outcome.ts +2 -1
  83. package/src/server/query.ts +12 -5
  84. package/src/server/request-context.ts +6 -3
  85. package/src/server/respond.ts +108 -6
  86. package/src/server/runtime-core.ts +54 -1
  87. package/src/server/server.ts +249 -99
  88. package/src/server/transport-lane.ts +28 -22
  89. package/src/static-file.ts +72 -16
@@ -71,6 +71,8 @@ const FALLBACK_FILE_NAME = "file"
71
71
  const MAX_FILE_NAME_LENGTH = 255
72
72
 
73
73
  const DASH = 45
74
+ const CR = 13
75
+ const LF = 10
74
76
  const QUOTE = 34
75
77
  const BACKSLASH = 92
76
78
  const SLASH = 47
@@ -148,41 +150,74 @@ function failureTable(pattern: Uint8Array): Uint8Array {
148
150
  return table
149
151
  }
150
152
 
151
- /** What a scan for `--boundary` found: how many, and whether the last one closes the body. */
153
+ /**
154
+ * What a scan for `--boundary` found: how many, how many of them close a part with no content, and
155
+ * whether the last one closes the body.
156
+ */
152
157
  interface Delimiters {
153
158
  readonly count: number
159
+ readonly empty: number
154
160
  readonly closed: boolean
155
161
  }
156
162
 
163
+ /** The most delimiters a scan counts: past either budget the body is refused unparsed. */
164
+ interface DelimiterLimits {
165
+ readonly filled: number
166
+ readonly empty: number
167
+ }
168
+
157
169
  /** `end` is the index after the last delimiter, or -1 when there was none. */
158
- function delimiters(bytes: Uint8Array, count: number, end: number): Delimiters {
159
- return { count, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
170
+ function delimiters(bytes: Uint8Array, count: number, empty: number, end: number): Delimiters {
171
+ return { count, empty, closed: end !== -1 && bytes[end] === DASH && bytes[end + 1] === DASH }
172
+ }
173
+
174
+ /**
175
+ * Whether the delimiter at `at` closes a part with no content: the blank line ending the part's
176
+ * headers is followed at once by the CRLF that opens the delimiter. A part whose own content ends
177
+ * in a blank line reads the same, which only spends the empty budget instead of the filled one.
178
+ */
179
+ function closesEmptyPart(bytes: Uint8Array, at: number): boolean {
180
+ return (
181
+ at >= 6 &&
182
+ bytes[at - 1] === LF &&
183
+ bytes[at - 2] === CR &&
184
+ bytes[at - 3] === LF &&
185
+ bytes[at - 4] === CR &&
186
+ bytes[at - 5] === LF &&
187
+ bytes[at - 6] === CR
188
+ )
160
189
  }
161
190
 
191
+ const overLimits = (count: number, empty: number, limits: DelimiterLimits): boolean =>
192
+ count - empty > limits.filled || empty > limits.empty
193
+
162
194
  /** Knuth-Morris-Pratt from `start`: linear in the input whatever the pattern repeats. */
163
195
  function scanFrom(
164
196
  bytes: Uint8Array,
165
197
  pattern: Uint8Array,
166
198
  start: number,
167
199
  counted: number,
200
+ countedEmpty: number,
168
201
  lastEnd: number,
169
- limit: number,
202
+ limits: DelimiterLimits,
170
203
  ): Delimiters {
171
204
  const table = failureTable(pattern)
172
205
  const size = pattern.length
173
206
  let count = counted
207
+ let empty = countedEmpty
174
208
  let end = lastEnd
175
209
  for (let i = start, k = 0; i < bytes.length; i++) {
176
210
  const byte = bytes[i]
177
211
  while (k > 0 && byte !== pattern[k]) k = table[k - 1]!
178
212
  if (byte === pattern[k]) k++
179
213
  if (k === size) {
214
+ if (count > 0 && closesEmptyPart(bytes, i + 1 - size)) empty++
180
215
  end = i + 1
181
- if (++count > limit) break
216
+ if (overLimits(++count, empty, limits)) break
182
217
  k = 0
183
218
  }
184
219
  }
185
- return delimiters(bytes, count, end)
220
+ return delimiters(bytes, count, empty, end)
186
221
  }
187
222
 
188
223
  /**
@@ -194,10 +229,15 @@ function scanFrom(
194
229
  * the verify loop re-read the same bytes (dashes against a boundary of dashes) spends a budget of
195
230
  * one pass over the input, then the scan continues with the linear matcher.
196
231
  */
197
- function scanDelimiters(bytes: Uint8Array, pattern: Uint8Array, limit: number): Delimiters {
232
+ function scanDelimiters(
233
+ bytes: Uint8Array,
234
+ pattern: Uint8Array,
235
+ limits: DelimiterLimits,
236
+ ): Delimiters {
198
237
  const size = pattern.length
199
238
  const last = bytes.length - size
200
239
  let count = 0
240
+ let empty = 0
201
241
  let end = -1
202
242
  let work = 0
203
243
  let i = 0
@@ -207,16 +247,17 @@ function scanDelimiters(bytes: Uint8Array, pattern: Uint8Array, limit: number):
207
247
  let k = 1
208
248
  while (k < size && bytes[i + k] === pattern[k]) k++
209
249
  if (k === size) {
250
+ if (count > 0 && closesEmptyPart(bytes, i)) empty++
210
251
  i += size
211
252
  end = i
212
- if (++count > limit) break
253
+ if (overLimits(++count, empty, limits)) break
213
254
  continue
214
255
  }
215
256
  work += k
216
- if (work > bytes.length) return scanFrom(bytes, pattern, i, count, end, limit)
257
+ if (work > bytes.length) return scanFrom(bytes, pattern, i, count, empty, end, limits)
217
258
  i++
218
259
  }
219
- return delimiters(bytes, count, end)
260
+ return delimiters(bytes, count, empty, end)
220
261
  }
221
262
 
222
263
  /** True when `value` takes more than `max` bytes as UTF-8. */
@@ -308,8 +349,13 @@ function createReader(
308
349
  const maxFiles = limitOf(limits.maxFiles, DEFAULT_MAX_FILES, "maxFiles")
309
350
  const maxFieldBytes = limitOf(limits.maxFieldBytes, DEFAULT_MAX_FIELD_BYTES, "maxFieldBytes")
310
351
  const maxFileBytes = limitOf(limits.maxFileBytes, UNLIMITED_BODY_BYTES, "maxFileBytes")
311
- // Every part sits between two delimiters, and the last delimiter closes the body.
312
- const maxDelimiters = maxFields + maxFiles + 1
352
+ // Every part sits between two delimiters, and the last delimiter closes the body. A part with no
353
+ // content - a file input left empty, which is dropped below - spends a budget of its own, so an
354
+ // untouched optional input does not count against the parts that carry something.
355
+ const delimiterLimits: DelimiterLimits = {
356
+ filled: maxFields + maxFiles + 1,
357
+ empty: maxFields + maxFiles,
358
+ }
313
359
 
314
360
  const read: SchemaBodyReader = async (source, contentType, maxBodyBytes, protoPoisoning) => {
315
361
  const boundary = boundaryOf(contentType)
@@ -335,8 +381,10 @@ function createReader(
335
381
  delimiter[0] = DASH
336
382
  delimiter[1] = DASH
337
383
  for (let i = 0; i < boundary.length; i++) delimiter[i + 2] = boundary.charCodeAt(i)
338
- const found = scanDelimiters(bytes, delimiter, maxDelimiters)
339
- if (found.count > maxDelimiters) return plainError(413, "too_many_parts")
384
+ const found = scanDelimiters(bytes, delimiter, delimiterLimits)
385
+ if (overLimits(found.count, found.empty, delimiterLimits)) {
386
+ return plainError(413, "too_many_parts")
387
+ }
340
388
  // A body is over when `--boundary--` says so. One that stops short is refused here, because a
341
389
  // parser that tolerates it hands back the parts it saw as if they were the whole form.
342
390
  if (!found.closed) return INVALID()
@@ -10,6 +10,7 @@ import {
10
10
  normalizeBodylessResponse,
11
11
  RESPONSE_OWNED_HEADERS,
12
12
  rememberRenderedResponse,
13
+ responseBodyUsed,
13
14
  SENT_BODY,
14
15
  taggedResponseBody,
15
16
  } from "./respond.ts"
@@ -83,7 +84,7 @@ export function toNodeOutcome(result: HandlerResult, set: CtxSet): NodeServeOutc
83
84
  )
84
85
  }
85
86
  if (result instanceof Response) {
86
- if (result.bodyUsed) return plainNodeOutcome(SENT_BODY.plain!, EMPTY_RESPONSE_CONTROLS)
87
+ if (responseBodyUsed(result)) return plainNodeOutcome(SENT_BODY.plain!, EMPTY_RESPONSE_CONTROLS)
87
88
  return nodeOutcomeFromResponse(applySetToResponse(normalizeBodylessResponse(result), set))
88
89
  }
89
90
  const status = set.status ?? (result === undefined ? 204 : 200)
@@ -98,8 +98,10 @@ export function queryObjectOf(search: string): Record<string, QueryValue> {
98
98
  const amp = search.indexOf("&", pos)
99
99
  const end = amp === -1 ? search.length : amp
100
100
  if (end > pos) {
101
- const eq = search.indexOf("=", pos)
102
- const split = eq !== -1 && eq < end ? eq : end
101
+ // Bounded by the segment, so a run of bare segments (`a&a&a...`) stays linear; keys are short,
102
+ // so the loop also beats an `indexOf` call.
103
+ let split = pos
104
+ while (split < end && search.charCodeAt(split) !== 61 /* = */) split++
103
105
  const key = search.slice(pos, split)
104
106
  const value = split === end ? "" : search.slice(split + 1, end)
105
107
  setQueryValue(out, key, value)
@@ -159,12 +161,17 @@ export function isJsonMediaType(contentType: string): boolean {
159
161
  )
160
162
  }
161
163
 
162
- /** `application/x-www-form-urlencoded`, with or without a charset suffix. */
164
+ /** `application/x-www-form-urlencoded`, with or without parameters, in any case (RFC 9110 8.3.1). */
163
165
  export function isUrlEncodedForm(contentType: string): boolean {
164
- return (
166
+ if (
165
167
  contentType === "application/x-www-form-urlencoded" ||
166
168
  contentType.startsWith("application/x-www-form-urlencoded;")
167
- )
169
+ ) {
170
+ return true
171
+ }
172
+ const semi = contentType.indexOf(";")
173
+ const essence = (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase()
174
+ return essence === "application/x-www-form-urlencoded"
168
175
  }
169
176
 
170
177
  const FORM_DECODER = new TextDecoder()
@@ -14,7 +14,7 @@ import { headerObjectOf } from "./headers.ts"
14
14
  import { plainError } from "./http.ts"
15
15
  import type { ProtoPoisoning } from "./proto-guard.ts"
16
16
  import { searchOf } from "./query.ts"
17
- import { responseJsonContentType } from "./respond.ts"
17
+ import { residentText, responseJsonContentType } from "./respond.ts"
18
18
  import {
19
19
  CONTEXT_REQUEST,
20
20
  CONTEXT_SEARCH,
@@ -378,9 +378,12 @@ export class RequestContext implements RawContext {
378
378
  }
379
379
  }
380
380
  // Default to text/plain; if the caller passes their own headers, they own the content-type.
381
- if (i?.headers !== undefined) return markResidentBody(new Response(body, i))
381
+ if (i?.headers !== undefined) return markResidentBody(residentText(new Response(body, i), body))
382
382
  return markResidentBody(
383
- new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } }),
383
+ residentText(
384
+ new Response(body, { ...i, headers: { "content-type": TEXT_CONTENT_TYPE } }),
385
+ body,
386
+ ),
384
387
  )
385
388
  }
386
389
 
@@ -314,6 +314,37 @@ export function writableResponse(response: Response, request?: Request): Respons
314
314
  return copiedResponse(response, new Response(response.body, response), true)
315
315
  }
316
316
 
317
+ // Body-bearing responses a hook handed over as a request's answer. The Node writer sends a buffered
318
+ // body without reading it, so `bodyUsed` alone does not show that one was already sent.
319
+ const HANDED_OVER = new WeakSet<Response>()
320
+
321
+ /**
322
+ * Whether `response`, handed over by a hook as this request's answer, may be sent: not when its body
323
+ * already answered a request or was handed over for one. A body-bearing response is claimed here.
324
+ */
325
+ export function claimHandedOver(response: Response): boolean {
326
+ if (responseBodyUsed(response) || HANDED_OVER.has(response)) return false
327
+ if (
328
+ hasResidentBody(response) ||
329
+ WRITABLE_BODY_RESPONSES.has(response) ||
330
+ response.body !== null
331
+ ) {
332
+ HANDED_OVER.add(response)
333
+ }
334
+ return true
335
+ }
336
+
337
+ /**
338
+ * `writableResponse` for a Response a hook hands back in place of the one it was given, with the
339
+ * single-use check a handler's result gets: a reused body is the internal error a reused handler
340
+ * result is, carrying none of its earlier request's headers.
341
+ */
342
+ export function handedBackResponse(response: Response, request: Request): Response {
343
+ return claimHandedOver(response)
344
+ ? writableResponse(response, request)
345
+ : toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, undefined, request)
346
+ }
347
+
317
348
  /** `copy`, recorded and carrying `original`'s buffered-body marker: the Node writer still sends the
318
349
  * serialized bytes directly, and a marked body-less Response keeps the body only the marker holds. */
319
350
  function copiedResponse(original: Response, copy: Response, hasBody: boolean): Response {
@@ -385,6 +416,63 @@ export function taggedResponseOwner(response: Response): object | undefined {
385
416
  : undefined
386
417
  }
387
418
 
419
+ // The text each c.text() response was built from; null once adopted by the body tier. A hook can
420
+ // substitute its body without consuming the original stream, but that source is still single-use.
421
+ const RESIDENT_TEXTS = new WeakMap<Response, string | null>()
422
+ let residentTextsRecorded = false
423
+
424
+ /** From now on, keep the string of each `c.text()` response for the body tier. */
425
+ export function recordResidentTexts(): void {
426
+ residentTextsRecorded = true
427
+ }
428
+
429
+ /** `response`, which `c.text()` built from `body`. */
430
+ export function residentText(response: Response, body: string): Response {
431
+ if (residentTextsRecorded) RESIDENT_TEXTS.set(response, body)
432
+ return response
433
+ }
434
+
435
+ /** Whether the actual body, or its body-tier substitute, has already been handed to a request. */
436
+ export function responseBodyUsed(response: Response): boolean {
437
+ return response.bodyUsed || RESIDENT_TEXTS.get(response) === null
438
+ }
439
+
440
+ /** `response` tagged with the text it was built from, so this app's body-tier hooks get the string. */
441
+ function withResidentText(
442
+ response: Response,
443
+ source: Response,
444
+ owner: object | undefined,
445
+ ): Response {
446
+ if (owner === undefined) return response
447
+ const text = RESIDENT_TEXTS.get(source)
448
+ if (text === undefined || text === null) return response
449
+ RESIDENT_TEXTS.set(source, null)
450
+ return taggedResponseBody(response) !== undefined
451
+ ? response
452
+ : markTaggedResponse(response, text, owner)
453
+ }
454
+
455
+ /** A buffered page built from the body it reports, tagged for this app's body-tier hooks. */
456
+ function bufferedPageResponse(
457
+ page: {
458
+ readonly status: number
459
+ readonly headers: Readonly<Record<string, string | readonly string[]>> | undefined
460
+ readonly body: string
461
+ },
462
+ owner: object,
463
+ ): Response {
464
+ const headers = new Headers()
465
+ for (const [name, value] of Object.entries(page.headers ?? {})) {
466
+ if (typeof value === "string") headers.set(name, value)
467
+ else for (const item of value) headers.append(name, item)
468
+ }
469
+ return markTaggedResponse(
470
+ withBody(new Response(page.body, { status: page.status, headers })),
471
+ page.body,
472
+ owner,
473
+ )
474
+ }
475
+
388
476
  export function markTaggedResponse(
389
477
  response: Response,
390
478
  body: string | Uint8Array,
@@ -546,20 +634,34 @@ export function toResponse(
546
634
  request,
547
635
  )
548
636
  }
637
+ // A buffered page is built from the body it reports, so the bytes its hooks see are the ones sent.
638
+ const owner = typeof tagResponseBody === "object" ? tagResponseBody : undefined
639
+ const page = owner === undefined ? undefined : result.toNodeBody?.()
640
+ const response =
641
+ owner !== undefined &&
642
+ page !== undefined &&
643
+ typeof page.body === "string" &&
644
+ !isBodylessStatus(page.status)
645
+ ? bufferedPageResponse({ ...page, body: page.body }, owner)
646
+ : result.toResponse()
549
647
  return withStatics(
550
- applySetToResponse(normalizeBodylessResponse(result.toResponse(), request), set, request),
648
+ applySetToResponse(normalizeBodylessResponse(response, request), set, request),
551
649
  statics,
552
650
  request,
553
651
  )
554
652
  }
555
653
  if (result instanceof Response) {
556
- if (result.bodyUsed) {
654
+ if (responseBodyUsed(result)) {
557
655
  return toResponse(SENT_BODY, EMPTY_RESPONSE_CONTROLS, false, statics, request)
558
656
  }
559
- return withStatics(
560
- applySetToResponse(normalizeBodylessResponse(result, request), set, request),
561
- statics,
562
- request,
657
+ return withResidentText(
658
+ withStatics(
659
+ applySetToResponse(normalizeBodylessResponse(result, request), set, request),
660
+ statics,
661
+ request,
662
+ ),
663
+ result,
664
+ typeof tagResponseBody === "object" ? tagResponseBody : undefined,
563
665
  )
564
666
  }
565
667
  return renderValue(result, set, tagResponseBody, statics, request)
@@ -31,6 +31,16 @@ export interface PreDecodedBody {
31
31
  readonly value: unknown
32
32
  /** The encoded size, so the route's own body limit still applies to a body decoded before routing. */
33
33
  readonly byteLength: number
34
+ /** The bytes as sent and their media type: the stand-in body is not the payload, so an
35
+ * idempotency fingerprint must be taken over these. */
36
+ readonly encoded: Uint8Array
37
+ readonly contentType: string
38
+ }
39
+
40
+ /** The stash a transport codec left on a request, if one decoded its body. */
41
+ export function preDecodedBody(request: object): PreDecodedBody | undefined {
42
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only the codec lane and the idempotency prepare step define this key, each with a PreDecodedBody.
43
+ return (request as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
34
44
  }
35
45
 
36
46
  /**
@@ -181,12 +191,55 @@ export function requestOf(source: RequestSource): Request {
181
191
  return source.request ?? (source as unknown as Request)
182
192
  }
183
193
 
194
+ // On Deno, reading `headers` builds the request's whole header list: ~4% of a bare route's
195
+ // throughput for one header. A `Deno.serve` request's inner record (Deno's own "request" slot)
196
+ // reads one header off the parsed request: the value `headers.get` would return, except that a name
197
+ // no header can carry reads as absent instead of throwing, as on the Node source. It is used only
198
+ // while the `Headers` are unbuilt (Deno's "headers cache" slot is empty), so the two never disagree;
199
+ // on a closed request both throw Deno's "Request closed". A request Deno did not parse - constructed,
200
+ // cloned, rebuilt - reads `headers`, as before.
201
+ const denoSlots = "Deno" in globalThis ? denoRequestSlots(new Request("http://x/")) : undefined
202
+
203
+ /** @internal Deno's own slots on a `Request`, found by the names Deno gives them. */
204
+ export interface DenoSlots {
205
+ readonly inner: symbol
206
+ readonly headers: symbol
207
+ }
208
+
209
+ /** @internal */
210
+ export function denoRequestSlots(request: object): DenoSlots | undefined {
211
+ let inner: symbol | undefined
212
+ let headers: symbol | undefined
213
+ for (const key of Object.getOwnPropertySymbols(request)) {
214
+ if (key.description === "request") inner = key
215
+ else if (key.description === "headers cache") headers = key
216
+ }
217
+ return inner === undefined || headers === undefined ? undefined : { inner, headers }
218
+ }
219
+
220
+ /** @internal The header as received, or `undefined` when only `headers` can answer. */
221
+ export function denoWireHeader(
222
+ request: object,
223
+ slots: DenoSlots,
224
+ name: string,
225
+ ): string | null | undefined {
226
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: only the two symbol slots are read, and each value is checked before it is used.
227
+ const slotted = request as Record<symbol, { readonly header?: unknown } | undefined>
228
+ if (slotted[slots.headers] !== undefined) return undefined
229
+ const inner = slotted[slots.inner]
230
+ if (typeof inner?.header !== "function") return undefined
231
+ const value: unknown = inner.header(name)
232
+ return value === null || typeof value === "string" ? value : undefined
233
+ }
234
+
184
235
  /** Read one request header. `header()` answers authoritatively when the source implements it - its
185
236
  * `null` means ABSENT, not "ask `headers` instead". Falling through on `null` would materialize the
186
237
  * lazy sources' full `Headers` object on every absent-header probe (measured at ~4% of request CPU
187
238
  * on the Node POST lane, which checks `transfer-encoding` on every request). */
188
239
  export function headerOf(source: RequestSource, name: string): string | null {
189
- return source.header !== undefined ? source.header(name) : source.headers.get(name)
240
+ if (source.header !== undefined) return source.header(name)
241
+ const wire = denoSlots === undefined ? undefined : denoWireHeader(source, denoSlots, name)
242
+ return wire !== undefined ? wire : source.headers.get(name)
190
243
  }
191
244
 
192
245
  /** Off-edge `waitUntil`: run the background work fire-and-forget, never leaking an unhandled