@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
@@ -38,8 +38,10 @@
38
38
  * object. So a value's hooks are applied first, as the serializer would, and that result is validated
39
39
  * and sent: a class whose `toJSON` adds a private field fails a closed schema instead of passing on its
40
40
  * public ones. `strip` keeps the declared fields of what the hook writes, so a hook that writes `id`
41
- * from private state keeps it. A `Date` is the exception; it stays a `Date` for a date schema and is
42
- * written as its ISO string.
41
+ * from private state keeps it. A `Date` is judged as the ISO string its own hook writes too, so a
42
+ * `t.string({ format: "date-time" })` or `t.date()` field holding a database timestamp passes: a
43
+ * schema that carries its JSON Schema is checked as JSON carries it. A schema without one (zod,
44
+ * valibot) cannot say which place expects a string, so it is shown every `Date` as one.
43
45
  *
44
46
  * ## What enforcement costs
45
47
  *
@@ -53,12 +55,15 @@
53
55
  * or lifecycle hook has already left that lane, so for the routes that look like production the
54
56
  * contract is effectively free - declare it.
55
57
  */
58
+
59
+ import { isRecord } from "../internal/record.ts"
56
60
  import {
57
61
  formatStandardIssues,
58
62
  type StandardResult,
59
63
  type StandardSchemaV1,
60
64
  } from "../schema/standard.ts"
61
65
  import { INSTALL_RESPONSE_CONTRACT } from "./install.ts"
66
+ import { DATE_TO_JSON, define, readsShippedTime } from "./json-shared.ts"
62
67
  import { jsonText } from "./json-text.ts"
63
68
  import type { IdentityPlugin } from "./plugin.ts"
64
69
  import { isResponseResult, status } from "./runtime-core.ts"
@@ -141,18 +146,14 @@ export type ResponseContractOutcome =
141
146
  /** `enforce` only: the payload cannot be reconciled with the contract, so it must not be sent. */
142
147
  | { readonly kind: "violation"; readonly message: string }
143
148
 
144
- const isPlainObject = (v: unknown): v is Record<string, unknown> =>
145
- typeof v === "object" && v !== null && !Array.isArray(v)
146
-
147
149
  /** Rebuilds a value with only what its schema declares; absent when the schema can strip nothing. */
148
150
  type Projector = (value: unknown) => unknown
149
151
 
150
152
  const projectors = new WeakMap<object, Projector | null>()
151
153
 
152
- /** The projector for a schema's JSON Schema, compiled once per schema. */
153
- function projectorOf(schema: StandardSchemaV1): Projector | undefined {
154
- const node: unknown = Reflect.get(schema, "jsonSchema")
155
- if (typeof node !== "object" || node === null) return undefined
154
+ /** The projector for a schema's JSON Schema, compiled once per schema. It writes each `Date` as it
155
+ * descends, as every schema that carries its JSON Schema is judged. */
156
+ function projectorOf(node: object): Projector | undefined {
156
157
  let found = projectors.get(node)
157
158
  if (found === undefined) {
158
159
  found = compileProjector(node) ?? null
@@ -161,6 +162,12 @@ function projectorOf(schema: StandardSchemaV1): Projector | undefined {
161
162
  return found ?? undefined
162
163
  }
163
164
 
165
+ /** The JSON Schema a schema carries as `jsonSchema` (every `@nifrajs/schema` `t` schema), if any. */
166
+ function jsonSchemaOf(schema: StandardSchemaV1): object | undefined {
167
+ const node: unknown = Reflect.get(schema, "jsonSchema")
168
+ return typeof node === "object" && node !== null ? node : undefined
169
+ }
170
+
164
171
  const objectField = (node: object, key: string): object | undefined => {
165
172
  const value: unknown = Reflect.get(node, key)
166
173
  return typeof value === "object" && value !== null ? value : undefined
@@ -180,7 +187,7 @@ function compileProjector(node: unknown): Projector | undefined {
180
187
  : (value) => {
181
188
  if (!Array.isArray(value)) return value
182
189
  const out: unknown[] = []
183
- for (let i = 0; i < value.length; i++) out.push(item(writtenAt(value[i], i)))
190
+ for (let i = 0; i < value.length; i++) out.push(item(writtenAt(value[i], i, true)))
184
191
  return out
185
192
  }
186
193
  }
@@ -207,12 +214,16 @@ function compileProjector(node: unknown): Projector | undefined {
207
214
  return undefined
208
215
  const declared = new Set(keys)
209
216
  return (value) => {
210
- if (!isPlainObject(value)) return value
217
+ if (!isRecord(value)) return value
211
218
  const out: Record<string, unknown> = closed ? {} : { ...value }
212
219
  for (const key of closed ? keys : nested.keys()) {
213
220
  if (!hasField(value, key)) continue
214
221
  const projector = nested.get(key)
215
- define(out, key, projector === undefined ? value[key] : projector(writtenAt(value[key], key)))
222
+ define(
223
+ out,
224
+ key,
225
+ projector === undefined ? value[key] : projector(writtenAt(value[key], key, true)),
226
+ )
216
227
  }
217
228
  if (patterns.length > 0) projectPatternKeys(value, out, declared, patterns, closed)
218
229
  return out
@@ -243,7 +254,7 @@ function compileUnion(branches: readonly unknown[]): Projector | undefined {
243
254
  if (forArray === undefined && forObject === undefined) return undefined
244
255
  return (value) => {
245
256
  if (Array.isArray(value)) return forArray === undefined ? value : forArray(value)
246
- return forObject !== undefined && isPlainObject(value) ? forObject(value) : value
257
+ return forObject !== undefined && isRecord(value) ? forObject(value) : value
247
258
  }
248
259
  }
249
260
 
@@ -290,7 +301,7 @@ function compileDiscriminated(branches: readonly object[]): Projector | undefine
290
301
  })
291
302
  if (!disjoint) continue
292
303
  return (value) => {
293
- if (!isPlainObject(value) || !hasField(value, key)) return value
304
+ if (!isRecord(value) || !hasField(value, key)) return value
294
305
  const projector = byTag.get(value[key])
295
306
  return projector === undefined ? value : projector(value)
296
307
  }
@@ -357,33 +368,31 @@ function projectPatternKeys(
357
368
  }
358
369
  if (matches === 0) continue
359
370
  if (matches === 1 && projector !== undefined)
360
- define(out, key, projector(writtenAt(value[key], key)))
371
+ define(out, key, projector(writtenAt(value[key], key, true)))
361
372
  else if (closed) define(out, key, value[key])
362
373
  }
363
374
  }
364
375
 
365
376
  /** `__proto__` is a declared name like any other here; plain assignment would set the prototype. */
366
- function define(out: Record<string, unknown>, key: string, value: unknown): void {
367
- if (key === "__proto__")
368
- Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
369
- else out[key] = value
370
- }
371
-
372
- const DATE_TO_JSON = Date.prototype.toJSON
373
-
374
377
  // Deeper than this, the copying walk takes over with its own cycle check.
375
378
  const MAX_HOOK_SCAN_DEPTH = 64
376
379
 
377
- /** Whether `value` holds a `toJSON` hook other than a `Date`'s. Allocates nothing: most payloads
378
- * have none, and this is the price every contracted response pays. */
379
- function hasHook(value: object, depth: number): boolean {
380
+ const isDate = (value: object, hook: unknown): value is Date =>
381
+ hook === DATE_TO_JSON && value instanceof Date
382
+
383
+ /**
384
+ * Whether `value` holds a `toJSON` hook, a `Date`'s only when `dates` writes them. Stops at the first
385
+ * and allocates nothing: most payloads have none, and this is the price every contracted response
386
+ * pays.
387
+ */
388
+ function hasHook(value: object, depth: number, dates: boolean): boolean {
380
389
  if (depth > MAX_HOOK_SCAN_DEPTH) return true
381
390
  const hook: unknown = Reflect.get(value, "toJSON")
382
- if (typeof hook === "function") return !(hook === DATE_TO_JSON && value instanceof Date)
391
+ if (typeof hook === "function") return dates || !isDate(value, hook)
383
392
  if (Array.isArray(value)) {
384
393
  for (let i = 0; i < value.length; i++) {
385
394
  const item: unknown = value[i]
386
- if (typeof item === "object" && item !== null && hasHook(item, depth + 1)) return true
395
+ if (typeof item === "object" && item !== null && hasHook(item, depth + 1, dates)) return true
387
396
  }
388
397
  return false
389
398
  }
@@ -391,7 +400,7 @@ function hasHook(value: object, depth: number): boolean {
391
400
  for (const key in value) {
392
401
  // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: `key` was enumerated from `value`; `Reflect.get` here halves the speed of this for-in.
393
402
  const field: unknown = (value as Record<string, unknown>)[key]
394
- if (typeof field === "object" && field !== null && hasHook(field, depth + 1)) return true
403
+ if (typeof field === "object" && field !== null && hasHook(field, depth + 1, dates)) return true
395
404
  }
396
405
  return false
397
406
  }
@@ -399,22 +408,26 @@ function hasHook(value: object, depth: number): boolean {
399
408
  /**
400
409
  * What `JSON.stringify` writes for `value`: every `toJSON` hook applied as the serializer applies it,
401
410
  * so the contract judges the bytes that go out rather than fields a hook then replaces. Copies only the
402
- * containers above a hook. A `Date` keeps its object, since its own hook writes nothing but the ISO
403
- * string a date schema declares.
411
+ * containers above a hook. A `Date` keeps its object unless `dates`.
404
412
  */
405
- const wireValueOf = (value: unknown): unknown =>
406
- typeof value === "object" && value !== null && hasHook(value, 0)
407
- ? wireValue(value, "", [])
413
+ const wireValueOf = (value: unknown, dates: boolean): unknown =>
414
+ typeof value === "object" && value !== null && hasHook(value, 0, dates)
415
+ ? wireValue(value, "", [], dates)
408
416
  : value
409
417
 
410
- const wireValue = (value: unknown, key: string | number, path: object[]): unknown =>
411
- wireChildren(writtenAt(value, key), path)
418
+ const wireValue = (value: unknown, key: string | number, path: object[], dates: boolean): unknown =>
419
+ wireChildren(writtenAt(value, key, dates), path, dates)
412
420
 
413
421
  /** `value` as the serializer takes it under `key`: its own hook applied, its fields still as they are. */
414
- function writtenAt(value: unknown, key: string | number): unknown {
422
+ function writtenAt(value: unknown, key: string | number, dates: boolean): unknown {
415
423
  if (typeof value !== "object" || value === null) return value
416
424
  const hook: unknown = Reflect.get(value, "toJSON")
417
- if (typeof hook !== "function" || (hook === DATE_TO_JSON && value instanceof Date)) return value
425
+ if (typeof hook !== "function") return value
426
+ if (isDate(value, hook)) {
427
+ if (!dates) return value
428
+ if (readsShippedTime(value))
429
+ return Number.isFinite(value.valueOf()) ? value.toISOString() : null
430
+ }
418
431
  return fieldsOf(Reflect.apply(hook, value, [String(key)]))
419
432
  }
420
433
 
@@ -432,33 +445,36 @@ function fieldsOf(value: unknown): unknown {
432
445
  return fields
433
446
  }
434
447
 
435
- function wireChildren(value: unknown, path: object[]): unknown {
448
+ /** `value` with each of its fields written; a container is copied once, when a first field changes. */
449
+ function wireChildren(value: unknown, path: object[], dates: boolean): unknown {
436
450
  // A cycle is left as it is for `JSON.stringify` to refuse.
437
451
  if (typeof value !== "object" || value === null || path.includes(value)) return value
438
452
  path.push(value)
453
+ // A primitive is written as it is, so only an object field is walked.
439
454
  if (Array.isArray(value)) {
440
455
  let items: unknown[] | undefined
441
456
  for (let i = 0; i < value.length; i++) {
442
457
  const item: unknown = value[i]
443
- const written = wireValue(item, i, path)
444
- if (written !== item) items ??= value.slice(0, i)
445
- if (items !== undefined) items[i] = written
458
+ if (typeof item !== "object" || item === null) continue
459
+ const written = wireValue(item, i, path, dates)
460
+ if (written === item) continue
461
+ items ??= value.slice()
462
+ items[i] = written
446
463
  }
447
464
  path.pop()
448
465
  return items ?? value
449
466
  }
450
- const keys = Object.keys(value)
467
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: every key read below comes from `Object.keys(value)`; `Reflect.get` costs a call per field.
468
+ const record = value as Record<string, unknown>
451
469
  let out: Record<string, unknown> | undefined
452
- let index = 0
453
- for (const name of keys) {
454
- const field: unknown = Reflect.get(value, name)
455
- const written = wireValue(field, name, path)
456
- if (written !== field && out === undefined) {
457
- out = {}
458
- for (const earlier of keys.slice(0, index)) define(out, earlier, Reflect.get(value, earlier))
459
- }
460
- if (out !== undefined) define(out, name, written)
461
- index++
470
+ for (const name of Object.keys(record)) {
471
+ const field = record[name]
472
+ if (typeof field !== "object" || field === null) continue
473
+ const written = wireValue(field, name, path, dates)
474
+ if (written === field) continue
475
+ // A spread copies the own enumerable fields JSON writes, faster than one assignment each.
476
+ out ??= { ...record }
477
+ define(out, name, written)
462
478
  }
463
479
  path.pop()
464
480
  return out ?? value
@@ -480,7 +496,7 @@ function collectStripped(sent: unknown, kept: unknown, path: string, out: Set<st
480
496
  }
481
497
  return
482
498
  }
483
- if (!isPlainObject(sent) || !isPlainObject(kept)) return
499
+ if (!isRecord(sent) || !isRecord(kept)) return
484
500
  for (const key of Object.keys(sent)) {
485
501
  const short = key.length > MAX_NAME_LENGTH ? `${key.slice(0, MAX_NAME_LENGTH)}...` : key
486
502
  const name = path === "" ? short : `${path}.${short}`
@@ -520,7 +536,7 @@ function strippedMessage(fields: readonly string[]): string {
520
536
 
521
537
  /** Top-level keys present in the handler's result but absent from the validated value. */
522
538
  function droppedKeys(result: unknown, value: unknown): string[] {
523
- if (!isPlainObject(result) || !isPlainObject(value)) return []
539
+ if (!isRecord(result) || !isRecord(value)) return []
524
540
  return Object.keys(result).filter((k) => !(k in value))
525
541
  }
526
542
 
@@ -587,6 +603,14 @@ interface ContractPolicy {
587
603
  const STRIP: ContractPolicy = { strip: true, report: undefined }
588
604
  const KEEP: ContractPolicy = { strip: false, report: undefined }
589
605
 
606
+ /** Set by `@nifrajs/schema` on the validate of a `t` schema with a `t.date()`: the check for a value
607
+ * as JSON writes it. */
608
+ const VALIDATE_WIRE: unique symbol = Symbol.for("nifra.schema.validateWire")
609
+
610
+ type ContractValidate = StandardSchemaV1["~standard"]["validate"] & {
611
+ readonly [VALIDATE_WIRE]?: unknown
612
+ }
613
+
590
614
  function checkContract(
591
615
  definition: ResponseContractDefinition,
592
616
  result: unknown,
@@ -634,19 +658,32 @@ function validateContract(
634
658
  policy: ContractPolicy,
635
659
  replace: (value: unknown) => unknown,
636
660
  ): ResponseContractOutcome | Promise<ResponseContractOutcome> {
637
- const project = policy.strip ? projectorOf(schema) : undefined
661
+ const node = jsonSchemaOf(schema)
662
+ // A `Date` is a hook like any other for a schema that carries its JSON Schema: one walk writes them.
663
+ const dates = node !== undefined
664
+ const project = policy.strip && node !== undefined ? projectorOf(node) : undefined
638
665
  // Naming what was stripped compares against everything the payload writes, so it walks it whole.
639
- const before =
640
- project === undefined || policy.report !== undefined || mode === "warn"
641
- ? wireValueOf(payload)
642
- : payload
666
+ const hooks =
667
+ (project === undefined || policy.report !== undefined || mode === "warn") &&
668
+ typeof payload === "object" &&
669
+ payload !== null &&
670
+ hasHook(payload, 0, dates)
671
+ const before = hooks ? wireValue(payload, "", [], dates) : payload
643
672
  // The projection applies the hook of each value it descends into and reads only declared fields,
644
673
  // so a quiet strip never enumerates the payload's own keys. What it copies whole is walked after.
645
- const sent = project === undefined ? before : wireValueOf(project(writtenAt(before, "")))
646
- const settled = schema["~standard"].validate(sent)
647
- // When hooks wrote the payload, `warn` sends what they wrote here rather than calling them again,
648
- // so a hook that answers differently on a second call cannot send what was not judged.
649
- const shown = mode === "warn" && before !== payload ? replace(before) : served
674
+ const sent =
675
+ project === undefined ? before : wireValueOf(project(writtenAt(before, "", true)), true)
676
+ // A `t` schema's check for a value as JSON carries it: its `t.date()` strings stay strings, where
677
+ // the plain validate would hand back the `Date`s a request's handler receives.
678
+ const validate: ContractValidate = schema["~standard"].validate
679
+ const wire = validate[VALIDATE_WIRE]
680
+ const settled: StandardResult<unknown> | Promise<StandardResult<unknown>> =
681
+ typeof wire === "function" ? wire(sent) : schema["~standard"].validate(sent)
682
+ // When hooks wrote the payload, `warn` sends what they wrote here rather than calling them again:
683
+ // a hook that answers differently on a second call cannot send what was not judged, and the
684
+ // serializer does not write a `Date` twice. A contracted route's `c.json` reply has none left, as
685
+ // `write` applied them when the reply was built.
686
+ const shown = mode === "warn" && hooks ? replace(before) : served
650
687
  return settled instanceof Promise
651
688
  ? settled.then((r) => interpret(r, before, shown, mode, replace, policy.report))
652
689
  : interpret(settled, before, shown, mode, replace, policy.report)
@@ -702,6 +739,12 @@ export interface ResponseContractRuntime {
702
739
  readonly jsonBody?: unknown
703
740
  },
704
741
  ): ResponseContractOutcome | Promise<ResponseContractOutcome>
742
+ /**
743
+ * What a `c.json(body)` reply with this status writes, each `toJSON` hook applied as `check` judges
744
+ * it, so the reply's bytes and its check share one call of each. Without it, as from a plugin of
745
+ * an older copy of core, the reply writes `body` as given.
746
+ */
747
+ write?(definition: ResponseContractDefinition, body: unknown, status: number | undefined): unknown
705
748
  }
706
749
 
707
750
  interface ResponseContractInstallable {
@@ -772,6 +815,10 @@ export function responseContract(
772
815
  }
773
816
  const runtime: ResponseContractRuntime = {
774
817
  mode,
818
+ write: (definition, body, status) => {
819
+ const schema = schemaFor(definition, status ?? 200)
820
+ return schema === undefined ? body : wireValueOf(body, jsonSchemaOf(schema) !== undefined)
821
+ },
775
822
  check: (definition, result, context) => {
776
823
  const jsonReply =
777
824
  result instanceof Response && result === context?.jsonReply
@@ -26,6 +26,9 @@ export const CONTEXT_SET = Symbol("nifra.context.set")
26
26
  export const CONTEXT_SEARCH = Symbol("nifra.context.search")
27
27
  /** The request a context answers, read without the transport cap `req` applies for user code. */
28
28
  export const CONTEXT_REQUEST = Symbol("nifra.context.request")
29
+ /** Sets the idle timeout of the connection a context's request arrived on (`undefined` puts back
30
+ * the listener's own); `false` when the serving adapter has none to set. */
31
+ export const CONTEXT_IDLE_TIMEOUT = Symbol()
29
32
 
30
33
  /**
31
34
  * A transport codec (or other pre-parsing hook) that already decoded the body stashes the value
@@ -114,21 +117,20 @@ export function isResponseResult(value: unknown): value is ResponseResult {
114
117
  }
115
118
 
116
119
  /**
117
- * The headers a plain render ships: its own on top of whatever the request left in `c.set.headers`,
118
- * so an ambient header (a request id, say) survives an early exit the way it survives an ordinary
119
- * return, and a header named at the exit site still wins.
120
+ * The headers a plain render ships: its `own` on top of the `ambient` ones the request left in
121
+ * `c.set.headers`, so an ambient header (a request id, say) survives an early exit the way it
122
+ * survives an ordinary return, and a header named at the exit site still wins.
120
123
  *
121
124
  * Always a fresh object when the render has headers of its own: the node writers mutate the record
122
125
  * they are handed (content-type, content-length, cookies), and a `status(...)` value is commonly
123
126
  * hoisted to module scope and answered from on every request.
124
127
  */
125
128
  export function plainRenderHeaders(
126
- plain: PlainRender,
127
- set: CtxSet,
129
+ own: Readonly<Record<string, string>> | undefined,
130
+ ambient: Record<string, string> | undefined,
128
131
  ): Record<string, string> | undefined {
129
- const own = plain.headers
130
- if (own === undefined) return set._headers
131
- return set._headers === undefined ? { ...own } : { ...set._headers, ...own }
132
+ if (own === undefined) return ambient
133
+ return ambient === undefined ? { ...own } : { ...ambient, ...own }
132
134
  }
133
135
 
134
136
  /**
@@ -89,6 +89,33 @@ export interface ServerOptions {
89
89
  * untrusted input.
90
90
  */
91
91
  readonly trustBodyFraming?: boolean
92
+ /**
93
+ * Answer `400 invalid_host` for Hosts the request target cannot safely be built on: one sent
94
+ * twice, one with a space or byte past ASCII, a port past 65535, an IPv6 literal no URL parser
95
+ * takes, a percent-encoded or sub-delimiter Host, or an authority that can change Deno's route.
96
+ * Bun may leave `c.req.url` relative or build it on the first Host, so `new URL(c.req.url)` can
97
+ * throw in a handler.
98
+ *
99
+ * Disabled by default: checking Host on every request measured about 6% of a bare route on Bun.
100
+ * Deno builds `Request.url` from Host; without this check, a malformed Host can change the routed
101
+ * path. Enable it when Deno receives requests with an untrusted Host header.
102
+ */
103
+ readonly validateHost?: boolean
104
+ /**
105
+ * Route a Deno request on the path a WHATWG parser would give it. Bun and workerd deliver a parsed
106
+ * URL, so `/public/../admin` reaches the app as `/admin`; Deno hands over the target as sent. Off,
107
+ * Deno routes the text received, as most frameworks do: a guard on `/admin` does not see
108
+ * `/public/../admin`, and a proxy that normalizes paths before forwarding disagrees with the app
109
+ * about which route a request is for. On, dot segments (`.`, `..`, `%2e`), backslashes and the
110
+ * characters a parser percent-encodes are resolved before routing, so every runtime routes the
111
+ * same path.
112
+ *
113
+ * Disabled by default: the check reads and scans the whole URL of every request, measured at about
114
+ * 3% of a bare route on Deno. Enable it when path guards, or a path-normalizing proxy, sit in
115
+ * front of or inside the app. Bun already delivers parsed targets and is unaffected; `@nifrajs/node`
116
+ * has the same option on `serve()`.
117
+ */
118
+ readonly normalizeTarget?: boolean
92
119
  /**
93
120
  * Max inbound WebSocket message size (bytes). A larger message never reaches the handler and the
94
121
  * socket closes with `1009`; when Bun's own frame limit refuses it, Bun up to 1.4.3 drops the