@nifrajs/core 5.3.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 (93) hide show
  1. package/CHANGELOG.md +65 -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/range.d.ts.map +1 -1
  12. package/dist/range.js +62 -2
  13. package/dist/range.js.map +1 -1
  14. package/dist/router/router.d.ts +3 -2
  15. package/dist/router/router.d.ts.map +1 -1
  16. package/dist/router/router.js +22 -6
  17. package/dist/router/router.js.map +1 -1
  18. package/dist/security/baseline.d.ts.map +1 -1
  19. package/dist/security/baseline.js +4 -3
  20. package/dist/security/baseline.js.map +1 -1
  21. package/dist/server/body-lane.d.ts.map +1 -1
  22. package/dist/server/body-lane.js +4 -3
  23. package/dist/server/body-lane.js.map +1 -1
  24. package/dist/server/body.d.ts +2 -0
  25. package/dist/server/body.d.ts.map +1 -1
  26. package/dist/server/body.js +5 -0
  27. package/dist/server/body.js.map +1 -1
  28. package/dist/server/cookies.d.ts.map +1 -1
  29. package/dist/server/cookies.js +9 -2
  30. package/dist/server/cookies.js.map +1 -1
  31. package/dist/server/core-copies.js +1 -1
  32. package/dist/server/idempotency-lane.d.ts +11 -12
  33. package/dist/server/idempotency-lane.d.ts.map +1 -1
  34. package/dist/server/idempotency-lane.js +301 -151
  35. package/dist/server/idempotency-lane.js.map +1 -1
  36. package/dist/server/methods.d.ts +3 -2
  37. package/dist/server/methods.d.ts.map +1 -1
  38. package/dist/server/methods.js.map +1 -1
  39. package/dist/server/multipart.d.ts.map +1 -1
  40. package/dist/server/multipart.js +42 -13
  41. package/dist/server/multipart.js.map +1 -1
  42. package/dist/server/node-outcome.d.ts.map +1 -1
  43. package/dist/server/node-outcome.js +2 -2
  44. package/dist/server/node-outcome.js.map +1 -1
  45. package/dist/server/query.d.ts +1 -1
  46. package/dist/server/query.d.ts.map +1 -1
  47. package/dist/server/query.js +13 -5
  48. package/dist/server/query.js.map +1 -1
  49. package/dist/server/request-context.d.ts.map +1 -1
  50. package/dist/server/request-context.js +3 -3
  51. package/dist/server/request-context.js.map +1 -1
  52. package/dist/server/respond.d.ts +17 -0
  53. package/dist/server/respond.d.ts.map +1 -1
  54. package/dist/server/respond.js +81 -3
  55. package/dist/server/respond.js.map +1 -1
  56. package/dist/server/runtime-core.d.ts +15 -0
  57. package/dist/server/runtime-core.d.ts.map +1 -1
  58. package/dist/server/runtime-core.js +41 -1
  59. package/dist/server/runtime-core.js.map +1 -1
  60. package/dist/server/server.d.ts +14 -5
  61. package/dist/server/server.d.ts.map +1 -1
  62. package/dist/server/server.js +187 -62
  63. package/dist/server/server.js.map +1 -1
  64. package/dist/server/transport-lane.d.ts +2 -2
  65. package/dist/server/transport-lane.d.ts.map +1 -1
  66. package/dist/server/transport-lane.js +20 -20
  67. package/dist/server/transport-lane.js.map +1 -1
  68. package/dist/static-file.d.ts +127 -0
  69. package/dist/static-file.d.ts.map +1 -0
  70. package/dist/static-file.js +269 -0
  71. package/dist/static-file.js.map +1 -0
  72. package/package.json +6 -1
  73. package/src/index.ts +1 -1
  74. package/src/internal/route-lanes.ts +18 -16
  75. package/src/internal/route-program.ts +76 -12
  76. package/src/range.ts +61 -2
  77. package/src/router/router.ts +26 -5
  78. package/src/security/baseline.ts +4 -3
  79. package/src/server/body-lane.ts +4 -4
  80. package/src/server/body.ts +6 -0
  81. package/src/server/cookies.ts +7 -2
  82. package/src/server/core-copies.ts +1 -1
  83. package/src/server/idempotency-lane.ts +343 -209
  84. package/src/server/methods.ts +3 -2
  85. package/src/server/multipart.ts +62 -14
  86. package/src/server/node-outcome.ts +2 -1
  87. package/src/server/query.ts +12 -5
  88. package/src/server/request-context.ts +6 -3
  89. package/src/server/respond.ts +108 -6
  90. package/src/server/runtime-core.ts +54 -1
  91. package/src/server/server.ts +257 -102
  92. package/src/server/transport-lane.ts +28 -22
  93. package/src/static-file.ts +362 -0
@@ -158,6 +158,36 @@ interface RouteProgramRuntime {
158
158
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
159
159
  wrapResponse: (response: Response | ResponseResult) => T,
160
160
  ): MaybePromise<T>
161
+ /** Installed by `idempotency()`, and present whenever a route resolved idempotency. */
162
+ readonly idempotencyRuntime: {
163
+ gate<T>(
164
+ entry: RouteEntry,
165
+ ctx: RawContext,
166
+ wrapResponse: (response: Response | ResponseResult) => T,
167
+ resume: IdempotencyResume<T>,
168
+ fail: (error: unknown) => MaybePromise<T>,
169
+ ): Promise<T>
170
+ }
171
+ }
172
+
173
+ /**
174
+ * The rest of a program after the idempotency gate. The gate continues it with the request it
175
+ * buffered, and with a replay entry and program when the key already holds a response.
176
+ */
177
+ export type IdempotencyResume<T> = (entry: RouteEntry, program: RouteProgram) => ProgramResult<T>
178
+
179
+ /** Idempotency infrastructure failures use the same boundary as the resumed route program. */
180
+ function runIdempotencyGate<T>(
181
+ host: RouteProgramRuntime,
182
+ entry: RouteEntry,
183
+ ctx: RawContext,
184
+ finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
185
+ wrapResponse: (response: Response | ResponseResult) => T,
186
+ resume: IdempotencyResume<T>,
187
+ ): Promise<T> {
188
+ return host.idempotencyRuntime.gate(entry, ctx, wrapResponse, resume, (error) =>
189
+ host.handleProgramError(entry, error, ctx, finalize, wrapResponse),
190
+ )
161
191
  }
162
192
 
163
193
  function responseSet(ctx: RawContext): CtxSet {
@@ -278,6 +308,22 @@ function runAuthenticationStages<T>(
278
308
  validationComplete = false,
279
309
  ): ProgramResult<T> {
280
310
  if (start >= program.authStages.length) {
311
+ // Auth-first: the idempotency gate buffers the body, so it waits for every stage to pass. It
312
+ // resumes on an entry without the gate, so the program does not meet it twice.
313
+ if (entry.idempotent !== undefined && !validationComplete) {
314
+ return runIdempotencyGate(host, entry, ctx, finalize, wrapResponse, (e, p) =>
315
+ runAuthenticationStages(
316
+ host,
317
+ e,
318
+ p,
319
+ source,
320
+ ctx,
321
+ finalize,
322
+ wrapResponse,
323
+ p.authStages.length,
324
+ ),
325
+ )
326
+ }
281
327
  if (program.authFastLane) {
282
328
  const validationIndex = validationComplete ? program.validationStages.length : 0
283
329
  return runFastAuthenticatedProgram(
@@ -578,22 +624,33 @@ async function runAuthBeforeValidation<T>(
578
624
  ctx: RawContext,
579
625
  finalize: (result: unknown, set: CtxSet, ctx?: RawContext) => T,
580
626
  wrapResponse: (response: Response | ResponseResult) => T,
627
+ gated = false,
581
628
  ): Promise<T> {
582
629
  try {
583
- if (program.hasDecorations) Object.assign(ctx, program.decorations)
630
+ if (!gated) {
631
+ if (program.hasDecorations) Object.assign(ctx, program.decorations)
584
632
 
585
- for (const derive of program.derives) {
586
- const result = derive(ctx)
587
- const settled = result instanceof Promise ? await result : result
588
- if (isEarly(settled)) return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
589
- Object.assign(ctx, settled)
590
- }
633
+ for (const derive of program.derives) {
634
+ const result = derive(ctx)
635
+ const settled = result instanceof Promise ? await result : result
636
+ if (isEarly(settled)) return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
637
+ Object.assign(ctx, settled)
638
+ }
591
639
 
592
- for (const before of program.beforeHandle) {
593
- const result = before(ctx)
594
- const settled = result instanceof Promise ? await result : result
595
- if (settled !== undefined)
596
- return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
640
+ for (const before of program.beforeHandle) {
641
+ const result = before(ctx)
642
+ const settled = result instanceof Promise ? await result : result
643
+ if (settled !== undefined)
644
+ return finishEarly(host, entry, ctx, settled, finalize, wrapResponse)
645
+ }
646
+
647
+ // The policy hooks have passed, so the idempotency gate may buffer the body now. Not awaited:
648
+ // the gate reports its own failures, and one the resumed program let escape must skip this catch.
649
+ if (entry.idempotent !== undefined) {
650
+ return runIdempotencyGate(host, entry, ctx, finalize, wrapResponse, (e, p) =>
651
+ runAuthBeforeValidation(host, e, p, source, ctx, finalize, wrapResponse, true),
652
+ )
653
+ }
597
654
  }
598
655
 
599
656
  for (const stage of program.validationStages) {
@@ -634,6 +691,13 @@ export function executeRouteProgram<T, R extends Registry, Ctx>(
634
691
  wrapResponse: (response: Response | ResponseResult) => T,
635
692
  ): ProgramResult<T> {
636
693
  const host = runtime as unknown as RouteProgramRuntime
694
+ // An auth-first program meets the idempotency gate once authentication has passed, so the gate
695
+ // never buffers a body for a caller it would refuse. Every other program meets it first.
696
+ if (entry.idempotent !== undefined && !program.authBeforeValidation) {
697
+ return runIdempotencyGate(host, entry, ctx, finalize, wrapResponse, (e, p) =>
698
+ executeRouteProgram(runtime, e, p, source, ctx, finalize, wrapResponse),
699
+ )
700
+ }
637
701
  try {
638
702
  if (program.authStages.length > 0) {
639
703
  if (program.hasDecorations) Object.assign(ctx, program.decorations)
package/src/range.ts CHANGED
@@ -32,6 +32,63 @@ function validSize(size: number): void {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * `bytes=<digits>-<digits>`, the one form clients send, answered with a single scan; `undefined`
37
+ * for anything else, which the general path below answers. The answers must stay the general path's:
38
+ * test/range.test.ts compares both against a frozen copy of it.
39
+ */
40
+ function parseSingleRange(value: string, size: number): ByteRangeResult | undefined {
41
+ const length = value.length
42
+ // `| 0x20` folds only ASCII case, which is all the general path's /bytes/i matches.
43
+ if (
44
+ length < 7 ||
45
+ value.charCodeAt(5) !== 0x3d ||
46
+ (value.charCodeAt(0) | 0x20) !== 0x62 ||
47
+ (value.charCodeAt(1) | 0x20) !== 0x79 ||
48
+ (value.charCodeAt(2) | 0x20) !== 0x74 ||
49
+ (value.charCodeAt(3) | 0x20) !== 0x65 ||
50
+ (value.charCodeAt(4) | 0x20) !== 0x73
51
+ ) {
52
+ return undefined
53
+ }
54
+ let dash = -1
55
+ for (let index = 6; index < length; index++) {
56
+ const code = value.charCodeAt(index)
57
+ if (code === 0x2d) {
58
+ if (dash !== -1) return undefined
59
+ dash = index
60
+ } else if (code < 0x30 || code > 0x39) {
61
+ return undefined
62
+ }
63
+ }
64
+ if (dash === -1) return undefined
65
+ const leftDigits = dash - 6
66
+ const rightDigits = length - dash - 1
67
+ // INTEGER: no leading zero.
68
+ if (
69
+ (leftDigits > 1 && value.charCodeAt(6) === 0x30) ||
70
+ (rightDigits > 1 && value.charCodeAt(dash + 1) === 0x30)
71
+ ) {
72
+ return { kind: "none" }
73
+ }
74
+ let start: number
75
+ let end: number
76
+ if (leftDigits === 0) {
77
+ // A suffix with no length, or a zero one, is ignored.
78
+ if (rightDigits === 0 || (rightDigits === 1 && value.charCodeAt(dash + 1) === 0x30)) {
79
+ return { kind: "none" }
80
+ }
81
+ const suffix = Number(value.slice(dash + 1))
82
+ start = suffix >= size ? 0 : size - suffix
83
+ end = size - 1
84
+ } else {
85
+ start = Number(value.slice(6, dash))
86
+ end = rightDigits === 0 ? size - 1 : Math.min(Number(value.slice(dash + 1)), size - 1)
87
+ }
88
+ if (start > end || start >= size || size === 0) return { kind: "unsatisfiable" }
89
+ return { kind: "satisfiable", ranges: [{ start, end }] }
90
+ }
91
+
35
92
  /**
36
93
  * Parse an HTTP `Range: bytes=...` header against a known representation size.
37
94
  *
@@ -45,12 +102,14 @@ export function parseByteRange(value: string | null, size: number): ByteRangeRes
45
102
  // Bound the input before the regex runs, not after: an oversized header must never reach the
46
103
  // matcher in the first place.
47
104
  if (value.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
105
+ const single = parseSingleRange(value, size)
106
+ if (single !== undefined) return single
48
107
  const match = /^bytes\s*=\s*(.+)$/i.exec(value)
49
108
  if (match === null) return { kind: "none" }
50
109
  const source = match[1]!
51
110
  let separators = 0
52
- for (const character of source) {
53
- if (character === "," && ++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
111
+ for (let comma = source.indexOf(","); comma !== -1; comma = source.indexOf(",", comma + 1)) {
112
+ if (++separators >= MAX_RANGE_SPECIFIERS) return { kind: "none" }
54
113
  }
55
114
 
56
115
  const ranges: ByteRange[] = []
@@ -10,8 +10,18 @@ import {
10
10
  mixedSegmentShape,
11
11
  } from "./pattern.ts"
12
12
 
13
- /** The standard HTTP methods: the ones with a builder on the server and a call on the typed client. */
14
- export const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const
13
+ /** The standard HTTP methods: the ones the typed registry and the typed client know. Each has a server
14
+ * builder except `QUERY` (RFC 9110's safe, idempotent read with a body), which `method()` registers. */
15
+ export const METHODS = [
16
+ "GET",
17
+ "POST",
18
+ "PUT",
19
+ "PATCH",
20
+ "DELETE",
21
+ "HEAD",
22
+ "OPTIONS",
23
+ "QUERY",
24
+ ] as const
15
25
 
16
26
  export type Method = (typeof METHODS)[number]
17
27
 
@@ -37,6 +47,8 @@ export function isRegistrableMethod(method: string): boolean {
37
47
  const SLASH = 47
38
48
  export const EMPTY_PARAMS: Record<string, string> = Object.freeze({})
39
49
  const DYNAMIC_MATCH_CACHE_MAX = 2048
50
+ // The client picks every key, so their length is bounded too: 2048 paths of 8 KiB held 16 MiB.
51
+ const DYNAMIC_MATCH_CACHE_CHARS = 256 * 1024
40
52
 
41
53
  /**
42
54
  * Result of {@link Router.find}. The `found: false` cases deliberately separate
@@ -67,6 +79,8 @@ interface DynamicMatchCacheEntry<T> {
67
79
 
68
80
  interface DynamicMatchCacheState<T> {
69
81
  cache: Map<string, DynamicMatchCacheEntry<T>> | undefined
82
+ /** Total length of the cached paths. */
83
+ chars: number
70
84
  pendingPath: string | undefined
71
85
  }
72
86
 
@@ -79,6 +93,7 @@ function dynamicMatchCacheStateFor<T>(owner: object): DynamicMatchCacheState<T>
79
93
  }
80
94
  const state: DynamicMatchCacheState<T> = {
81
95
  cache: undefined,
96
+ chars: 0,
82
97
  pendingPath: undefined,
83
98
  }
84
99
  DYNAMIC_MATCH_CACHES.set(owner, state as DynamicMatchCacheState<unknown>)
@@ -476,13 +491,19 @@ export class Router<T> {
476
491
  cache = new Map<string, DynamicMatchCacheEntry<T>>()
477
492
  state.cache = cache
478
493
  }
479
- if (cache.size >= DYNAMIC_MATCH_CACHE_MAX) {
480
- const oldest = cache.keys().next().value
481
- if (oldest !== undefined) cache.delete(oldest)
494
+ for (const oldest of cache.keys()) {
495
+ if (
496
+ cache.size < DYNAMIC_MATCH_CACHE_MAX &&
497
+ state.chars + path.length <= DYNAMIC_MATCH_CACHE_CHARS
498
+ )
499
+ break
500
+ cache.delete(oldest)
501
+ state.chars -= oldest.length
482
502
  }
483
503
  cache.set(path, {
484
504
  terminal,
485
505
  values: values.slice(0, terminal.paramNames.length),
486
506
  })
507
+ state.chars += path.length
487
508
  }
488
509
  }
@@ -41,9 +41,10 @@ export interface SecurityBaselineOptions {
41
41
  readonly requireRuntimeProvenance?: boolean
42
42
  }
43
43
 
44
- /** A mutation is every method these leave out: POST, PUT, PATCH, DELETE and any custom method. */
45
- const NOT_MUTATING: readonly (Method | "WS")[] = ["GET", "HEAD", "OPTIONS", "WS"]
46
- const READING = ["GET", "HEAD"] as const
44
+ /** A mutation is every method these leave out: POST, PUT, PATCH, DELETE and any custom method. QUERY
45
+ * reads, with a body that the generic body rule still bounds. */
46
+ const NOT_MUTATING: readonly (Method | "WS")[] = ["GET", "HEAD", "OPTIONS", "QUERY", "WS"]
47
+ const READING = ["GET", "HEAD", "QUERY"] as const
47
48
  /** The classification threshold above which the baseline demands authentication. */
48
49
  const SENSITIVE = "pii" as const
49
50
 
@@ -11,8 +11,7 @@ import { isJsonMediaType, isUrlEncodedForm, readBoundedForm } from "./query.ts"
11
11
  import {
12
12
  headerOf,
13
13
  isResponseResult,
14
- PRE_DECODED_BODY,
15
- type PreDecodedBody,
14
+ preDecodedBody,
16
15
  type ResponseResult,
17
16
  TEXT_DECODER,
18
17
  } from "./runtime-core.ts"
@@ -52,7 +51,7 @@ export function readBoundedJsonSource<T>(
52
51
  onResult?: JsonResultContinuation<T>,
53
52
  onError?: JsonErrorContinuation<T>,
54
53
  ): Promise<unknown | ResponseResult | T> {
55
- const preDecoded = (req as { [PRE_DECODED_BODY]?: PreDecodedBody })[PRE_DECODED_BODY]
54
+ const preDecoded = preDecodedBody(req)
56
55
  if (preDecoded !== undefined) {
57
56
  const value =
58
57
  preDecoded.byteLength > maxBytes ? plainError(413, "payload_too_large") : preDecoded.value
@@ -237,7 +236,8 @@ export function readBodyFramed<T>(
237
236
  formOrigins?: FormOrigins,
238
237
  ): Promise<T> {
239
238
  const contentType = headerOf(source, "content-type") ?? ""
240
- if (!isJsonMediaType(contentType)) {
239
+ // A body a transport codec decoded keeps its media type; the JSON lane below takes its value.
240
+ if (!isJsonMediaType(contentType) && preDecodedBody(source) === undefined) {
241
241
  // JSON needs a preflight, so CORS judges it; a form body reaches here from any site with cookies.
242
242
  if (isFormMediaType(contentType) && !formOriginAllowed(source, formOrigins)) {
243
243
  return Promise.resolve(wrapResponse(plainError(403, "forbidden_origin")))
@@ -462,6 +462,12 @@ export function markTransportCap(source: object, maxBytes: number): void {
462
462
  ;(source as { [TRANSPORT_CAP]?: number })[TRANSPORT_CAP] = maxBytes
463
463
  }
464
464
 
465
+ /** The cap {@link markTransportCap} set on `source`, for a copy that must keep it. */
466
+ export function transportCapOf(source: object): number | undefined {
467
+ const cap: unknown = Reflect.get(source, TRANSPORT_CAP)
468
+ return typeof cap === "number" ? cap : undefined
469
+ }
470
+
465
471
  /**
466
472
  * Enforce a marked source's transport byte cap on **direct user reads** of its request
467
473
  * (`c.req.json()`, `c.req.arrayBuffer()`, the raw `body` stream, `clone()`, ...), without swapping
@@ -78,6 +78,9 @@ export function parseCookies(header: string | null | undefined): Record<string,
78
78
  if (!header) return out
79
79
  const len = header.length
80
80
  let pos = 0
81
+ // The first "=" at or after the segment, kept while it lies in a later one, so a run of bare
82
+ // segments does not rescan the rest of the header each time. -1: none left.
83
+ let eq = header.indexOf("=")
81
84
  while (pos < len) {
82
85
  let end = header.indexOf(";", pos)
83
86
  if (end === -1) end = len
@@ -86,7 +89,7 @@ export function parseCookies(header: string | null | undefined): Record<string,
86
89
  while (start < end && header.charCodeAt(start) === 32) start++
87
90
  let stop = end
88
91
  while (stop > start && header.charCodeAt(stop - 1) === 32) stop--
89
- const eq = header.indexOf("=", start)
92
+ if (eq !== -1 && eq < start) eq = header.indexOf("=", start)
90
93
  if (eq > start && eq < stop) {
91
94
  // name: trim trailing spaces before '='; value: trim leading spaces after it
92
95
  let nameEnd = eq
@@ -125,6 +128,8 @@ export function hasDuplicateCookie(header: string | null | undefined, targetName
125
128
  let found = false
126
129
  const len = header.length
127
130
  let pos = 0
131
+ // Kept across segments as in parseCookies.
132
+ let eq = header.indexOf("=")
128
133
  while (pos < len) {
129
134
  let end = header.indexOf(";", pos)
130
135
  if (end === -1) end = len
@@ -132,7 +137,7 @@ export function hasDuplicateCookie(header: string | null | undefined, targetName
132
137
  while (start < end && header.charCodeAt(start) === 32) start++
133
138
  let stop = end
134
139
  while (stop > start && header.charCodeAt(stop - 1) === 32) stop--
135
- const eq = header.indexOf("=", start)
140
+ if (eq !== -1 && eq < start) eq = header.indexOf("=", start)
136
141
  if (eq > start && eq < stop) {
137
142
  let nameEnd = eq
138
143
  while (nameEnd > start && header.charCodeAt(nameEnd - 1) === 32) nameEnd--
@@ -23,7 +23,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
23
23
 
24
24
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
25
25
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
26
- const COPY_VERSION = "5.3.0"
26
+ const COPY_VERSION = "5.5.0"
27
27
 
28
28
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
29
29
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */