@nifrajs/core 5.7.0 → 5.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 (223) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
package/src/evidence.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { AssuranceEvidence, AssuranceFinding, AssuranceReport } from "./assurance.ts"
10
+ import { canonicalJson } from "./canonical-json.ts"
10
11
  import type {
11
12
  AssuredCapabilityRoute,
12
13
  CapabilityAssuranceReport,
@@ -15,7 +16,9 @@ import type {
15
16
  } from "./capabilities.ts"
16
17
  import type { ResponseClassification } from "./classification.ts"
17
18
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
19
+ import { sha256Hex } from "./internal/hex.ts"
18
20
  import { evidenceProvenance } from "./internal/route-assurance.ts"
21
+ import { mapSchemaParts } from "./internal/schema-parts.ts"
19
22
  import {
20
23
  type JsonSchema,
21
24
  type ReflectedMount,
@@ -134,11 +137,6 @@ export interface ProjectEvidenceCompositionPart {
134
137
  readonly pathPrefix?: string
135
138
  }
136
139
 
137
- const recordOf = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
138
- value !== null && typeof value === "object" && !Array.isArray(value)
139
- ? (value as Readonly<Record<string, unknown>>)
140
- : undefined
141
-
142
140
  function schemaPart(value: SchemaReflection | undefined): ProjectEvidenceSchemaPart | undefined {
143
141
  if (value === undefined) return undefined
144
142
  return Object.freeze({
@@ -154,12 +152,6 @@ function schemaPart(value: SchemaReflection | undefined): ProjectEvidenceSchemaP
154
152
  function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
155
153
  const source = route.schema
156
154
  if (source === undefined) return undefined
157
- const headers = schemaPart(source.headers)
158
- const cookies = schemaPart(source.cookies)
159
- const body = schemaPart(source.body)
160
- const query = schemaPart(source.query)
161
- const params = schemaPart(source.params)
162
- const response = schemaPart(source.response)
163
155
  const sse = schemaPart(source.sse)
164
156
  const errors: Record<string, ProjectEvidenceSchemaPart> = {}
165
157
  for (const [status, value] of Object.entries(source.errors ?? {})) {
@@ -170,12 +162,7 @@ function schemaOf(route: ReflectedRoute): ProjectEvidenceSchema | undefined {
170
162
  ...(source.validationOrder !== undefined ? { validationOrder: source.validationOrder } : {}),
171
163
  ...(source.bodyLimit !== undefined ? { bodyLimit: source.bodyLimit } : {}),
172
164
  ...(source.bodyLimitReason !== undefined ? { bodyLimitReason: source.bodyLimitReason } : {}),
173
- ...(headers !== undefined ? { headers } : {}),
174
- ...(cookies !== undefined ? { cookies } : {}),
175
- ...(body !== undefined ? { body } : {}),
176
- ...(query !== undefined ? { query } : {}),
177
- ...(params !== undefined ? { params } : {}),
178
- ...(response !== undefined ? { response } : {}),
165
+ ...mapSchemaParts(source, schemaPart),
179
166
  ...(Object.keys(errors).length > 0 ? { errors: Object.freeze(errors) } : {}),
180
167
  ...(sse !== undefined ? { sse } : {}),
181
168
  ...(source.validationErrorHook === true ? { validationErrorHook: true as const } : {}),
@@ -221,6 +208,20 @@ const sortByRoute = <T extends { readonly method: string; readonly path: string
221
208
  [...values].sort((a, b) => codeUnitOrder(a.path, b.path) || codeUnitOrder(a.method, b.method)),
222
209
  )
223
210
 
211
+ const sortByFinding = <
212
+ T extends { readonly method: string; readonly path: string; readonly code: string },
213
+ >(
214
+ values: readonly T[],
215
+ ): readonly T[] =>
216
+ Object.freeze(
217
+ [...values].sort(
218
+ (a, b) =>
219
+ codeUnitOrder(a.path, b.path) ||
220
+ codeUnitOrder(a.method, b.method) ||
221
+ codeUnitOrder(a.code, b.code),
222
+ ),
223
+ )
224
+
224
225
  function evidenceRouteOf(
225
226
  route: ReflectedRoute,
226
227
  locations: readonly ProjectEvidenceSourceLocation[] | undefined,
@@ -260,14 +261,7 @@ function assuranceOf(report: AssuranceReport | undefined): ProjectEvidenceAssura
260
261
  }),
261
262
  ),
262
263
  ),
263
- findings: Object.freeze(
264
- [...report.findings].sort(
265
- (a, b) =>
266
- codeUnitOrder(a.path, b.path) ||
267
- codeUnitOrder(a.method, b.method) ||
268
- codeUnitOrder(a.code, b.code),
269
- ),
270
- ),
264
+ findings: sortByFinding(report.findings),
271
265
  })
272
266
  }
273
267
 
@@ -290,14 +284,7 @@ function capabilitiesOf(
290
284
  }),
291
285
  ),
292
286
  ),
293
- findings: Object.freeze(
294
- [...report.findings].sort(
295
- (a, b) =>
296
- codeUnitOrder(a.path, b.path) ||
297
- codeUnitOrder(a.method, b.method) ||
298
- codeUnitOrder(a.code, b.code),
299
- ),
300
- ),
287
+ findings: sortByFinding(report.findings),
301
288
  })
302
289
  }
303
290
 
@@ -443,16 +430,7 @@ export function composeProjectEvidence(
443
430
  : Object.freeze({
444
431
  ok: assuranceReports.every((report) => report.ok),
445
432
  routes: sortByRoute(assuranceReports.flatMap((report) => report.routes)),
446
- findings: Object.freeze(
447
- assuranceReports
448
- .flatMap((report) => report.findings)
449
- .sort(
450
- (a, b) =>
451
- codeUnitOrder(a.path, b.path) ||
452
- codeUnitOrder(a.method, b.method) ||
453
- codeUnitOrder(a.code, b.code),
454
- ),
455
- ),
433
+ findings: sortByFinding(assuranceReports.flatMap((report) => report.findings)),
456
434
  })
457
435
  const capabilities =
458
436
  capabilityReports.length === 0
@@ -460,16 +438,7 @@ export function composeProjectEvidence(
460
438
  : Object.freeze({
461
439
  ok: capabilityReports.every((report) => report.ok),
462
440
  routes: sortByRoute(capabilityReports.flatMap((report) => report.routes)),
463
- findings: Object.freeze(
464
- capabilityReports
465
- .flatMap((report) => report.findings)
466
- .sort(
467
- (a, b) =>
468
- codeUnitOrder(a.path, b.path) ||
469
- codeUnitOrder(a.method, b.method) ||
470
- codeUnitOrder(a.code, b.code),
471
- ),
472
- ),
441
+ findings: sortByFinding(capabilityReports.flatMap((report) => report.findings)),
473
442
  })
474
443
  if (assurance !== undefined) validateComposedReportRoutes(assurance, routeKeys, "assurance")
475
444
  if (capabilities !== undefined)
@@ -506,23 +475,12 @@ function schemaFromEvidence(schema: ProjectEvidenceSchema | undefined): Reflecte
506
475
  schemaPartToReflection(value) as SchemaReflection,
507
476
  ]),
508
477
  )
509
- const headers = schemaPartToReflection(schema.headers)
510
- const cookies = schemaPartToReflection(schema.cookies)
511
- const body = schemaPartToReflection(schema.body)
512
- const query = schemaPartToReflection(schema.query)
513
- const params = schemaPartToReflection(schema.params)
514
- const response = schemaPartToReflection(schema.response)
515
478
  const sse = schemaPartToReflection(schema.sse)
516
479
  return {
517
480
  ...(schema.validationOrder !== undefined ? { validationOrder: schema.validationOrder } : {}),
518
481
  ...(schema.bodyLimit !== undefined ? { bodyLimit: schema.bodyLimit } : {}),
519
482
  ...(schema.bodyLimitReason !== undefined ? { bodyLimitReason: schema.bodyLimitReason } : {}),
520
- ...(headers !== undefined ? { headers } : {}),
521
- ...(cookies !== undefined ? { cookies } : {}),
522
- ...(body !== undefined ? { body } : {}),
523
- ...(query !== undefined ? { query } : {}),
524
- ...(params !== undefined ? { params } : {}),
525
- ...(response !== undefined ? { response } : {}),
483
+ ...mapSchemaParts(schema, schemaPartToReflection),
526
484
  ...(Object.keys(errors).length > 0 ? { errors } : {}),
527
485
  ...(sse !== undefined ? { sse } : {}),
528
486
  ...(schema.validationErrorHook === true ? { validationErrorHook: true as const } : {}),
@@ -592,35 +550,12 @@ const mountsPart = (
592
550
  ),
593
551
  }
594
552
 
595
- function canonicalValue(value: unknown): string {
596
- if (value === null || typeof value === "string" || typeof value === "boolean") {
597
- return JSON.stringify(value)
598
- }
599
- if (typeof value === "number") {
600
- if (!Number.isFinite(value))
601
- throw new TypeError("project evidence cannot encode non-finite numbers")
602
- return JSON.stringify(value)
603
- }
604
- if (Array.isArray(value)) return `[${value.map((item) => canonicalValue(item)).join(",")}]`
605
- const record = recordOf(value)
606
- if (record !== undefined) {
607
- return `{${Object.keys(record)
608
- .filter((key) => record[key] !== undefined)
609
- .sort()
610
- .map((key) => `${JSON.stringify(key)}:${canonicalValue(record[key])}`)
611
- .join(",")}}`
612
- }
613
- throw new TypeError(`project evidence cannot encode ${typeof value}`)
614
- }
615
-
616
553
  /** Stable JSON for logs, MCP, generated artifacts, and snapshot tests. */
617
554
  export function serializeProjectEvidence(snapshot: ProjectEvidenceSnapshot): string {
618
- return canonicalValue(snapshot)
555
+ return canonicalJson(snapshot)
619
556
  }
620
557
 
621
558
  /** SHA-256 of the canonical snapshot, useful as a cheap freshness/reference token. */
622
559
  export async function digestProjectEvidence(snapshot: ProjectEvidenceSnapshot): Promise<string> {
623
- const bytes = new TextEncoder().encode(serializeProjectEvidence(snapshot))
624
- const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))
625
- return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")
560
+ return sha256Hex(serializeProjectEvidence(snapshot))
626
561
  }
@@ -10,6 +10,9 @@
10
10
  * unit-tested without a server. A durable store (cross-restart) implements the same interface.
11
11
  */
12
12
 
13
+ import { canonicalJson } from "./canonical-json.ts"
14
+ import { bytesToHex } from "./internal/hex.ts"
15
+
13
16
  /** Whether a route's idempotency is satisfied by an in-process store or a durable (cross-restart) one. */
14
17
  export type IdempotencyScope = "request" | "durable"
15
18
 
@@ -172,32 +175,64 @@ export async function computeIdempotencyFingerprint(
172
175
  const material = new Uint8Array(head.length + body.length)
173
176
  material.set(head, 0)
174
177
  material.set(body, head.length)
175
- const digest = await crypto.subtle.digest("SHA-256", material)
176
- const bytes = new Uint8Array(digest)
177
- let hex = ""
178
- for (let i = 0; i < bytes.length; i++) hex += (bytes[i] as number).toString(16).padStart(2, "0")
179
- return hex
178
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", material)))
180
179
  }
181
180
 
182
- function canonicalJson(value: unknown): string {
183
- if (value === null || typeof value === "boolean" || typeof value === "string") {
184
- return JSON.stringify(value)
181
+ const isDigit = (code: number): boolean => code >= 48 && code <= 57
182
+ const isDigitOrDot = (code: number): boolean => isDigit(code) || code === 46
183
+
184
+ /**
185
+ * Whether `text` may hold a JSON number no double holds exactly: one written with 16+ digits (a dot
186
+ * counted) or with a negative exponent of 3+ digits. Any other number round-trips exactly, or
187
+ * overflows to Infinity, which canonical JSON refuses. A match inside a string only costs the slower
188
+ * parse.
189
+ */
190
+ function mayHoldLossyNumber(text: string): boolean {
191
+ // A run of 16 digits or dots covers an index that is 15 mod 16, so only those indexes are probed.
192
+ for (let i = 15; i < text.length; i += 16) {
193
+ if (!isDigitOrDot(text.charCodeAt(i))) continue
194
+ let start = i
195
+ while (isDigitOrDot(text.charCodeAt(start - 1))) start--
196
+ let end = i + 1
197
+ while (isDigitOrDot(text.charCodeAt(end))) end++
198
+ if (end - start >= 16) return true
185
199
  }
186
- if (typeof value === "number") {
187
- if (!Number.isFinite(value)) throw new TypeError("non-finite JSON number")
188
- return JSON.stringify(value)
200
+ // A negative exponent follows "e-" or "E-"; "-" is the rarer character to search for.
201
+ for (let i = text.indexOf("-"); i !== -1; i = text.indexOf("-", i + 1)) {
202
+ if ((text.charCodeAt(i - 1) | 32) !== 101) continue
203
+ let digit = i + 1
204
+ while (text.charCodeAt(digit) === 48) digit++
205
+ if (
206
+ isDigit(text.charCodeAt(digit)) &&
207
+ isDigit(text.charCodeAt(digit + 1)) &&
208
+ isDigit(text.charCodeAt(digit + 2))
209
+ ) {
210
+ return true
211
+ }
189
212
  }
190
- if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(",")}]`
191
- if (typeof value === "object") {
192
- const record = value as Record<string, unknown>
193
- const entries = Object.keys(record)
194
- .sort()
195
- .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
196
- return `{${entries.join(",")}}`
213
+ return false
214
+ }
215
+
216
+ /** A `JSON.parse` reviver that keeps a lossy number's own text and reads every other value as usual. */
217
+ function keepLossyNumberText(
218
+ _key: string,
219
+ value: unknown,
220
+ context?: { readonly source?: string },
221
+ ): unknown {
222
+ if (typeof value !== "number") return value
223
+ const source = context?.source
224
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: the member is optional, so an engine without JSON.rawJSON leaves it undefined.
225
+ const rawJson = (JSON as JSON & { readonly rawJSON?: (text: string) => unknown }).rawJSON
226
+ // Unable to keep the text, the caller fingerprints the exact bytes rather than a rounded number.
227
+ if (source === undefined || rawJson === undefined) {
228
+ throw new TypeError("no JSON number source text")
197
229
  }
198
- throw new TypeError("unsupported JSON value")
230
+ return mayHoldLossyNumber(source) ? rawJson(source) : value
199
231
  }
200
232
 
233
+ // Not fatal: Deno's fatal decode costs several times a replacing one; U+FFFD is checked for instead.
234
+ const utf8 = new TextDecoder()
235
+
201
236
  /** Canonicalize JSON bodies so whitespace/property-order retries bind to the same semantic request. */
202
237
  export function canonicalizeIdempotencyBody(
203
238
  body: Uint8Array,
@@ -206,7 +241,14 @@ export function canonicalizeIdempotencyBody(
206
241
  const mediaType = contentType?.split(";", 1)[0]?.trim().toLowerCase() ?? ""
207
242
  if (mediaType !== "application/json" && !mediaType.endsWith("+json")) return body
208
243
  try {
209
- return encoder.encode(canonicalJson(JSON.parse(new TextDecoder().decode(body))))
244
+ const text = utf8.decode(body)
245
+ // Every invalid byte decodes to U+FFFD, so two different bodies could read the same: such a
246
+ // body, or one that writes U+FFFD itself, keeps its exact bytes.
247
+ if (text.includes("\uFFFD")) return body
248
+ const parsed: unknown = mayHoldLossyNumber(text)
249
+ ? JSON.parse(text, keepLossyNumberText)
250
+ : JSON.parse(text)
251
+ return encoder.encode(canonicalJson(parsed))
210
252
  } catch {
211
253
  // Invalid JSON is rejected by the validation/body lane. Preserve its raw bytes for a stable
212
254
  // fingerprint until that rejection releases the reservation.
package/src/index.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * read its own package.json at runtime. `scripts/version.ts` rewrites it on every release bump and
17
17
  * `check:publish` asserts it equals `@nifrajs/core`'s package version.
18
18
  */
19
- export const VERSION = "5.7.0" as const
19
+ export const VERSION = "5.9.0" as const
20
20
 
21
21
  export type Version = typeof VERSION
22
22
 
@@ -85,7 +85,13 @@ export {
85
85
  type ScheduledHandler,
86
86
  toFetchHandler,
87
87
  } from "./server/edge.ts"
88
- export { isSameOriginPath, pathnameOf, type UrlParts, urlPartsOf } from "./server/http.ts"
88
+ export {
89
+ isSameOriginPath,
90
+ pathnameOf,
91
+ type UrlParts,
92
+ urlPartsOf,
93
+ varyOn,
94
+ } from "./server/http.ts"
89
95
  export {
90
96
  commonSecretPatterns,
91
97
  jsonLogger,
@@ -119,6 +125,11 @@ export type {
119
125
  RouteInfoFor,
120
126
  } from "./server/registry.ts"
121
127
  export { replacedRequestOf } from "./server/request-lineage.ts"
128
+ export {
129
+ carryRequestLocals,
130
+ type RequestLocal,
131
+ requestLocal,
132
+ } from "./server/request-local.ts"
122
133
  export { writableResponse } from "./server/respond.ts"
123
134
  export { routeTemplateOf } from "./server/route-template.ts"
124
135
  export {
@@ -0,0 +1,8 @@
1
+ /** Whether `value` holds a C0 control character or DEL. */
2
+ export function hasControlCharacter(value: string): boolean {
3
+ for (let index = 0; index < value.length; index++) {
4
+ const code = value.charCodeAt(index)
5
+ if (code <= 31 || code === 127) return true
6
+ }
7
+ return false
8
+ }
@@ -40,7 +40,10 @@ export function addDuration(timestamp: number, duration: number, label: string):
40
40
  export function bytesToBase64Url(bytes: Uint8Array): string {
41
41
  let binary = ""
42
42
  for (const byte of bytes) binary += String.fromCharCode(byte)
43
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "")
43
+ return btoa(binary)
44
+ .replaceAll("+", "-")
45
+ .replaceAll("/", "_")
46
+ .replace(/={1,2}$/u, "")
44
47
  }
45
48
 
46
49
  export async function sha256(value: string): Promise<string> {
@@ -0,0 +1,14 @@
1
+ const encoder = new TextEncoder()
2
+
3
+ /** Lowercase hex, two digits a byte. */
4
+ export function bytesToHex(bytes: Uint8Array): string {
5
+ let hex = ""
6
+ // Indexed: a for-of over the bytes runs 1.4x slower on Bun.
7
+ for (let i = 0; i < bytes.length; i++) hex += (bytes[i] ?? 0).toString(16).padStart(2, "0")
8
+ return hex
9
+ }
10
+
11
+ /** SHA-256 of `text`'s UTF-8 bytes, as lowercase hex. */
12
+ export async function sha256Hex(text: string): Promise<string> {
13
+ return bytesToHex(new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(text))))
14
+ }
@@ -0,0 +1,4 @@
1
+ /** A non-null, non-array object. The prototype is not checked: a `Date` or a `Map` passes. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value)
4
+ }
@@ -17,6 +17,7 @@ import { assertByteLimit } from "../server/body.ts"
17
17
  import type { RouteSchema } from "../server/context.ts"
18
18
  import type { IdempotencyRuntime, ResolvedIdempotency } from "../server/idempotency-lane.ts"
19
19
  import type { EffectLedgerRuntime, ResolvedEffectLedger } from "../server/ledger-lane.ts"
20
+ import { decorationsAssignable } from "../server/request-context.ts"
20
21
  import type {
21
22
  ResponseContractDefinition,
22
23
  ResponseContractRuntime,
@@ -136,7 +137,6 @@ export function adoptRouteRuntimes(
136
137
  hasIdempotency: entry.idempotent !== undefined,
137
138
  hasLedger: ledgered !== undefined,
138
139
  hasResponseContract: responseContract !== undefined,
139
- hasDecorations: entry.hasDecorations,
140
140
  derives: entry.derives.length,
141
141
  beforeHandle: entry.beforeHandle.length,
142
142
  afterHandle: entry.afterHandle.length,
@@ -297,7 +297,7 @@ export function compileRouteOptions(
297
297
  hasIdempotency: idempotent !== undefined,
298
298
  hasLedger: ledgered !== undefined,
299
299
  hasResponseContract: contracted !== undefined,
300
- hasDecorations,
300
+ decorationsAssignable: !hasDecorations || decorationsAssignable(routeDecorations),
301
301
  derives: context.derives.length,
302
302
  beforeHandle: context.beforeHandleHooks.length,
303
303
  afterHandle: context.afterHandleHooks.length,
@@ -140,15 +140,7 @@ export function compileRouteExecutionPlan(options: {
140
140
  readonly hasLedger: boolean
141
141
  readonly fusedWeb: FusedWebRunner | undefined
142
142
  readonly fusedBody: FusedBodyRunner | undefined
143
- readonly fusedLane:
144
- | "bare"
145
- | "body"
146
- | "query"
147
- | "derive-before"
148
- | "derive-before-after"
149
- | "body-derive-before"
150
- | "body-derive-before-after"
151
- | undefined
143
+ readonly fusedLane: "bare" | "body" | "query" | "hooks" | "body-hooks" | undefined
152
144
  }): RouteExecutionPlan {
153
145
  const { lane, contextless, hasAround, hasLedger, fusedWeb, fusedBody, fusedLane } = options
154
146
 
@@ -254,6 +246,9 @@ export function compileRouteExecutionPlan(options: {
254
246
  maxBodyBytes,
255
247
  server.protoPoisoning,
256
248
  )
249
+ // `c.json` writes by the route's contract, if it has one, so the reply and its check share one walk.
250
+ // Only then: one more own property moves a context that holds a body out of its inline slots.
251
+ if (entry.responseContract !== undefined) ctx.jsonContract = entry.responseContract
257
252
  let ledger: RequestLedger | undefined
258
253
  // The runtime is always present when a route resolved a ledger (enforced at registration).
259
254
  const ledgerRuntime = server.effectLedgerRuntime
@@ -286,15 +281,7 @@ export interface RouteExecutionPlan {
286
281
  /** Which builder produced {@link fusedWeb} - a merge rebinds the closure to the executing server
287
282
  * and must rebuild it with the SAME semantics (a query-fused route rebuilt as bare would skip its
288
283
  * validation). `undefined` iff `fusedWeb` is. */
289
- readonly fusedLane:
290
- | "bare"
291
- | "body"
292
- | "query"
293
- | "derive-before"
294
- | "derive-before-after"
295
- | "body-derive-before"
296
- | "body-derive-before-after"
297
- | undefined
284
+ readonly fusedLane: "bare" | "body" | "query" | "hooks" | "body-hooks" | undefined
298
285
  }
299
286
 
300
287
  export interface RouteEntry {
@@ -334,8 +321,8 @@ export interface RouteEntry {
334
321
  readonly beforeHandle: ReadonlyArray<RawBeforeHandle>
335
322
  readonly afterHandle: ReadonlyArray<RawAfterHandle>
336
323
  readonly onError: ReadonlyArray<RawErrorHandler>
337
- /** Registration-specialized hook shape for the common derive + before middleware route. */
338
- readonly lifecycleHookLane: "derive-before" | "derive-before-after" | undefined
324
+ /** Registration-specialized hook shape: a route whose only lifecycle steps are derive, before and after hooks. */
325
+ readonly lifecycleHookLane: "hooks" | undefined
339
326
  /** Wraps the matched route lifecycle. Empty for the common no-around path. */
340
327
  readonly around: ReadonlyArray<RawAround>
341
328
  /** The single immutable execution decision consumed by portable, Node-direct, and Bun-native paths. */
@@ -17,16 +17,8 @@ export interface RouteLaneSelection {
17
17
  readonly fusedBody: boolean
18
18
  readonly lane: RouteExecutionLane
19
19
  readonly lifecycleLane: LifecycleExecutionLane
20
- readonly lifecycleHookLane: "derive-before" | "derive-before-after" | undefined
21
- readonly fusedLane:
22
- | "bare"
23
- | "body"
24
- | "query"
25
- | "derive-before"
26
- | "derive-before-after"
27
- | "body-derive-before"
28
- | "body-derive-before-after"
29
- | undefined
20
+ readonly lifecycleHookLane: "hooks" | undefined
21
+ readonly fusedLane: "bare" | "body" | "query" | "hooks" | "body-hooks" | undefined
30
22
  }
31
23
 
32
24
  export function selectRouteLanes(options: {
@@ -35,7 +27,8 @@ export function selectRouteLanes(options: {
35
27
  readonly hasIdempotency: boolean
36
28
  readonly hasLedger: boolean
37
29
  readonly hasResponseContract: boolean
38
- readonly hasDecorations: boolean
30
+ /** False when a decoration cannot be merged into the context, which keeps the fused hook lanes off. */
31
+ readonly decorationsAssignable?: boolean
39
32
  readonly derives: number
40
33
  readonly beforeHandle: number
41
34
  readonly afterHandle: number
@@ -48,7 +41,7 @@ export function selectRouteLanes(options: {
48
41
  hasIdempotency,
49
42
  hasLedger,
50
43
  hasResponseContract,
51
- hasDecorations,
44
+ decorationsAssignable = true,
52
45
  derives,
53
46
  beforeHandle,
54
47
  afterHandle,
@@ -153,39 +146,31 @@ export function selectRouteLanes(options: {
153
146
  ? "query"
154
147
  : "hooks"
155
148
 
156
- // The realistic middleware shape is commonly exactly one synchronous-or-async derive followed by
157
- // one before hook. Keep the generic runner for every route that can observe decorations, after hooks,
158
- // error hooks, or response contracts; this lane only removes the two per-request hook-loop dispatches
159
- // and preserves the same async continuations and error handling. A params, headers, or cookies schema
160
- // routes back to the generic lane because the fused builder has no validation step for them
161
- // baked in - adding them would be more code than it saves. A `body` schema is the same story
162
- // because the fused derive-before / derive-before-after builders don't parse the body: routes with a
163
- // body schema are routed to the `body-derive-before` / `body-derive-before-after` lane below.
149
+ // A route whose only lifecycle steps are derive, before and after hooks (any number of each, at least
150
+ // one) runs them in one fused closure chain, with the route's decorations and error hooks. Anything
151
+ // that can observe around hooks, a response contract or a validation-error recovery keeps the
152
+ // generic runner. A params, headers or cookies schema
153
+ // routes back to the generic lane because the fused builder has no validation step for them, and a
154
+ // `body` schema is the `body-hooks` lane below because this one does not parse the body.
155
+ const hookCount = derives + beforeHandle + afterHandle
164
156
  const lifecycleHookLane =
165
157
  lane === "lifecycle" &&
166
158
  !authBeforeValidation &&
167
159
  !hasResponseContract &&
168
- !hasDecorations &&
160
+ decorationsAssignable &&
169
161
  !hasIdempotency &&
170
162
  !hasLedger &&
171
163
  schema?.onValidationError === undefined &&
172
164
  !defaultOnValidationError &&
173
165
  plain &&
174
166
  schema?.body === undefined &&
175
- derives === 1 &&
176
- beforeHandle === 1 &&
177
- afterHandle <= 1 &&
178
- onError === 0 &&
167
+ hookCount > 0 &&
179
168
  around === 0
180
- ? afterHandle === 0
181
- ? "derive-before"
182
- : "derive-before-after"
169
+ ? "hooks"
183
170
  : undefined
184
171
 
185
- // Body + lifecycle hooks in a closed shape: a body schema plus the same 1-derive + 1-before +
186
- // 0-or-1-after + 0-onError shape, with no other body/query/path conflict. The fused closure runs
187
- // parse → validate → derive → before → handler → after → respond in one function frame, the same
188
- // win `fusedBody` already gives for the no-hooks body shape.
172
+ // The same hooks around a body schema: the fused closure runs parse, validate, derive, before,
173
+ // handler and after in one function frame, the win `fusedBody` already gives the no-hooks shape.
189
174
  const fusedBodyLifecycle =
190
175
  lane === "lifecycle" &&
191
176
  !authBeforeValidation &&
@@ -195,13 +180,10 @@ export function selectRouteLanes(options: {
195
180
  schema?.onValidationError === undefined &&
196
181
  !defaultOnValidationError &&
197
182
  !hasResponseContract &&
198
- !hasDecorations &&
183
+ decorationsAssignable &&
199
184
  !hasIdempotency &&
200
185
  !hasLedger &&
201
- derives === 1 &&
202
- beforeHandle === 1 &&
203
- afterHandle <= 1 &&
204
- onError === 0 &&
186
+ hookCount > 0 &&
205
187
  around === 0
206
188
 
207
189
  return {
@@ -220,13 +202,7 @@ export function selectRouteLanes(options: {
220
202
  ? "body"
221
203
  : bare
222
204
  ? "bare"
223
- : lifecycleHookLane !== undefined
224
- ? (lifecycleHookLane as "derive-before" | "derive-before-after")
225
- : fusedBodyLifecycle
226
- ? afterHandle === 0
227
- ? "body-derive-before"
228
- : "body-derive-before-after"
229
- : undefined
205
+ : (lifecycleHookLane ?? (fusedBodyLifecycle ? "body-hooks" : undefined))
230
206
  : undefined,
231
207
  }
232
208
  }
@@ -0,0 +1,19 @@
1
+ const SCHEMA_PARTS = ["headers", "cookies", "body", "query", "params", "response"] as const
2
+
3
+ type SchemaPartKey = (typeof SCHEMA_PARTS)[number]
4
+
5
+ /**
6
+ * Each request and response schema part of `source` that `map` turns into a value, under its own key,
7
+ * in the order route reflections, evidence and snapshots all write them.
8
+ */
9
+ export function mapSchemaParts<In, Out>(
10
+ source: { readonly [K in SchemaPartKey]?: In },
11
+ map: (part: In | undefined) => Out | undefined,
12
+ ): { [K in SchemaPartKey]?: Out } {
13
+ const parts: { [K in SchemaPartKey]?: Out } = {}
14
+ for (const key of SCHEMA_PARTS) {
15
+ const part = map(source[key])
16
+ if (part !== undefined) parts[key] = part
17
+ }
18
+ return parts
19
+ }
package/src/ledger.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { validCapabilityId } from "./internal/capability-runtime.ts"
18
+ import { bytesToHex, sha256Hex } from "./internal/hex.ts"
18
19
 
19
20
  /** Lifecycle phase of one effect. `intent` precedes execution; the rest describe its outcome. */
20
21
  export type EffectPhase = "intent" | "committed" | "failed" | "compensated"
@@ -341,17 +342,6 @@ function canonicalEntry(entry: EffectEntry): string {
341
342
  )
342
343
  }
343
344
 
344
- const encoder = new TextEncoder()
345
-
346
- async function sha256Hex(value: string): Promise<string> {
347
- const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
348
- let hex = ""
349
- for (let index = 0; index < digest.length; index++) {
350
- hex += (digest[index] as number).toString(16).padStart(2, "0")
351
- }
352
- return hex
353
- }
354
-
355
345
  export interface MemoryLedgerSinkOptions {
356
346
  /** Retain at most this many sealed ledgers (oldest evicted first). Default 1000. */
357
347
  readonly maxLedgers?: number
@@ -440,11 +430,7 @@ export async function computeEffectDigest(
440
430
  const mac = new Uint8Array(
441
431
  await crypto.subtle.sign("HMAC", cryptoKey, payload as unknown as ArrayBuffer),
442
432
  )
443
- let hex = ""
444
- for (let index = 0; index < mac.length; index++) {
445
- hex += (mac[index] as number).toString(16).padStart(2, "0")
446
- }
447
- return hex
433
+ return bytesToHex(mac)
448
434
  }
449
435
 
450
436
  const EFFECT_LEDGER = Symbol("nifra.effect.ledger")