@nifrajs/core 5.8.0 → 5.10.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 (239) hide show
  1. package/CHANGELOG.md +118 -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/router/path-codec.d.ts +22 -0
  80. package/dist/router/path-codec.d.ts.map +1 -0
  81. package/dist/router/path-codec.js +87 -0
  82. package/dist/router/path-codec.js.map +1 -0
  83. package/dist/router/pattern.d.ts +21 -1
  84. package/dist/router/pattern.d.ts.map +1 -1
  85. package/dist/router/pattern.js +117 -0
  86. package/dist/router/pattern.js.map +1 -1
  87. package/dist/router/same-origin-path.d.ts +22 -0
  88. package/dist/router/same-origin-path.d.ts.map +1 -0
  89. package/dist/router/same-origin-path.js +37 -0
  90. package/dist/router/same-origin-path.js.map +1 -0
  91. package/dist/serve-static.d.ts +22 -0
  92. package/dist/serve-static.d.ts.map +1 -0
  93. package/dist/serve-static.js +187 -0
  94. package/dist/serve-static.js.map +1 -0
  95. package/dist/server/bare-error-lane.d.ts +2 -1
  96. package/dist/server/bare-error-lane.d.ts.map +1 -1
  97. package/dist/server/bare-error-lane.js +12 -3
  98. package/dist/server/bare-error-lane.js.map +1 -1
  99. package/dist/server/base64-answers.d.ts.map +1 -1
  100. package/dist/server/base64-answers.js +1 -4
  101. package/dist/server/base64-answers.js.map +1 -1
  102. package/dist/server/body-lane.d.ts.map +1 -1
  103. package/dist/server/body-lane.js +43 -38
  104. package/dist/server/body-lane.js.map +1 -1
  105. package/dist/server/body.d.ts.map +1 -1
  106. package/dist/server/body.js +4 -1
  107. package/dist/server/body.js.map +1 -1
  108. package/dist/server/bridge-markers.d.ts +1 -1
  109. package/dist/server/bridge-markers.js +1 -1
  110. package/dist/server/cookies.d.ts.map +1 -1
  111. package/dist/server/cookies.js +4 -1
  112. package/dist/server/cookies.js.map +1 -1
  113. package/dist/server/core-copies.js +1 -1
  114. package/dist/server/core-copies.js.map +1 -1
  115. package/dist/server/http.d.ts +6 -18
  116. package/dist/server/http.d.ts.map +1 -1
  117. package/dist/server/http.js +14 -33
  118. package/dist/server/http.js.map +1 -1
  119. package/dist/server/idempotency-lane.d.ts.map +1 -1
  120. package/dist/server/idempotency-lane.js +2 -0
  121. package/dist/server/idempotency-lane.js.map +1 -1
  122. package/dist/server/idle-timeout.d.ts +39 -0
  123. package/dist/server/idle-timeout.d.ts.map +1 -0
  124. package/dist/server/idle-timeout.js +95 -0
  125. package/dist/server/idle-timeout.js.map +1 -0
  126. package/dist/server/json-shared.d.ts +23 -0
  127. package/dist/server/json-shared.d.ts.map +1 -0
  128. package/dist/server/json-shared.js +33 -0
  129. package/dist/server/json-shared.js.map +1 -0
  130. package/dist/server/json-text.d.ts +1 -1
  131. package/dist/server/json-text.js +1 -1
  132. package/dist/server/node-outcome.js +1 -1
  133. package/dist/server/node-outcome.js.map +1 -1
  134. package/dist/server/request-context.d.ts +22 -1
  135. package/dist/server/request-context.d.ts.map +1 -1
  136. package/dist/server/request-context.js +44 -4
  137. package/dist/server/request-context.js.map +1 -1
  138. package/dist/server/request-local.d.ts +44 -0
  139. package/dist/server/request-local.d.ts.map +1 -0
  140. package/dist/server/request-local.js +91 -0
  141. package/dist/server/request-local.js.map +1 -0
  142. package/dist/server/respond.js +1 -1
  143. package/dist/server/respond.js.map +1 -1
  144. package/dist/server/response-contract-lane.d.ts +10 -2
  145. package/dist/server/response-contract-lane.d.ts.map +1 -1
  146. package/dist/server/response-contract-lane.js +93 -64
  147. package/dist/server/response-contract-lane.js.map +1 -1
  148. package/dist/server/runtime-core.d.ts +7 -4
  149. package/dist/server/runtime-core.d.ts.map +1 -1
  150. package/dist/server/runtime-core.js +9 -7
  151. package/dist/server/runtime-core.js.map +1 -1
  152. package/dist/server/server-types.d.ts +23 -8
  153. package/dist/server/server-types.d.ts.map +1 -1
  154. package/dist/server/server.d.ts +58 -59
  155. package/dist/server/server.d.ts.map +1 -1
  156. package/dist/server/server.js +860 -510
  157. package/dist/server/server.js.map +1 -1
  158. package/dist/server/transport-lane.d.ts.map +1 -1
  159. package/dist/server/transport-lane.js +2 -0
  160. package/dist/server/transport-lane.js.map +1 -1
  161. package/dist/server/webhook.js +1 -1
  162. package/dist/server/webhook.js.map +1 -1
  163. package/dist/server/websocket.d.ts +3 -1
  164. package/dist/server/websocket.d.ts.map +1 -1
  165. package/dist/server/websocket.js +14 -1
  166. package/dist/server/websocket.js.map +1 -1
  167. package/dist/server/ws-adapter-view.d.ts +54 -0
  168. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  169. package/dist/server/ws-adapter-view.js +7 -0
  170. package/dist/server/ws-adapter-view.js.map +1 -0
  171. package/dist/server.d.ts +2 -1
  172. package/dist/server.d.ts.map +1 -1
  173. package/dist/server.js +2 -1
  174. package/dist/server.js.map +1 -1
  175. package/dist/single-copy.d.ts.map +1 -1
  176. package/dist/single-copy.js +1 -1
  177. package/dist/single-copy.js.map +1 -1
  178. package/dist/static-file.d.ts +156 -4
  179. package/dist/static-file.d.ts.map +1 -1
  180. package/dist/static-file.js +198 -4
  181. package/dist/static-file.js.map +1 -1
  182. package/dist/wire.d.ts.map +1 -1
  183. package/dist/wire.js +1 -3
  184. package/dist/wire.js.map +1 -1
  185. package/package.json +16 -1
  186. package/src/assurance.ts +66 -16
  187. package/src/canonical-json.ts +43 -0
  188. package/src/capabilities.ts +1 -8
  189. package/src/data.ts +2 -8
  190. package/src/diff.ts +2 -12
  191. package/src/durable-adapters.ts +53 -50
  192. package/src/durable-saga.ts +30 -23
  193. package/src/evidence.ts +25 -90
  194. package/src/idempotency.ts +62 -20
  195. package/src/index.ts +13 -2
  196. package/src/internal/control-character.ts +8 -0
  197. package/src/internal/durable-shared.ts +4 -1
  198. package/src/internal/hex.ts +14 -0
  199. package/src/internal/record.ts +4 -0
  200. package/src/internal/route-compiler.ts +2 -2
  201. package/src/internal/route-execution.ts +7 -20
  202. package/src/internal/route-lanes.ts +20 -44
  203. package/src/internal/schema-parts.ts +19 -0
  204. package/src/ledger.ts +2 -16
  205. package/src/manifest.ts +10 -38
  206. package/src/open-resolved.ts +64 -0
  207. package/src/range.ts +1 -1
  208. package/src/router/path-codec.ts +93 -0
  209. package/src/router/pattern.ts +127 -0
  210. package/src/router/same-origin-path.ts +36 -0
  211. package/src/serve-static.ts +227 -0
  212. package/src/server/bare-error-lane.ts +11 -2
  213. package/src/server/base64-answers.ts +2 -6
  214. package/src/server/body-lane.ts +58 -51
  215. package/src/server/body.ts +4 -1
  216. package/src/server/bridge-markers.ts +1 -1
  217. package/src/server/cookies.ts +4 -1
  218. package/src/server/core-copies.ts +1 -1
  219. package/src/server/http.ts +13 -33
  220. package/src/server/idempotency-lane.ts +2 -0
  221. package/src/server/idle-timeout.ts +107 -0
  222. package/src/server/json-shared.ts +38 -0
  223. package/src/server/json-text.ts +1 -1
  224. package/src/server/node-outcome.ts +1 -1
  225. package/src/server/request-context.ts +62 -3
  226. package/src/server/request-local.ts +109 -0
  227. package/src/server/respond.ts +1 -1
  228. package/src/server/response-contract-lane.ts +112 -65
  229. package/src/server/runtime-core.ts +10 -8
  230. package/src/server/server-types.ts +23 -8
  231. package/src/server/server.ts +1220 -798
  232. package/src/server/transport-lane.ts +2 -0
  233. package/src/server/webhook.ts +1 -1
  234. package/src/server/websocket.ts +18 -1
  235. package/src/server/ws-adapter-view.ts +63 -0
  236. package/src/server.ts +12 -1
  237. package/src/single-copy.ts +1 -3
  238. package/src/static-file.ts +350 -4
  239. package/src/wire.ts +2 -4
package/src/manifest.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Deterministic, signed route manifest: contract × assurance × capabilities × classification. */
2
2
 
3
3
  import type { AssuranceReport } from "./assurance.ts"
4
+ import { canonicalJson } from "./canonical-json.ts"
4
5
  import type { CapabilityAssuranceReport } from "./capabilities.ts"
5
6
  import {
6
7
  type DataClassification,
@@ -15,6 +16,8 @@ import {
15
16
  } from "./diff.ts"
16
17
  import { type ProjectEvidenceSnapshot, snapshotProjectEvidence } from "./evidence.ts"
17
18
  import { codeUnitOrder } from "./internal/code-unit-order.ts"
19
+ import { hasControlCharacter } from "./internal/control-character.ts"
20
+ import { sha256Hex } from "./internal/hex.ts"
18
21
 
19
22
  export interface NifraManifestAssurance {
20
23
  readonly rule?: string
@@ -70,35 +73,6 @@ export interface BuildNifraManifestInput {
70
73
 
71
74
  const encoder = new TextEncoder()
72
75
 
73
- function hasControlCharacter(value: string): boolean {
74
- for (let index = 0; index < value.length; index++) {
75
- const code = value.charCodeAt(index)
76
- if (code <= 31 || code === 127) return true
77
- }
78
- return false
79
- }
80
-
81
- function canonicalValue(value: unknown): string {
82
- if (value === null || typeof value === "string" || typeof value === "boolean") {
83
- return JSON.stringify(value)
84
- }
85
- if (typeof value === "number") {
86
- if (!Number.isFinite(value))
87
- throw new TypeError("nifra manifest cannot encode non-finite numbers")
88
- return JSON.stringify(value)
89
- }
90
- if (Array.isArray(value)) return `[${value.map((item) => canonicalValue(item)).join(",")}]`
91
- if (typeof value === "object") {
92
- const record = value as Readonly<Record<string, unknown>>
93
- return `{${Object.keys(record)
94
- .filter((key) => record[key] !== undefined)
95
- .sort()
96
- .map((key) => `${JSON.stringify(key)}:${canonicalValue(record[key])}`)
97
- .join(",")}}`
98
- }
99
- throw new TypeError(`nifra manifest cannot encode ${typeof value}`)
100
- }
101
-
102
76
  function manifestBody(manifest: Pick<NifraManifest, "manifestVersion" | "routes">): {
103
77
  readonly manifestVersion: 1
104
78
  readonly routes: readonly NifraManifestRoute[]
@@ -110,17 +84,17 @@ function manifestBody(manifest: Pick<NifraManifest, "manifestVersion" | "routes"
110
84
  export function canonicalManifest(
111
85
  manifest: Pick<NifraManifest, "manifestVersion" | "routes">,
112
86
  ): string {
113
- return canonicalValue(manifestBody(manifest))
87
+ return canonicalJson(manifestBody(manifest))
114
88
  }
115
89
 
116
90
  /** Byte-stable artifact serialization (including `contentHash`). */
117
91
  export function serializeNifraManifest(manifest: NifraManifest): string {
118
- return canonicalValue(manifest)
92
+ return canonicalJson(manifest)
119
93
  }
120
94
 
121
95
  /** Byte-stable serialization for the detached signature sidecar. */
122
96
  export function serializeNifraManifestSignature(signature: NifraManifestSignature): string {
123
- return canonicalValue(signature)
97
+ return canonicalJson(signature)
124
98
  }
125
99
 
126
100
  /** Parse the detached sidecar before selecting its operator-controlled public key. */
@@ -163,11 +137,6 @@ export function parseNifraManifestSignature(
163
137
  return Object.freeze(candidate as NifraManifestSignature)
164
138
  }
165
139
 
166
- async function sha256Hex(value: string): Promise<string> {
167
- const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(value)))
168
- return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("")
169
- }
170
-
171
140
  const routeKey = (method: string, path: string): string => `${method.toUpperCase()}\n${path}`
172
141
  const sortedStrings = (values: Iterable<string>): readonly string[] =>
173
142
  Object.freeze([...new Set(values)].sort())
@@ -255,7 +224,10 @@ export async function buildNifraManifest(input: BuildNifraManifestInput): Promis
255
224
  function base64Url(bytes: Uint8Array): string {
256
225
  let binary = ""
257
226
  for (const byte of bytes) binary += String.fromCharCode(byte)
258
- return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")
227
+ return btoa(binary)
228
+ .replaceAll("+", "-")
229
+ .replaceAll("/", "_")
230
+ .replace(/={1,2}$/, "")
259
231
  }
260
232
 
261
233
  function fromBase64Url(value: string): Uint8Array<ArrayBuffer> {
@@ -0,0 +1,64 @@
1
+ import { constants as FS } from "node:fs"
2
+ import { type FileHandle, open, readlink, realpath, stat } from "node:fs/promises"
3
+
4
+ // Darwin's open(2) flag refusing a symlink in ANY component; `O_NOFOLLOW` covers only the last one.
5
+ const O_NOFOLLOW_ANY = 0x20000000
6
+ // Deno's node:fs rejects flags it does not know with EINVAL; the first refusal turns the flag off.
7
+ let noFollowAny: boolean | undefined
8
+
9
+ /**
10
+ * Open a name `realpath` already resolved, refusing it when any component became a symlink since.
11
+ * A local writer inside the served tree can swap an intermediate directory for a link after the
12
+ * containment check; `O_NOFOLLOW` alone would follow it. Darwin refuses such a path in the kernel;
13
+ * Linux reads back the name the descriptor holds. Elsewhere, Windows included, no file API can open
14
+ * without following a middle link or name an open descriptor's path, so the check in
15
+ * {@link opensAt} is a platform limit: it narrows the race to repeated swaps but cannot close it.
16
+ */
17
+ export async function openResolved(realFile: string): Promise<FileHandle> {
18
+ // Read per open, not at load: this module can sit in a client bundle graph, where `process` is
19
+ // undefined.
20
+ if (noFollowAny ?? process.platform === "darwin") {
21
+ try {
22
+ return await open(realFile, FS.O_RDONLY | O_NOFOLLOW_ANY)
23
+ } catch (error) {
24
+ if (!(error instanceof Error && "code" in error && error.code === "EINVAL")) throw error
25
+ noFollowAny = false
26
+ }
27
+ }
28
+ const handle = await open(realFile, FS.O_RDONLY | FS.O_NOFOLLOW)
29
+ try {
30
+ if (await opensAt(handle, realFile)) return handle
31
+ } catch {}
32
+ await handle.close()
33
+ throw Object.assign(new Error(`${realFile} moved while it was opened`), { code: "ELOOP" })
34
+ }
35
+
36
+ /** Whether `handle` is the file at `realFile` with no symlink in its path. */
37
+ export async function opensAt(
38
+ handle: Pick<FileHandle, "fd" | "stat">,
39
+ realFile: string,
40
+ ): Promise<boolean> {
41
+ try {
42
+ return (await readlink(`/proc/self/fd/${handle.fd}`)) === realFile
43
+ } catch {
44
+ return sameFileAt(handle, realFile)
45
+ }
46
+ }
47
+
48
+ /**
49
+ * {@link opensAt} where no descriptor's path can be read. Exported for its test, which runs it on
50
+ * every host.
51
+ */
52
+ export async function sameFileAt(
53
+ handle: Pick<FileHandle, "stat">,
54
+ realFile: string,
55
+ ): Promise<boolean> {
56
+ // One step after another, so passing takes a swap back and a swap again between steps: the name
57
+ // resolves to itself, then to the opened file, then to itself again. Identities are compared as
58
+ // bigints because a Windows file index can exceed 2^53.
59
+ if ((await realpath(realFile)) !== realFile) return false
60
+ const opened = await handle.stat({ bigint: true })
61
+ const named = await stat(realFile, { bigint: true })
62
+ if (opened.dev !== named.dev || opened.ino !== named.ino) return false
63
+ return (await realpath(realFile)) === realFile
64
+ }
package/src/range.ts CHANGED
@@ -104,7 +104,7 @@ export function parseByteRange(value: string | null, size: number): ByteRangeRes
104
104
  if (value.length > MAX_RANGE_HEADER_LENGTH) return { kind: "none" }
105
105
  const single = parseSingleRange(value, size)
106
106
  if (single !== undefined) return single
107
- const match = /^bytes\s*=\s*(.+)$/i.exec(value)
107
+ const match = /^bytes\s*=\s*(\S.*)$/i.exec(value)
108
108
  if (match === null) return { kind: "none" }
109
109
  const source = match[1]!
110
110
  let separators = 0
@@ -0,0 +1,93 @@
1
+ // One URL path segment, encoded and decoded. Every place that spells a value into a path calls this
2
+ // module, so a link, a prerendered file, a client request and a public asset agree on one spelling.
3
+
4
+ const HEX = "0123456789ABCDEF"
5
+
6
+ const UNRESERVED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
7
+
8
+ function rawTable(chars: string): Uint8Array {
9
+ const table = new Uint8Array(128)
10
+ for (let i = 0; i < chars.length; i++) table[chars.charCodeAt(i)] = 1
11
+ return table
12
+ }
13
+
14
+ // RFC 3986 `pchar` minus `;`: the unreserved set, the sub-delimiters, `:` and `@`. `;` delimits path
15
+ // parameters (RFC 3986 section 3.3) and servlet containers strip them; `[`, `]` and `|` are not valid in
16
+ // a path at all and Tomcat rejects them unencoded by default.
17
+ const CANONICAL = /* @__PURE__ */ rawTable(`${UNRESERVED}!$&'()*+,:=@`)
18
+
19
+ // What a browser leaves as written in a path: the canonical set plus `;`, `[`, `]` and `|`.
20
+ const BROWSER = /* @__PURE__ */ rawTable(`${UNRESERVED}!$&'()*+,:=@;[]|`)
21
+
22
+ function encodeNonAscii(run: string): string {
23
+ try {
24
+ return encodeURIComponent(run)
25
+ } catch {
26
+ throw new RangeError("[nifra/core] a path segment cannot hold a lone surrogate")
27
+ }
28
+ }
29
+
30
+ function encodeWith(raw: Uint8Array, value: string): string {
31
+ if (value === "." || value === "..") {
32
+ throw new RangeError(
33
+ `[nifra/core] a path segment cannot be ${JSON.stringify(value)}: every spelling of it is a dot segment to a URL parser`,
34
+ )
35
+ }
36
+ const length = value.length
37
+ let out = ""
38
+ let start = 0
39
+ let i = 0
40
+ while (i < length) {
41
+ const code = value.charCodeAt(i)
42
+ if (code < 128 && raw[code] === 1) {
43
+ i++
44
+ continue
45
+ }
46
+ out += value.slice(start, i)
47
+ if (code < 128) {
48
+ out += `%${HEX[code >> 4]}${HEX[code & 15]}`
49
+ i++
50
+ } else {
51
+ let end = i + 1
52
+ while (end < length && value.charCodeAt(end) >= 128) end++
53
+ out += encodeNonAscii(value.slice(i, end))
54
+ i = end
55
+ }
56
+ start = i
57
+ }
58
+ return start === 0 ? value : out + value.slice(start)
59
+ }
60
+
61
+ /**
62
+ * `value` as one path segment. Left as written: letters, digits, the at sign and `- . _ ~ ! $ & ' ( ) * + , : =`.
63
+ * Everything else is percent-encoded, UTF-8 and upper-case hex, including `; [ ] | / ? # % \`.
64
+ *
65
+ * Throws a `RangeError` for a value that no segment can carry: exactly `.` or `..` (a URL parser reads
66
+ * `%2E` and `%2E%2E` as dot segments too) and a lone surrogate (substituting U+FFFD would map distinct
67
+ * values to one URL). The empty string is returned as is; a caller that cannot use it checks for it.
68
+ */
69
+ export function encodePathSegment(value: string): string {
70
+ return encodeWith(CANONICAL, value)
71
+ }
72
+
73
+ /**
74
+ * `value` as a browser spells it in a request URL: {@link encodePathSegment} with `; [ ] |` left as
75
+ * written. Used for one thing, registering a public file under the spelling its page links request;
76
+ * it differs from the canonical form on those four characters only.
77
+ */
78
+ export function browserPathSegment(value: string): string {
79
+ return encodeWith(BROWSER, value)
80
+ }
81
+
82
+ /**
83
+ * `raw` with its percent-escapes decoded, or `undefined` when one is malformed. The inverse of
84
+ * {@link encodePathSegment} and of {@link browserPathSegment}; a segment in any other spelling decodes
85
+ * too. What a malformed escape means is the caller's policy.
86
+ */
87
+ export function decodePathSegment(raw: string): string | undefined {
88
+ try {
89
+ return decodeURIComponent(raw)
90
+ } catch {
91
+ return undefined
92
+ }
93
+ }
@@ -1,4 +1,8 @@
1
1
  import { RouteConfigError } from "../errors.ts"
2
+ import { encodePathSegment } from "./path-codec.ts"
3
+
4
+ export { browserPathSegment, decodePathSegment, encodePathSegment } from "./path-codec.ts"
5
+ export { isSameOriginPath } from "./same-origin-path.ts"
2
6
 
3
7
  const PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
4
8
  const RESERVED_PARAM_NAMES = new Set(["__proto__", "constructor", "prototype"])
@@ -649,6 +653,129 @@ export function decodeRouteParams(raw: Record<string, string>): Record<string, s
649
653
  return out ?? raw
650
654
  }
651
655
 
656
+ export type FilledRoutePath =
657
+ | { readonly ok: true; readonly path: string }
658
+ | {
659
+ readonly ok: false
660
+ /** Params the pattern names that `params` leaves out or holds as the empty string. */
661
+ readonly missing: readonly string[]
662
+ /** Params whose value cannot be carried or would be split differently by this pattern. */
663
+ readonly invalid: readonly string[]
664
+ }
665
+
666
+ /**
667
+ * The path `pattern` names for `params`, every value spelled by {@link encodePathSegment}. A wildcard
668
+ * value is split on `/` and each part encoded, empty parts dropped; a `.` or `..` part is invalid, as
669
+ * a whole-segment value is. Nothing is thrown for a bad value: the caller decides what a missing or
670
+ * invalid param means. A param inside a mixed segment is invalid when its value holds the literal that ends it (`name` = `a.b` in
671
+ * `:name.:ext`), or when it touches the next param and encodes to more than one character, because the
672
+ * router would split it differently. A param's constraint (`:id{[0-9]+}`) is not checked here.
673
+ */
674
+ export function fillRoutePath(
675
+ pattern: string | CompiledRoutePattern,
676
+ params: Readonly<Record<string, string | undefined>>,
677
+ ): FilledRoutePath {
678
+ const compiled = typeof pattern === "string" ? compileRoutePattern(pattern) : pattern
679
+ // Built as one string, with the two problem lists made only when a param is bad: a path that fills
680
+ // allocates nothing else.
681
+ let path = ""
682
+ let missing: string[] | undefined
683
+ let invalid: string[] | undefined
684
+ const segments = compiled.segments
685
+ for (let s = 0; s < segments.length; s++) {
686
+ const segment = segments[s]!
687
+ if (segment.kind === "static") {
688
+ path += `/${segment.value}`
689
+ } else if (segment.kind === "param") {
690
+ const value = fillValue(params, segment.name)
691
+ if (value === undefined) {
692
+ missing = noted(missing, segment.name)
693
+ path += "/"
694
+ continue
695
+ }
696
+ const encoded = spell(value)
697
+ if (encoded === undefined) invalid = noted(invalid, segment.name)
698
+ path += `/${encoded ?? ""}`
699
+ } else if (segment.kind === "wildcard") {
700
+ const value = fillValue(params, segment.name)
701
+ let joined = ""
702
+ let any = false
703
+ if (value !== undefined) {
704
+ for (const piece of value.split("/")) {
705
+ if (piece === "") continue
706
+ const encoded = spell(piece)
707
+ if (encoded === undefined) invalid = noted(invalid, segment.name)
708
+ joined += any ? `/${encoded ?? ""}` : (encoded ?? "")
709
+ any = true
710
+ }
711
+ }
712
+ if (!any) missing = noted(missing, segment.name)
713
+ path += `/${joined}`
714
+ } else {
715
+ let text = ""
716
+ const shape = segment.parts
717
+ for (let i = 0; i < shape.length; i++) {
718
+ const part = shape[i]!
719
+ if (part.t === "lit") {
720
+ text += part.v
721
+ continue
722
+ }
723
+ const value = fillValue(params, part.name)
724
+ if (value === undefined) {
725
+ missing = noted(missing, part.name)
726
+ continue
727
+ }
728
+ const encoded = spell(value)
729
+ if (encoded === undefined) {
730
+ invalid = noted(invalid, part.name)
731
+ continue
732
+ }
733
+ // matchMixedSegment ends a parameter at the first occurrence of the literal after it, one
734
+ // character in or later. A value holding that literal earlier would come back as different
735
+ // params. A literal in the last place is the segment's tail, which is anchored to its end.
736
+ const next = shape[i + 1]
737
+ // With no separating literal the matcher gives this parameter exactly one raw character.
738
+ // Splitting a percent-escape here would also make the captured parameters malformed.
739
+ if (next?.t === "param" && encoded.length !== 1) invalid = noted(invalid, part.name)
740
+ else if (next?.t === "lit" && shape[i + 2] !== undefined) {
741
+ if ((encoded + next.v).indexOf(next.v, 1) !== encoded.length)
742
+ invalid = noted(invalid, part.name)
743
+ }
744
+ text += encoded
745
+ }
746
+ path += `/${text}`
747
+ }
748
+ }
749
+ return missing !== undefined || invalid !== undefined
750
+ ? { ok: false, missing: missing ?? [], invalid: invalid ?? [] }
751
+ : { ok: true, path: path === "" ? "/" : path }
752
+ }
753
+
754
+ /** A param's value when it is a non-empty string the caller gave, not one inherited from the prototype. */
755
+ function fillValue(
756
+ params: Readonly<Record<string, string | undefined>>,
757
+ name: string,
758
+ ): string | undefined {
759
+ const value = Object.hasOwn(params, name) ? params[name] : undefined
760
+ return typeof value === "string" && value !== "" ? value : undefined
761
+ }
762
+
763
+ /** `value` spelled as one path segment, or undefined when no segment can carry it. */
764
+ function spell(value: string): string | undefined {
765
+ try {
766
+ return encodePathSegment(value)
767
+ } catch {
768
+ return undefined
769
+ }
770
+ }
771
+
772
+ /** `list` with `name` in it once, made when there was none. */
773
+ function noted(list: string[] | undefined, name: string): string[] {
774
+ if (list === undefined) return [name]
775
+ if (!list.includes(name)) list.push(name)
776
+ return list
777
+ }
778
+
652
779
  /** Match one compiled pattern and return decoded captures. The caller decides cross-pattern order. */
653
780
  export function matchRoutePattern(
654
781
  compiled: CompiledRoutePattern,
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Is `value` a destination that stays on this origin?
3
+ *
4
+ * True only for an absolute path: one leading `/`, never `//` (protocol-relative → another origin),
5
+ * and free of any character a URL parser turns into an origin escape. A leading `/` alone is not
6
+ * enough: under a special scheme `\` parses as `/`, and tab/CR/LF are STRIPPED before parsing, so
7
+ * `/\evil.example` and `/<TAB>/evil.example` both resolve to the external host `evil.example` while
8
+ * passing a `//` test. Rejecting CR/LF here also keeps a validated destination out of the
9
+ * response-splitting sink it usually flows into.
10
+ *
11
+ * One implementation, because every caller is the same open-redirect gate: `redirect()` and
12
+ * `rawHref()` in `@nifrajs/web`, the `redirectTo` of the `@nifrajs/auth` and `@nifrajs/better-auth` guards. Three
13
+ * copies of a security predicate is three chances for one of them to be hardened alone.
14
+ *
15
+ * It answers about a PATH, not a URL: an absolute `https://this.host/x` is false even for the
16
+ * current origin, because the whole point is that the value never got to name a host.
17
+ *
18
+ * It sits in a leaf with no imports, so browser code (`rawHref` in `@nifrajs/web`) can use it without
19
+ * the server entry; `@nifrajs/core/server` and `@nifrajs/core/pattern` both export it.
20
+ */
21
+ export function isSameOriginPath(value: string): boolean {
22
+ if (!value.startsWith("/") || value.startsWith("//")) return false
23
+ // Scanned by character code rather than matched by regex: no control characters have to be written
24
+ // into a pattern, and the check is a single pass over a short string.
25
+ for (let i = 1; i < value.length; i++) {
26
+ const code = value.charCodeAt(i)
27
+ if (code === 0x5c /* \ */ || code <= 0x1f || code === 0x7f) return false
28
+ }
29
+ // `/.//host` and `/a/..//host` (or their `%2e` forms) resolve to the path `//host`, which any
30
+ // relative reference later built from that path - a client router's redirect - reads as a host.
31
+ // A path-absolute reference against a valid base always parses, so this cannot throw.
32
+ return (
33
+ !(value.includes(".") || value.includes("%")) ||
34
+ !new URL(value, "http://n.invalid").pathname.startsWith("//")
35
+ )
36
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `serveStatic()` for Bun's `listen({ static })`: files on disk under URL prefixes, with the same
3
+ * mounts `@nifrajs/node` and `@nifrajs/deno` take. Which request a mount takes and how it answers is
4
+ * `./static-file.ts`; this module only reads the file, through node:fs, which Bun runs natively.
5
+ *
6
+ * A subpath rather than part of `listen()`, so an app that serves no files bundles no file reader.
7
+ */
8
+ import { createHash } from "node:crypto"
9
+ import { type FileHandle, realpath } from "node:fs/promises"
10
+ import { isAbsolute, relative, resolve, sep } from "node:path"
11
+ import { Readable } from "node:stream"
12
+ import { fileURLToPath } from "node:url"
13
+ import { openResolved } from "./open-resolved.ts"
14
+ import { parseByteRange } from "./range.ts"
15
+ import { pathnameOf } from "./server/http.ts"
16
+ import {
17
+ CONTENT_SNAPSHOT_MAX,
18
+ contentTags,
19
+ isHiddenPath,
20
+ matchStatic,
21
+ planStatic,
22
+ type ServeStaticOptions,
23
+ type StaticMount,
24
+ staticMountsOf,
25
+ } from "./static-file.ts"
26
+
27
+ export type { HashedFiles, ServeStaticOptions } from "./static-file.ts"
28
+ export { IMMUTABLE, ONE_DAY } from "./static-file.ts"
29
+
30
+ // Bun.serve drops the declared length of any stream body and sends it chunked, and a Bun.file body
31
+ // would need this descriptor open for a send nothing reports the end of. So on Bun a body up to 1 MiB
32
+ // is read whole: a slow client then holds ~1 MB, against ~0.6 MB through Bun's stream sink.
33
+ const BUFFERED_BODY_MAX = typeof Bun === "undefined" ? CONTENT_SNAPSHOT_MAX : 1024 * 1024
34
+
35
+ // One per process: the tag of a file served from any mount is remembered by its real path.
36
+ const TAGS = contentTags(createHash)
37
+
38
+ function rootOf(dir: string | URL): string {
39
+ return resolve(typeof dir === "string" ? dir : fileURLToPath(dir))
40
+ }
41
+
42
+ /** The host of an absolute URL, as `new URL(url).host` would give it, without parsing the rest. */
43
+ function hostOf(url: string): string {
44
+ const start = url.indexOf("//") + 2
45
+ let end = start
46
+ while (end < url.length) {
47
+ const code = url.charCodeAt(end)
48
+ if (code === 47 /* '/' */ || code === 63 /* '?' */ || code === 35 /* '#' */) break
49
+ end++
50
+ }
51
+ return url.slice(start, end)
52
+ }
53
+
54
+ function refusalOf(status: 400 | 403 | 404): Response {
55
+ return new Response(status === 400 ? "Bad Request" : status === 403 ? "Forbidden" : "Not Found", {
56
+ status,
57
+ })
58
+ }
59
+
60
+ /** `length` bytes of `handle` from `start`; fewer when the file shrank since its stat. */
61
+ async function readPinned(
62
+ handle: FileHandle,
63
+ start: number,
64
+ length: number,
65
+ ): Promise<Uint8Array<ArrayBuffer>> {
66
+ const bytes = new Uint8Array(length)
67
+ let offset = 0
68
+ while (offset < length) {
69
+ const { bytesRead } = await handle.read(bytes, offset, length - offset, start + offset)
70
+ if (bytesRead === 0) break
71
+ offset += bytesRead
72
+ }
73
+ return offset === length ? bytes : bytes.subarray(0, offset)
74
+ }
75
+
76
+ interface BunMount extends StaticMount {
77
+ /** The directory's real path, resolved on first use and again after a miss, so one made later serves. */
78
+ realRoot: Promise<string | undefined> | undefined
79
+ }
80
+
81
+ /**
82
+ * The answer for `rel` under `mount`, or `undefined` when the mount falls through on a file it cannot
83
+ * serve.
84
+ */
85
+ async function readStatic(
86
+ mount: BunMount,
87
+ rel: string,
88
+ immutable: boolean,
89
+ request: Request,
90
+ ): Promise<Response | undefined> {
91
+ const missing = (status: 403 | 404 = 404): Response | undefined =>
92
+ mount.fallthrough ? undefined : refusalOf(status)
93
+ const file = resolve(mount.root, rel)
94
+ // A drive or root in the decoded name (`C:/x` on Windows) leaves the directory without a `..`.
95
+ const contained = relative(mount.root, file)
96
+ if (contained.startsWith("..") || isAbsolute(contained)) return missing(403)
97
+ // Containment is checked on the RESOLVED name and that name is what opens, so a link swapped inside
98
+ // the tree after the check cannot redirect the read; openResolved refuses a name that gained a link.
99
+ mount.realRoot ??= realpath(mount.root).catch(() => undefined)
100
+ const [realRoot, realFile] = await Promise.all([
101
+ mount.realRoot,
102
+ realpath(file).catch(() => undefined),
103
+ ])
104
+ if (realRoot === undefined) mount.realRoot = undefined
105
+ if (
106
+ realRoot === undefined ||
107
+ realFile === undefined ||
108
+ (realFile !== realRoot && !realFile.startsWith(realRoot + sep))
109
+ ) {
110
+ return missing()
111
+ }
112
+ // A visible name linking to a dotfile inside the directory answers as a missing file does.
113
+ if (
114
+ mount.denyDotfiles &&
115
+ realFile !== realRoot &&
116
+ isHiddenPath(realFile.slice(realRoot.length + 1))
117
+ ) {
118
+ return missing()
119
+ }
120
+ let handle: FileHandle | undefined
121
+ try {
122
+ try {
123
+ handle = await openResolved(realFile)
124
+ } catch {
125
+ return missing()
126
+ }
127
+ const opened = handle
128
+ const stat = await opened.stat()
129
+ if (!stat.isFile()) return missing()
130
+ const method = request.method === "HEAD" ? "HEAD" : "GET"
131
+ let whole: Uint8Array<ArrayBuffer> | undefined
132
+ const plan = await planStatic(
133
+ mount,
134
+ immutable,
135
+ { method, header: (name) => request.headers.get(name) },
136
+ {
137
+ path: realFile,
138
+ version: stat,
139
+ readWhole: async () => {
140
+ whole = await readPinned(opened, 0, stat.size)
141
+ return whole
142
+ },
143
+ restat: () => opened.stat(),
144
+ tags: TAGS,
145
+ parseRange: parseByteRange,
146
+ },
147
+ )
148
+ if (!plan.body) return new Response(null, { status: plan.status, headers: plan.headers })
149
+ const { start, length, headers } = plan
150
+ if (length <= BUFFERED_BODY_MAX) {
151
+ const body =
152
+ whole === undefined
153
+ ? await readPinned(opened, start, length)
154
+ : whole.subarray(start, start + length)
155
+ headers["content-length"] = String(body.byteLength)
156
+ return new Response(body, { status: plan.status, headers })
157
+ }
158
+ const stream = opened.createReadStream({ start, end: start + length - 1 })
159
+ // createReadStream owns closing the descriptor after EOF or abort.
160
+ handle = undefined
161
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: node:stream/web's ReadableStream is the global one on Bun; only the declarations differ.
162
+ const body = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>
163
+ return new Response(body, { status: plan.status, headers })
164
+ } catch {
165
+ // Unreadable under the prefix: a 404 (not an SSR 404 page), unless the mount falls through.
166
+ return missing()
167
+ } finally {
168
+ try {
169
+ await handle?.close()
170
+ } catch {
171
+ // A close that fails changes nothing about the answer already chosen.
172
+ }
173
+ }
174
+ }
175
+
176
+ function serveFrom(
177
+ mounts: readonly BunMount[],
178
+ from: number,
179
+ request: Request,
180
+ path: string,
181
+ host: () => string,
182
+ ): Promise<Response | undefined> | undefined {
183
+ for (let i = from; i < mounts.length; i++) {
184
+ const mount = mounts[i]
185
+ if (mount === undefined) break
186
+ const matched = matchStatic(mount, path, host)
187
+ if (matched === "pass") continue
188
+ // matchStatic passes what a falling-through mount refuses, so a status here is always answered.
189
+ if ("status" in matched) return Promise.resolve(refusalOf(matched.status))
190
+ return readStatic(mount, matched.rel, matched.immutable, request).then(
191
+ (response) => response ?? serveFrom(mounts, i + 1, request, path, host),
192
+ )
193
+ }
194
+ return undefined
195
+ }
196
+
197
+ /**
198
+ * The `static` handler for files on disk, for Bun's `listen()`: each mount serves a directory under a
199
+ * URL prefix, and the first mount whose prefix (and `files`, when set) takes a GET or HEAD path
200
+ * answers it. A request no mount takes returns `undefined` synchronously and reaches the app. The
201
+ * same mounts work with `@nifrajs/node`'s and `@nifrajs/deno`'s `serveStatic`.
202
+ *
203
+ * Files up to 256 KiB carry a strong `ETag` from the exact bytes served. Larger files stream with no
204
+ * tag and a weak date, so an `If-Range` gets the whole file rather than a range spliced from two
205
+ * versions.
206
+ *
207
+ * @example
208
+ * ```ts
209
+ * import { serveStatic } from "@nifrajs/core/serve-static"
210
+ *
211
+ * app.listen(3000, { static: serveStatic({ dir: new URL("./assets/", import.meta.url) }) })
212
+ * ```
213
+ */
214
+ export function serveStatic(
215
+ mounts: ServeStaticOptions | readonly ServeStaticOptions[],
216
+ ): (request: Request) => Promise<Response | undefined> | undefined {
217
+ const states: readonly BunMount[] = staticMountsOf(mounts, rootOf).map((mount) => ({
218
+ ...mount,
219
+ realRoot: undefined,
220
+ }))
221
+ return (request) => {
222
+ const method = request.method
223
+ if (method !== "GET" && method !== "HEAD") return undefined
224
+ const url = request.url
225
+ return serveFrom(states, 0, request, pathnameOf(url), () => hostOf(url))
226
+ }
227
+ }