@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
@@ -7,6 +7,7 @@ import {
7
7
  import { readBoundedBytes } from "./body.ts"
8
8
  import { jsonError } from "./http.ts"
9
9
  import { guardDecodedValue, type ProtoPoisoning } from "./proto-guard.ts"
10
+ import { carryToCopy } from "./request-local.ts"
10
11
  import { isResponseResult, PRE_DECODED_BODY, type PreDecodedBody } from "./runtime-core.ts"
11
12
  import type { AnyServer, IdentityPlugin } from "./server.ts"
12
13
 
@@ -154,6 +155,7 @@ export function transportCodecs(
154
155
  contentType,
155
156
  }
156
157
  Object.defineProperty(normalized, PRE_DECODED_BODY, { value: stash })
158
+ carryToCopy(request, normalized)
157
159
  return normalized
158
160
  })
159
161
  app.afterHandle((result, context) => {
@@ -105,7 +105,7 @@ const BASE64URL = /^[A-Za-z0-9_-]+={0,2}$/
105
105
 
106
106
  const fromBase64Url = (value: string): Uint8Array<ArrayBuffer> | null => {
107
107
  if (!BASE64URL.test(value)) return null
108
- const unpadded = value.replace(/=+$/, "")
108
+ const unpadded = value.replace(/={1,2}$/, "")
109
109
  // Padding only ever completes the last quartet; a lone trailing character encodes no byte.
110
110
  if (unpadded.length % 4 === 1) return null
111
111
  if (unpadded.length !== value.length && value.length % 4 !== 0) return null
@@ -388,7 +388,17 @@ export function attachWebSocket<
388
388
 
389
389
  const WS_MESSAGE_DECODER = new TextDecoder()
390
390
 
391
- /** Whether a message is over `cap` UTF-8 bytes, without encoding a text one: each UTF-16 unit takes
391
+ // `@nifrajs/deno` and `@nifrajs/node` run generated copies of `UTF8_BUFFER`, `exceedsPayloadCap`,
392
+ // `utf8ExceedsCap` and `assertCloseArgs` (scripts/gen-vendored.ts), so these reference nothing else
393
+ // in this module.
394
+ /** Bun, Node, Deno and workerd from about 2026 expose `Buffer`, whose native `byteLength` sizes text
395
+ * without copying it; a Worker on an older compatibility date has none. */
396
+ const UTF8_BUFFER: { byteLength(text: string): number } | undefined = Reflect.get(
397
+ globalThis,
398
+ "Buffer",
399
+ )
400
+
401
+ /** Whether a message is over `cap` UTF-8 bytes, without copying a text one: each UTF-16 unit takes
392
402
  * 1-3 bytes (a surrogate pair 4 for its 2, a lone surrogate the 3 of U+FFFD), so only a length
393
403
  * between those bounds is counted. */
394
404
  export function exceedsPayloadCap(
@@ -399,6 +409,13 @@ export function exceedsPayloadCap(
399
409
  const units = message.length
400
410
  if (units > cap) return true
401
411
  if (units * 3 <= cap) return false
412
+ if (UTF8_BUFFER === undefined) return utf8ExceedsCap(message, cap)
413
+ return UTF8_BUFFER.byteLength(message) > cap
414
+ }
415
+
416
+ /** `exceedsPayloadCap`'s count where the runtime has no `Buffer`, one UTF-16 unit at a time. */
417
+ export function utf8ExceedsCap(message: string, cap: number): boolean {
418
+ const units = message.length
402
419
  let bytes = 0
403
420
  for (let i = 0; i < units; i++) {
404
421
  const unit = message.charCodeAt(i)
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The narrow WebSocket view the zero-dependency adapters read. `@nifrajs/node` and `@nifrajs/deno` run
3
+ * generated copies of these types (scripts/gen-vendored.ts) instead of importing core's, whose handler
4
+ * and outcome types carry the schemas and codecs the adapters never touch.
5
+ */
6
+
7
+ import type { TopicRegistry, WebSocketHandler, WebSocketUpgradeOutcome } from "./websocket.ts"
8
+
9
+ /** A received frame, normalized: text → `string`, binary → `Uint8Array`. */
10
+ export type NifraWsData = string | Uint8Array
11
+
12
+ /** The portable socket a nifra WS handler sees: `NifraWebSocket` with its data unknown. */
13
+ export interface NifraWs {
14
+ send(data: string | ArrayBufferView | ArrayBuffer): void
15
+ close(code?: number, reason?: string): void
16
+ readonly readyState: number
17
+ subscribe(topic: string): void
18
+ unsubscribe(topic: string): void
19
+ data: unknown
20
+ readonly raw: unknown
21
+ }
22
+
23
+ /** The `TopicRegistry` surface the adapter wires `ws.subscribe` to. */
24
+ export interface WsPubSub {
25
+ subscribe(topic: string, ws: NifraWs): void
26
+ unsubscribe(topic: string, ws: NifraWs): void
27
+ unsubscribeAll(ws: NifraWs): void
28
+ }
29
+
30
+ /** The post-upgrade callbacks of a `WebSocketHandler`. */
31
+ export interface NifraWsHandler {
32
+ open?(ws: NifraWs): void | Promise<void>
33
+ message?(ws: NifraWs, data: NifraWsData): void | Promise<void>
34
+ close?(ws: NifraWs, code: number, reason: string): void | Promise<void>
35
+ error?(ws: NifraWs, error: unknown): void | Promise<void>
36
+ }
37
+
38
+ /** What `resolveWebSocketUpgrade` returns, as the adapters read it. */
39
+ export type WsUpgradeOutcome =
40
+ | { readonly kind: "pass" }
41
+ | { readonly kind: "reject"; readonly response: Response }
42
+ | {
43
+ readonly kind: "upgrade"
44
+ readonly handler: NifraWsHandler
45
+ readonly data: unknown
46
+ readonly pubsub: WsPubSub
47
+ readonly maxPayloadBytes?: number
48
+ /** Builds `ws.send` with the route's outbound contract (`validateSend`). */
49
+ sender?(
50
+ handler: NifraWsHandler,
51
+ send: (payload: string | ArrayBufferView | ArrayBuffer) => void,
52
+ onError: (error: unknown) => void,
53
+ ): (payload: string | ArrayBufferView | ArrayBuffer) => void
54
+ }
55
+
56
+ type Fits<View, Actual extends View> = Actual
57
+
58
+ // Core's own types must stay assignable to the view, so a change the adapters cannot read fails here.
59
+ export type CoreFitsAdapterView = [
60
+ Fits<WsUpgradeOutcome, WebSocketUpgradeOutcome>,
61
+ Fits<NifraWsHandler, WebSocketHandler>,
62
+ Fits<WsPubSub, TopicRegistry>,
63
+ ]
package/src/server.ts CHANGED
@@ -73,7 +73,13 @@ export {
73
73
  type ScheduledHandler,
74
74
  toFetchHandler,
75
75
  } from "./server/edge.ts"
76
- export { isSameOriginPath, pathnameOf, type UrlParts, urlPartsOf } from "./server/http.ts"
76
+ export {
77
+ isSameOriginPath,
78
+ pathnameOf,
79
+ type UrlParts,
80
+ urlPartsOf,
81
+ varyOn,
82
+ } from "./server/http.ts"
77
83
  export {
78
84
  commonSecretPatterns,
79
85
  jsonLogger,
@@ -107,6 +113,11 @@ export type {
107
113
  RouteInfoFor,
108
114
  } from "./server/registry.ts"
109
115
  export { replacedRequestOf } from "./server/request-lineage.ts"
116
+ export {
117
+ carryRequestLocals,
118
+ type RequestLocal,
119
+ requestLocal,
120
+ } from "./server/request-local.ts"
110
121
  export { writableResponse } from "./server/respond.ts"
111
122
  export { routeTemplateOf } from "./server/route-template.ts"
112
123
  export {
@@ -58,6 +58,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync, statSyn
58
58
  import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:path"
59
59
  import type { BunPlugin } from "bun"
60
60
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
61
+ import { isRecord } from "./internal/record.ts"
61
62
 
62
63
  /**
63
64
  * Packages whose duplication is a defect rather than a waste. Each keeps module-scoped state that
@@ -151,9 +152,6 @@ export interface SingleCopyOptions {
151
152
  /** Environment switch for strict mode when the declaration does not set it: `1` or `true`. */
152
153
  export const SINGLE_COPY_STRICT_ENV = "NIFRA_SINGLE_COPY_STRICT"
153
154
 
154
- const isRecord = (value: unknown): value is Record<string, unknown> =>
155
- typeof value === "object" && value !== null && !Array.isArray(value)
156
-
157
155
  const readJsonSync = (path: string): Record<string, unknown> | undefined => {
158
156
  try {
159
157
  const value: unknown = JSON.parse(readFileSync(path, "utf8"))
@@ -2,12 +2,12 @@
2
2
  * What a file served from disk is typed as, which files are never served, and when a client's copy of
3
3
  * one is still current - the shared owner for every static-file surface.
4
4
  *
5
- * `@nifrajs/web`'s `servePublicDir` (dev, the Bun entry), the Deno entry's handler and
6
- * `@nifrajs/node`'s `static` reader (the Node entry) answer the same deployed files. Copies that drift
5
+ * `serveStatic()` on Bun (`@nifrajs/core/serve-static`, also `nifra dev`), `@nifrajs/deno` and
6
+ * `@nifrajs/node` answer the same deployed files. Copies that drift
7
7
  * serve a `.pdf` as a download on one runtime and inline on the other, a `.well-known` file on one and
8
8
  * a 404 on the other, or a resumed download spliced from two versions of a file on one, so all three
9
- * read this module. The Node adapter runs a generated copy (scripts/gen-node-outcome.ts), so this
10
- * module imports nothing.
9
+ * read this module. The Node and Deno adapters run a generated copy (scripts/gen-vendored.ts), so
10
+ * this module imports nothing.
11
11
  */
12
12
 
13
13
  /**
@@ -360,3 +360,349 @@ export function contentTags(
360
360
  },
361
361
  }
362
362
  }
363
+
364
+ /** `cache-control` for a content-hashed file: its name changes with its bytes. */
365
+ export const IMMUTABLE = "public, max-age=31536000, immutable"
366
+ /** `cache-control` for a file that keeps its name across deploys, as a build's `public/` files do. */
367
+ export const ONE_DAY = "public, max-age=86400"
368
+
369
+ /** Which files {@link ServeStaticOptions.hashedFiles} names: paths, patterns, or a predicate. */
370
+ export type HashedFiles = Iterable<string> | readonly RegExp[] | ((pathname: string) => boolean)
371
+
372
+ /**
373
+ * One directory `serveStatic()` serves under a URL prefix: the same options on Bun
374
+ * (`@nifrajs/core/serve-static`), `@nifrajs/node` and `@nifrajs/deno`. Each runtime reads the files
375
+ * with its own fastest file API; which request a mount takes, and how it answers, is decided here.
376
+ */
377
+ export interface ServeStaticOptions {
378
+ /** Directory to read files from - an absolute path or a `file://` URL (`new URL("./assets/", import.meta.url)`). */
379
+ readonly dir: string | URL
380
+ /** URL prefix these files are served under. Default `"/assets"`. Use `"/"` to serve the whole dir. */
381
+ readonly prefix?: string
382
+ /** Emit `cache-control: public, max-age=31536000, immutable` - correct for content-hashed files. Default `true`. */
383
+ readonly immutable?: boolean
384
+ /**
385
+ * The content-hashed files, matched on the URL path as sent. When set, `immutable` applies only to
386
+ * these, and any other file is sent with `cacheControl`, so a file that keeps its name across deploys
387
+ * still revalidates. One of:
388
+ *
389
+ * - paths, such as a build manifest's `assets`;
390
+ * - patterns, for files a pre-build step hashed that no manifest lists:
391
+ * `[/^\/fonts\/[\w-]+-[0-9a-f]{16}\.woff2$/]`;
392
+ * - a predicate, to combine the two: `(path) => assets.has(path) || FONT.test(path)`. It runs as
393
+ * written, so a RegExp it tests should be neither global nor sticky.
394
+ */
395
+ readonly hashedFiles?: HashedFiles
396
+ /**
397
+ * `cache-control` for a file that is not served immutable (`immutable: false`, or a file outside
398
+ * `hashedFiles`). Unset, such a file carries only its validators.
399
+ */
400
+ readonly cacheControl?: string
401
+ /**
402
+ * URL paths (as sent, percent-encoded) of the only files this mount serves. Any other path under
403
+ * `prefix` passes to the next mount, then the app, without this mount touching the filesystem - so
404
+ * a mount over a directory that also holds the server bundle can expose just the build's listed
405
+ * files. A later mount over the same paths can still serve the rest.
406
+ */
407
+ readonly files?: Iterable<string>
408
+ /**
409
+ * Whether this mount may answer `pathname` (as sent, without the query) for a request addressed to
410
+ * `host`. `false` passes the request on, without touching the filesystem, to the next mount and then
411
+ * the app - the seam a host policy uses so a host's refused file meets the app's own refusal instead
412
+ * of being served.
413
+ */
414
+ readonly allow?: (host: string, pathname: string) => boolean
415
+ /** Extra headers merged onto every served file. */
416
+ readonly headers?: Readonly<Record<string, string>>
417
+ /**
418
+ * Whether a path with a dot-leading segment (`/.env`, `/.git/config`, `/.hidden/app.js`) is
419
+ * served. Default `"deny"`: answered as a missing file is, so probing can't distinguish "hidden" from
420
+ * "absent". `/.well-known` is public by definition (RFC 8615) and always served. The rule applies to
421
+ * the requested path and again to the file it resolves to, so a visible name linking to `.env` is
422
+ * denied too. Set `"allow"` if the directory deliberately holds dotfiles.
423
+ */
424
+ readonly dotfiles?: "deny" | "allow"
425
+ /**
426
+ * What a path under `prefix` this mount cannot serve does: a missing or hidden file, a link out of
427
+ * the directory, a malformed or traversing path. Default `false`: answered `404` (`400` malformed,
428
+ * `403` traversing), so a missing asset is never rendered as a page. `true` passes it to the next
429
+ * mount, then the app - for a mount at `"/"` beside the app's own routes.
430
+ */
431
+ readonly fallthrough?: boolean
432
+ }
433
+
434
+ /** A {@link ServeStaticOptions} checked once, with its directory resolved by the runtime's reader. */
435
+ export interface StaticMount {
436
+ readonly root: string
437
+ /** `"/"`, or the prefix with one leading and no trailing slash. */
438
+ readonly prefix: string
439
+ readonly isImmutable: (path: string) => boolean
440
+ readonly cacheControl: string | undefined
441
+ readonly files: ReadonlySet<string> | undefined
442
+ readonly allow: ((host: string, pathname: string) => boolean) | undefined
443
+ /** Lowercased names. */
444
+ readonly headers: Readonly<Record<string, string>> | undefined
445
+ readonly denyDotfiles: boolean
446
+ readonly fallthrough: boolean
447
+ }
448
+
449
+ // `Array.isArray` does not narrow a readonly array out of a union.
450
+ const isPatternList = (value: HashedFiles): value is readonly RegExp[] =>
451
+ Array.isArray(value) && value.length > 0 && value.every((item) => item instanceof RegExp)
452
+
453
+ function hashedTest(hashedFiles: HashedFiles): (path: string) => boolean {
454
+ if (typeof hashedFiles === "function") return hashedFiles
455
+ if (isPatternList(hashedFiles)) {
456
+ // A global or sticky RegExp keeps `lastIndex` between tests, so every other match would miss.
457
+ const patterns = hashedFiles.map((pattern) =>
458
+ pattern.global || pattern.sticky
459
+ ? new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ""))
460
+ : pattern,
461
+ )
462
+ return (path) => patterns.some((pattern) => pattern.test(path))
463
+ }
464
+ const value: unknown = hashedFiles
465
+ // A Map iterates entries and tests keys, so a path it maps to `false` would still be immutable.
466
+ if (
467
+ typeof value !== "object" ||
468
+ value === null ||
469
+ value instanceof Map ||
470
+ !(Symbol.iterator in value)
471
+ ) {
472
+ throw new TypeError(
473
+ "[nifra] serveStatic hashedFiles must be paths, an array of RegExps, or a function",
474
+ )
475
+ }
476
+ const paths = new Set<string>()
477
+ for (const path of hashedFiles) {
478
+ const item: unknown = path
479
+ if (typeof item !== "string") {
480
+ throw new TypeError(
481
+ "[nifra] serveStatic hashedFiles must be paths, an array of RegExps, or a function",
482
+ )
483
+ }
484
+ paths.add(item)
485
+ }
486
+ return (path) => paths.has(path)
487
+ }
488
+
489
+ /** Check `options` once, with `root` the absolute path its runtime resolved `options.dir` to. */
490
+ export function staticMountOf(options: ServeStaticOptions, root: string): StaticMount {
491
+ const raw = options.prefix ?? "/assets"
492
+ // Index scans, not `/^\/+|\/+$/g` - the trailing-run half backtracks quadratically.
493
+ let from = 0
494
+ while (from < raw.length && raw.charCodeAt(from) === 47 /* '/' */) from++
495
+ let to = raw.length
496
+ while (to > from && raw.charCodeAt(to - 1) === 47) to--
497
+ const prefix = from === to ? "/" : `/${raw.slice(from, to)}`
498
+ const immutable = options.immutable !== false
499
+ const hashed = options.hashedFiles === undefined ? undefined : hashedTest(options.hashedFiles)
500
+ // fromEntries defines each name as its own property, so a `__proto__` header stays a header.
501
+ const headers =
502
+ options.headers === undefined
503
+ ? undefined
504
+ : Object.fromEntries(
505
+ Object.entries(options.headers).map(([name, value]) => [name.toLowerCase(), value]),
506
+ )
507
+ return {
508
+ root,
509
+ prefix,
510
+ isImmutable: !immutable ? () => false : hashed === undefined ? () => true : hashed,
511
+ cacheControl: options.cacheControl,
512
+ files: options.files === undefined ? undefined : new Set(options.files),
513
+ allow: options.allow,
514
+ headers,
515
+ denyDotfiles: options.dotfiles !== "allow",
516
+ fallthrough: options.fallthrough === true,
517
+ }
518
+ }
519
+
520
+ /** One mount or several, as `serveStatic()` takes them, each checked by {@link staticMountOf}. */
521
+ export function staticMountsOf(
522
+ mounts: ServeStaticOptions | readonly ServeStaticOptions[],
523
+ rootOf: (dir: string | URL) => string,
524
+ ): readonly StaticMount[] {
525
+ const list: readonly ServeStaticOptions[] = "dir" in mounts ? [mounts] : mounts
526
+ return list.map((options) => staticMountOf(options, rootOf(options.dir)))
527
+ }
528
+
529
+ /**
530
+ * What a mount does with a request path: `"pass"` to the next mount and then the app, a status to
531
+ * answer with, or the decoded path relative to the mount's directory (no leading slash, no `.` or `..`
532
+ * segment, no backslash or NUL) to read.
533
+ */
534
+ export type StaticMatch =
535
+ | "pass"
536
+ | { readonly status: 400 | 403 | 404 }
537
+ | { readonly rel: string; readonly immutable: boolean }
538
+
539
+ /**
540
+ * Match `path` (the URL path as sent, without the query) against `mount`, without touching the
541
+ * filesystem. `host` names the request's host, read only when the mount has an `allow`.
542
+ */
543
+ export function matchStatic(mount: StaticMount, path: string, host: () => string): StaticMatch {
544
+ const { prefix } = mount
545
+ if (prefix !== "/" && path !== prefix && !path.startsWith(`${prefix}/`)) return "pass"
546
+ if (mount.files !== undefined && !mount.files.has(path)) return "pass"
547
+ if (mount.allow !== undefined && !mount.allow(host(), path)) return "pass"
548
+ let rel: string
549
+ try {
550
+ rel = decodeURIComponent(prefix === "/" ? path : path.slice(prefix.length))
551
+ } catch {
552
+ return mount.fallthrough ? "pass" : { status: 400 }
553
+ }
554
+ let start = 0
555
+ while (start < rel.length && rel.charCodeAt(start) === 47) start++
556
+ rel = rel.slice(start)
557
+ // A directory request is the app's to answer.
558
+ if (rel === "" || rel.charCodeAt(rel.length - 1) === 47) return "pass"
559
+ if (rel.includes("\0")) return mount.fallthrough ? "pass" : { status: 400 }
560
+ // Traversal in the REQUEST form never reaches a path join: a `..` segment, or a backslash Windows
561
+ // would treat as a separator, has no use in a file URL. A name merely CONTAINING `..`
562
+ // (`logo..png`) is legal. The check runs after decoding, so `%2E`-spelled dots are plain here.
563
+ // Traversal answers 403 (an attack shape, nothing behind it to conceal); a hidden file answers as a
564
+ // missing one does.
565
+ const segments = rel.split("/")
566
+ if (rel.includes("\\") || segments.includes("..")) {
567
+ return mount.fallthrough ? "pass" : { status: 403 }
568
+ }
569
+ if (mount.denyDotfiles && isHiddenPath(rel)) return mount.fallthrough ? "pass" : { status: 404 }
570
+ return { rel, immutable: mount.isImmutable(path) }
571
+ }
572
+
573
+ /** The `cache-control` a file a mount serves carries, or `undefined` for its validators alone. */
574
+ export function staticCacheControl(mount: StaticMount, immutable: boolean): string | undefined {
575
+ return immutable ? IMMUTABLE : mount.cacheControl
576
+ }
577
+
578
+ /** A request's part of a static answer: its method and its headers by lowercase name. */
579
+ export interface StaticRequest {
580
+ readonly method: "GET" | "HEAD"
581
+ readonly header: (name: string) => string | null
582
+ }
583
+
584
+ /** How a reader reaches the opened, pinned file for {@link planStatic}. */
585
+ export interface StaticFileAccess {
586
+ /** The real path the file was opened at: what tags and types it. */
587
+ readonly path: string
588
+ readonly version: FileVersion
589
+ /** Read the whole file, up to {@link CONTENT_SNAPSHOT_MAX} bytes, through the open descriptor. */
590
+ readonly readWhole: () => Promise<Uint8Array>
591
+ /** Stat the open descriptor again. */
592
+ readonly restat: () => Promise<FileVersion>
593
+ readonly tags: ContentTags
594
+ /** `@nifrajs/core/range`'s parser, passed in so this module imports nothing. */
595
+ readonly parseRange: (
596
+ value: string,
597
+ size: number,
598
+ ) =>
599
+ | { readonly kind: "none" | "unsatisfiable" }
600
+ | {
601
+ readonly kind: "satisfiable"
602
+ readonly ranges: readonly { readonly start: number; readonly end: number }[]
603
+ }
604
+ }
605
+
606
+ /**
607
+ * What a reader sends for a file a mount took: a header-only answer, or `length` bytes from `start`.
608
+ * When tagging it called `readWhole`, the body comes from those same bytes.
609
+ */
610
+ export type StaticPlan =
611
+ | {
612
+ readonly status: 200 | 206 | 304 | 416
613
+ readonly headers: Record<string, string>
614
+ readonly body: false
615
+ }
616
+ | {
617
+ readonly status: 200 | 206
618
+ readonly headers: Record<string, string>
619
+ readonly body: true
620
+ readonly start: number
621
+ readonly length: number
622
+ }
623
+
624
+ /**
625
+ * The answer for an opened file, the one sequence every runtime's reader runs: the mount's headers
626
+ * (a caller's own `cache-control`, `last-modified` or `etag` kept), a strong tag from the snapshot
627
+ * served for a file up to {@link CONTENT_SNAPSHOT_MAX} bytes, a `304` for a current copy, then one
628
+ * byte range or the whole file. A larger file streams with no tag, and its date is weak: an
629
+ * `If-Range` gets the whole file rather than a range spliced from two versions.
630
+ */
631
+ export async function planStatic(
632
+ mount: StaticMount,
633
+ immutable: boolean,
634
+ request: StaticRequest,
635
+ file: StaticFileAccess,
636
+ ): Promise<StaticPlan> {
637
+ const headers: Record<string, string> = { ...mount.headers }
638
+ if (headers["cache-control"] === undefined) {
639
+ const cacheControl = staticCacheControl(mount, immutable)
640
+ if (cacheControl !== undefined) headers["cache-control"] = cacheControl
641
+ }
642
+ const { size } = file.version
643
+ const streamed = size > CONTENT_SNAPSHOT_MAX
644
+ const modified = streamed ? streamedLastModifiedOf(file.version) : lastModifiedOf(file.version)
645
+ // A date this reader chose for a streamed body is weak; a caller's own stays theirs to vouch for.
646
+ const weakDate = streamed && headers["last-modified"] === undefined
647
+ if (modified !== undefined) headers["last-modified"] ??= new Date(modified).toUTCString()
648
+ const parsedLastModified = Date.parse(headers["last-modified"] ?? "")
649
+ const lastModified = Number.isFinite(parsedLastModified) ? parsedLastModified : undefined
650
+ const ifNoneMatch = request.header("if-none-match")
651
+ const ifModifiedSince = request.header("if-modified-since")
652
+ if (headers.etag === undefined && !streamed) {
653
+ // A HEAD or a 304 sends none of the file, so the tag remembered for this settled version
654
+ // answers it from the stat alone: a write since that tag would show in the stat.
655
+ const known = file.tags.cached(file.path, file.version)
656
+ if (
657
+ known !== undefined &&
658
+ (request.method === "HEAD" ||
659
+ isNotModified(ifNoneMatch, ifModifiedSince, known, lastModified))
660
+ ) {
661
+ headers.etag = known
662
+ } else {
663
+ const whole = await file.readWhole()
664
+ headers.etag = await file.tags.ofSnapshot(file.path, file.version, whole, file.restat)
665
+ }
666
+ }
667
+ // Custom validators are response headers, not decoration: compare the exact values the client saw.
668
+ const etag = headers.etag
669
+ if (isNotModified(ifNoneMatch, ifModifiedSince, etag, lastModified)) {
670
+ delete headers["content-length"]
671
+ delete headers["content-type"]
672
+ return { status: 304, headers, body: false }
673
+ }
674
+ // The file served, not the name asked for: a link is typed by its target.
675
+ headers["content-type"] = contentTypeOf(file.path)
676
+ // Never let a client sniff a served file into a more dangerous type (an .svg as active content).
677
+ headers["x-content-type-options"] = "nosniff"
678
+ // Advertised unconditionally: a client that never sees it will not seek in a video or audio file.
679
+ headers["accept-ranges"] = "bytes"
680
+ // Range applies to GET only (RFC 9110 section 14.2); HEAD reports the whole representation.
681
+ const rangeHeader = request.method === "GET" ? request.header("range") : null
682
+ const parsed =
683
+ rangeHeader !== null &&
684
+ ifRangeAllowsPartial(request.header("if-range"), etag, weakDate ? undefined : lastModified)
685
+ ? file.parseRange(rangeHeader, size)
686
+ : undefined
687
+ if (parsed?.kind === "unsatisfiable") {
688
+ headers["content-range"] = `bytes */${size}`
689
+ delete headers["content-length"]
690
+ delete headers["content-type"]
691
+ return { status: 416, headers, body: false }
692
+ }
693
+ // Several disjoint ranges would need a `multipart/byteranges` body assembled from disk; RFC 9110
694
+ // lets a server ignore Range, so the whole file answers them.
695
+ const range =
696
+ parsed?.kind === "satisfiable" && parsed.ranges.length === 1 ? parsed.ranges[0] : undefined
697
+ if (range !== undefined) {
698
+ headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`
699
+ headers["content-length"] = String(range.end - range.start + 1)
700
+ } else {
701
+ headers["content-length"] = String(size)
702
+ }
703
+ const status = range === undefined ? 200 : 206
704
+ if (request.method === "HEAD") return { status, headers, body: false }
705
+ const start = range === undefined ? 0 : range.start
706
+ const length = range === undefined ? size : range.end - range.start + 1
707
+ return { status, headers, body: true, start, length }
708
+ }
package/src/wire.ts CHANGED
@@ -21,6 +21,8 @@
21
21
  * carrying its own enumerable properties), non-enumerable and symbol keys, and getters (read once).
22
22
  */
23
23
 
24
+ import { isRecord } from "./internal/record.ts"
25
+
24
26
  /** Discriminator key on a tagged wire node. Never collides with user data: user objects live under a
25
27
  * node's `v` field, so their own keys - including one literally named `$w` - are never re-interpreted. */
26
28
  const TAG = "$w"
@@ -74,10 +76,6 @@ export class WireDecodeError extends TypeError {
74
76
  }
75
77
  }
76
78
 
77
- function isRecord(value: unknown): value is Record<string, unknown> {
78
- return value !== null && typeof value === "object" && !Array.isArray(value)
79
- }
80
-
81
79
  function decodeLimit(value: number | undefined, fallback: number, label: string): number {
82
80
  const limit = value ?? fallback
83
81
  if (!Number.isSafeInteger(limit) || limit < 0) {