@nifrajs/core 5.7.0 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +9 -4
  3. package/dist/assurance.d.ts +26 -1
  4. package/dist/assurance.d.ts.map +1 -1
  5. package/dist/assurance.js +43 -11
  6. package/dist/assurance.js.map +1 -1
  7. package/dist/canonical-json.d.ts +11 -0
  8. package/dist/canonical-json.d.ts.map +1 -0
  9. package/dist/canonical-json.js +46 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/capabilities.d.ts.map +1 -1
  12. package/dist/capabilities.js +1 -8
  13. package/dist/capabilities.js.map +1 -1
  14. package/dist/data.d.ts.map +1 -1
  15. package/dist/data.js +1 -8
  16. package/dist/data.js.map +1 -1
  17. package/dist/diff.d.ts.map +1 -1
  18. package/dist/diff.js +2 -12
  19. package/dist/diff.js.map +1 -1
  20. package/dist/durable-adapters.d.ts +2 -0
  21. package/dist/durable-adapters.d.ts.map +1 -1
  22. package/dist/durable-adapters.js +41 -49
  23. package/dist/durable-adapters.js.map +1 -1
  24. package/dist/durable-saga.d.ts.map +1 -1
  25. package/dist/durable-saga.js +14 -20
  26. package/dist/durable-saga.js.map +1 -1
  27. package/dist/evidence.d.ts.map +1 -1
  28. package/dist/evidence.js +14 -68
  29. package/dist/evidence.js.map +1 -1
  30. package/dist/idempotency.d.ts.map +1 -1
  31. package/dist/idempotency.js +60 -23
  32. package/dist/idempotency.js.map +1 -1
  33. package/dist/index.d.ts +3 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +3 -2
  36. package/dist/index.js.map +1 -1
  37. package/dist/internal/control-character.d.ts +3 -0
  38. package/dist/internal/control-character.d.ts.map +1 -0
  39. package/dist/internal/control-character.js +10 -0
  40. package/dist/internal/control-character.js.map +1 -0
  41. package/dist/internal/durable-shared.d.ts.map +1 -1
  42. package/dist/internal/durable-shared.js +4 -1
  43. package/dist/internal/durable-shared.js.map +1 -1
  44. package/dist/internal/hex.d.ts +5 -0
  45. package/dist/internal/hex.d.ts.map +1 -0
  46. package/dist/internal/hex.js +14 -0
  47. package/dist/internal/hex.js.map +1 -0
  48. package/dist/internal/record.d.ts +3 -0
  49. package/dist/internal/record.d.ts.map +1 -0
  50. package/dist/internal/record.js +5 -0
  51. package/dist/internal/record.js.map +1 -0
  52. package/dist/internal/route-compiler.d.ts.map +1 -1
  53. package/dist/internal/route-compiler.js +2 -2
  54. package/dist/internal/route-compiler.js.map +1 -1
  55. package/dist/internal/route-execution.d.ts +4 -4
  56. package/dist/internal/route-execution.d.ts.map +1 -1
  57. package/dist/internal/route-execution.js +4 -0
  58. package/dist/internal/route-execution.js.map +1 -1
  59. package/dist/internal/route-lanes.d.ts +4 -3
  60. package/dist/internal/route-lanes.d.ts.map +1 -1
  61. package/dist/internal/route-lanes.js +16 -33
  62. package/dist/internal/route-lanes.js.map +1 -1
  63. package/dist/internal/schema-parts.d.ts +13 -0
  64. package/dist/internal/schema-parts.d.ts.map +1 -0
  65. package/dist/internal/schema-parts.js +15 -0
  66. package/dist/internal/schema-parts.js.map +1 -0
  67. package/dist/ledger.d.ts.map +1 -1
  68. package/dist/ledger.js +2 -14
  69. package/dist/ledger.js.map +1 -1
  70. package/dist/manifest.d.ts.map +1 -1
  71. package/dist/manifest.js +10 -37
  72. package/dist/manifest.js.map +1 -1
  73. package/dist/open-resolved.d.ts +18 -0
  74. package/dist/open-resolved.d.ts.map +1 -0
  75. package/dist/open-resolved.js +62 -0
  76. package/dist/open-resolved.js.map +1 -0
  77. package/dist/range.js +1 -1
  78. package/dist/range.js.map +1 -1
  79. package/dist/serve-static.d.ts +22 -0
  80. package/dist/serve-static.d.ts.map +1 -0
  81. package/dist/serve-static.js +187 -0
  82. package/dist/serve-static.js.map +1 -0
  83. package/dist/server/bare-error-lane.d.ts +2 -1
  84. package/dist/server/bare-error-lane.d.ts.map +1 -1
  85. package/dist/server/bare-error-lane.js +12 -3
  86. package/dist/server/bare-error-lane.js.map +1 -1
  87. package/dist/server/base64-answers.d.ts.map +1 -1
  88. package/dist/server/base64-answers.js +1 -4
  89. package/dist/server/base64-answers.js.map +1 -1
  90. package/dist/server/body-lane.d.ts.map +1 -1
  91. package/dist/server/body-lane.js +43 -38
  92. package/dist/server/body-lane.js.map +1 -1
  93. package/dist/server/body.d.ts.map +1 -1
  94. package/dist/server/body.js +4 -1
  95. package/dist/server/body.js.map +1 -1
  96. package/dist/server/bridge-markers.d.ts +1 -1
  97. package/dist/server/bridge-markers.js +1 -1
  98. package/dist/server/cookies.d.ts.map +1 -1
  99. package/dist/server/cookies.js +4 -1
  100. package/dist/server/cookies.js.map +1 -1
  101. package/dist/server/core-copies.js +1 -1
  102. package/dist/server/http.d.ts +5 -0
  103. package/dist/server/http.d.ts.map +1 -1
  104. package/dist/server/http.js +13 -0
  105. package/dist/server/http.js.map +1 -1
  106. package/dist/server/idempotency-lane.d.ts.map +1 -1
  107. package/dist/server/idempotency-lane.js +2 -0
  108. package/dist/server/idempotency-lane.js.map +1 -1
  109. package/dist/server/idle-timeout.d.ts +39 -0
  110. package/dist/server/idle-timeout.d.ts.map +1 -0
  111. package/dist/server/idle-timeout.js +95 -0
  112. package/dist/server/idle-timeout.js.map +1 -0
  113. package/dist/server/json-shared.d.ts +23 -0
  114. package/dist/server/json-shared.d.ts.map +1 -0
  115. package/dist/server/json-shared.js +33 -0
  116. package/dist/server/json-shared.js.map +1 -0
  117. package/dist/server/json-text.d.ts +1 -1
  118. package/dist/server/json-text.js +1 -1
  119. package/dist/server/node-outcome.js +1 -1
  120. package/dist/server/node-outcome.js.map +1 -1
  121. package/dist/server/request-context.d.ts +22 -1
  122. package/dist/server/request-context.d.ts.map +1 -1
  123. package/dist/server/request-context.js +44 -4
  124. package/dist/server/request-context.js.map +1 -1
  125. package/dist/server/request-local.d.ts +44 -0
  126. package/dist/server/request-local.d.ts.map +1 -0
  127. package/dist/server/request-local.js +91 -0
  128. package/dist/server/request-local.js.map +1 -0
  129. package/dist/server/respond.js +1 -1
  130. package/dist/server/respond.js.map +1 -1
  131. package/dist/server/response-contract-lane.d.ts +10 -2
  132. package/dist/server/response-contract-lane.d.ts.map +1 -1
  133. package/dist/server/response-contract-lane.js +93 -64
  134. package/dist/server/response-contract-lane.js.map +1 -1
  135. package/dist/server/runtime-core.d.ts +7 -4
  136. package/dist/server/runtime-core.d.ts.map +1 -1
  137. package/dist/server/runtime-core.js +9 -7
  138. package/dist/server/runtime-core.js.map +1 -1
  139. package/dist/server/server-types.d.ts +27 -0
  140. package/dist/server/server-types.d.ts.map +1 -1
  141. package/dist/server/server.d.ts +59 -59
  142. package/dist/server/server.d.ts.map +1 -1
  143. package/dist/server/server.js +869 -512
  144. package/dist/server/server.js.map +1 -1
  145. package/dist/server/transport-lane.d.ts.map +1 -1
  146. package/dist/server/transport-lane.js +2 -0
  147. package/dist/server/transport-lane.js.map +1 -1
  148. package/dist/server/webhook.js +1 -1
  149. package/dist/server/webhook.js.map +1 -1
  150. package/dist/server/websocket.d.ts +3 -1
  151. package/dist/server/websocket.d.ts.map +1 -1
  152. package/dist/server/websocket.js +14 -1
  153. package/dist/server/websocket.js.map +1 -1
  154. package/dist/server/ws-adapter-view.d.ts +54 -0
  155. package/dist/server/ws-adapter-view.d.ts.map +1 -0
  156. package/dist/server/ws-adapter-view.js +7 -0
  157. package/dist/server/ws-adapter-view.js.map +1 -0
  158. package/dist/server.d.ts +2 -1
  159. package/dist/server.d.ts.map +1 -1
  160. package/dist/server.js +2 -1
  161. package/dist/server.js.map +1 -1
  162. package/dist/single-copy.d.ts.map +1 -1
  163. package/dist/single-copy.js +1 -1
  164. package/dist/single-copy.js.map +1 -1
  165. package/dist/static-file.d.ts +156 -4
  166. package/dist/static-file.d.ts.map +1 -1
  167. package/dist/static-file.js +198 -4
  168. package/dist/static-file.js.map +1 -1
  169. package/dist/wire.d.ts.map +1 -1
  170. package/dist/wire.js +1 -3
  171. package/dist/wire.js.map +1 -1
  172. package/package.json +16 -1
  173. package/src/assurance.ts +66 -16
  174. package/src/canonical-json.ts +43 -0
  175. package/src/capabilities.ts +1 -8
  176. package/src/data.ts +2 -8
  177. package/src/diff.ts +2 -12
  178. package/src/durable-adapters.ts +53 -50
  179. package/src/durable-saga.ts +30 -23
  180. package/src/evidence.ts +25 -90
  181. package/src/idempotency.ts +62 -20
  182. package/src/index.ts +13 -2
  183. package/src/internal/control-character.ts +8 -0
  184. package/src/internal/durable-shared.ts +4 -1
  185. package/src/internal/hex.ts +14 -0
  186. package/src/internal/record.ts +4 -0
  187. package/src/internal/route-compiler.ts +2 -2
  188. package/src/internal/route-execution.ts +7 -20
  189. package/src/internal/route-lanes.ts +20 -44
  190. package/src/internal/schema-parts.ts +19 -0
  191. package/src/ledger.ts +2 -16
  192. package/src/manifest.ts +10 -38
  193. package/src/open-resolved.ts +64 -0
  194. package/src/range.ts +1 -1
  195. package/src/serve-static.ts +227 -0
  196. package/src/server/bare-error-lane.ts +11 -2
  197. package/src/server/base64-answers.ts +2 -6
  198. package/src/server/body-lane.ts +58 -51
  199. package/src/server/body.ts +4 -1
  200. package/src/server/bridge-markers.ts +1 -1
  201. package/src/server/cookies.ts +4 -1
  202. package/src/server/core-copies.ts +1 -1
  203. package/src/server/http.ts +12 -0
  204. package/src/server/idempotency-lane.ts +2 -0
  205. package/src/server/idle-timeout.ts +107 -0
  206. package/src/server/json-shared.ts +38 -0
  207. package/src/server/json-text.ts +1 -1
  208. package/src/server/node-outcome.ts +1 -1
  209. package/src/server/request-context.ts +62 -3
  210. package/src/server/request-local.ts +109 -0
  211. package/src/server/respond.ts +1 -1
  212. package/src/server/response-contract-lane.ts +112 -65
  213. package/src/server/runtime-core.ts +10 -8
  214. package/src/server/server-types.ts +27 -0
  215. package/src/server/server.ts +1230 -800
  216. package/src/server/transport-lane.ts +2 -0
  217. package/src/server/webhook.ts +1 -1
  218. package/src/server/websocket.ts +18 -1
  219. package/src/server/ws-adapter-view.ts +63 -0
  220. package/src/server.ts +12 -1
  221. package/src/single-copy.ts +1 -3
  222. package/src/static-file.ts +350 -4
  223. package/src/wire.ts +2 -4
package/src/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,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
+ }
@@ -31,7 +31,8 @@ export type ErrorLogDetail = "full" | "message" | "none"
31
31
  * - a `Response` or a `status(...)` is control flow - the app answering directly, or plain data.
32
32
  * Either renders through the same `finalize` a RETURNED one takes, with the request's `c.set`,
33
33
  * so a thrown one means what the return means: a `Response` ships as built, plus the cookies the
34
- * request queued (the set-session-then-`throw redirect()` pattern);
34
+ * request queued (the set-session-then-`throw redirect()` pattern); one whose body cannot render
35
+ * is a fault like any other, never a rejected request;
35
36
  * - a request body the app's own `c.req.json()` or `c.req.formData()` could not parse is the
36
37
  * client's 400 (`invalid_json`, `invalid_form`);
37
38
  * - anything else is a real fault: logged once, then a bodyless 500 (never the caller's error text).
@@ -48,7 +49,15 @@ export function renderBareError<T>(
48
49
  logError: (err: unknown, ctx: RawContext) => void,
49
50
  wrapResponse: (response: ResponseResult) => T,
50
51
  ): T {
51
- if (err instanceof Response || isResponseResult(err)) return finalize(err, responseSet(ctx), ctx)
52
+ if (err instanceof Response || isResponseResult(err)) {
53
+ try {
54
+ return finalize(err, responseSet(ctx), ctx)
55
+ } catch (renderErr) {
56
+ // A thrown answer whose body cannot render (a `toJSON` that throws) is a fault, not control flow.
57
+ logError(renderErr, ctx)
58
+ return wrapResponse(plainError(500, "internal_error"))
59
+ }
60
+ }
52
61
  const clientCode = clientBodyErrorCode(err)
53
62
  if (clientCode !== undefined) return wrapResponse(plainError(400, clientCode))
54
63
  logError(err, ctx)
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { tagStandIn } from "../internal/hook-origins.ts"
12
12
  import type { AnswerStages, JsonReplyContext } from "./answer-step.ts"
13
+ import { define } from "./json-shared.ts"
13
14
  import { isResponseResult, status } from "./runtime-core.ts"
14
15
 
15
16
  // Deeper than any real payload.
@@ -24,11 +25,6 @@ function base64(bytes: Uint8Array): string {
24
25
  return btoa(binary)
25
26
  }
26
27
 
27
- function define(out: object, key: string, value: unknown): void {
28
- // `out[key] = value` for "__proto__" would replace the prototype instead of adding the field.
29
- Object.defineProperty(out, key, { value, enumerable: true, writable: true, configurable: true })
30
- }
31
-
32
28
  /**
33
29
  * `value` with each `Uint8Array` as base64, copying only the containers above one. A container already
34
30
  * on the walk's path is left as it is, so a cycle costs one step, as it does `JSON.stringify`, which
@@ -52,7 +48,7 @@ function encoded(value: unknown, depth: number, path: Set<object>): unknown {
52
48
  }
53
49
  return out ?? value
54
50
  }
55
- let out: object | undefined
51
+ let out: Record<string, unknown> | undefined
56
52
  for (const key of Object.keys(value)) {
57
53
  const item: unknown = Reflect.get(value, key)
58
54
  const next = encoded(item, depth + 1, path)
@@ -22,16 +22,25 @@ type JsonErrorContinuation<T> = (error: unknown) => T | Promise<T>
22
22
 
23
23
  type FramedJsonSource = RequestSource & {
24
24
  readonly bytes?: () => Promise<Uint8Array>
25
+ readonly text?: () => Promise<string>
25
26
  readonly jsonWithByteLength?: () => Promise<{
26
27
  readonly value: unknown
27
28
  readonly byteLength: number
28
29
  }>
29
30
  }
30
31
 
32
+ // Every lane calls `onResult` outside its parse guard: a throw from the continuation is the app's,
33
+ // and must never come back to the client as `invalid_json`.
31
34
  const INVALID_JSON = (): ResponseResult => plainError(400, "invalid_json")
35
+ // A parameter, not a closure over the caller's `settle`: capturing it there costs every call a scope object.
36
+ const fromBuffer =
37
+ <T>(settle: (bytes: Uint8Array) => T) =>
38
+ (buffer: ArrayBuffer): T =>
39
+ settle(new Uint8Array(buffer))
32
40
 
33
41
  // `Deno.serve`'s own `Request.json()` keeps a leading UTF-8 byte order mark that its `text()` and every
34
- // other runtime's `json()` drop, so it refuses a body they parse. Deno decodes the bytes itself below.
42
+ // other runtime's `json()` drop, so it refuses a body they parse. Deno uses `text()` for an
43
+ // untouched, runtime-framed request, or decodes the checked bytes below for other sources.
35
44
  const nativeJsonKeepsBom = "Deno" in globalThis
36
45
 
37
46
  /** Read a JSON source with the declared-length precheck, delivered-byte recheck, streaming cap, and
@@ -76,17 +85,39 @@ export function readBoundedJsonSource<T>(
76
85
  ? Promise.resolve(plainError(413, "payload_too_large"))
77
86
  : Promise.resolve(plainError(413, "payload_too_large")).then(onResult)
78
87
 
88
+ // A trusted runtime frame makes Content-Length the byte bound. Wrapped or untrusted sources
89
+ // must keep the delivered-byte check below, and chunked bodies must keep the streaming cap.
90
+ if (
91
+ nativeJsonKeepsBom &&
92
+ raw === req &&
93
+ hasTrustedBodyFraming(req) &&
94
+ raw.text !== undefined &&
95
+ headerOf(req, "transfer-encoding") === null
96
+ ) {
97
+ return raw.text().then(
98
+ (text) => {
99
+ let parsed: unknown
100
+ try {
101
+ parsed = parseJsonGuarded(text, protoPoisoning)
102
+ } catch {
103
+ parsed = INVALID_JSON()
104
+ }
105
+ return onResult === undefined ? parsed : onResult(parsed)
106
+ },
107
+ () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
108
+ )
109
+ }
110
+
79
111
  if (hasTrustedBodyFraming(req) && !nativeJsonKeepsBom) {
80
112
  return raw.json().then(
81
113
  (parsed) => {
114
+ let guarded: unknown
82
115
  try {
83
- const guarded = guardParsedValue(parsed, protoPoisoning)
84
- return onResult === undefined ? guarded : onResult(guarded)
116
+ guarded = guardParsedValue(parsed, protoPoisoning)
85
117
  } catch {
86
- return onResult === undefined
87
- ? plainError(400, "invalid_json")
88
- : onResult(plainError(400, "invalid_json"))
118
+ guarded = INVALID_JSON()
89
119
  }
120
+ return onResult === undefined ? guarded : onResult(guarded)
90
121
  },
91
122
  () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
92
123
  )
@@ -102,60 +133,36 @@ export function readBoundedJsonSource<T>(
102
133
  ? plainError(413, "payload_too_large")
103
134
  : onResult(plainError(413, "payload_too_large"))
104
135
  }
136
+ let guarded: unknown
105
137
  try {
106
- const guarded = guardParsedValue(value, protoPoisoning)
107
- return onResult === undefined ? guarded : onResult(guarded)
138
+ guarded = guardParsedValue(value, protoPoisoning)
108
139
  } catch {
109
- return onResult === undefined
110
- ? plainError(400, "invalid_json")
111
- : onResult(plainError(400, "invalid_json"))
140
+ guarded = INVALID_JSON()
112
141
  }
142
+ return onResult === undefined ? guarded : onResult(guarded)
113
143
  },
114
144
  () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
115
145
  )
116
146
  }
117
147
 
118
- const bytesReader = raw.bytes
119
- if (bytesReader !== undefined) {
120
- return bytesReader.call(raw).then(
121
- (bytes) => {
122
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
123
- return onResult === undefined
124
- ? plainError(413, "payload_too_large")
125
- : onResult(plainError(413, "payload_too_large"))
126
- }
127
- try {
128
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
129
- return onResult === undefined ? parsed : onResult(parsed)
130
- } catch {
131
- return onResult === undefined
132
- ? plainError(400, "invalid_json")
133
- : onResult(plainError(400, "invalid_json"))
134
- }
135
- },
136
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
137
- )
148
+ const settle = (bytes: Uint8Array) => {
149
+ if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
150
+ return onResult === undefined
151
+ ? plainError(413, "payload_too_large")
152
+ : onResult(plainError(413, "payload_too_large"))
153
+ }
154
+ let parsed: unknown
155
+ try {
156
+ parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
157
+ } catch {
158
+ parsed = INVALID_JSON()
159
+ }
160
+ return onResult === undefined ? parsed : onResult(parsed)
138
161
  }
139
-
140
- return raw.arrayBuffer().then(
141
- (buffer) => {
142
- const bytes = new Uint8Array(buffer)
143
- if (bytes.byteLength > length || bytes.byteLength > maxBytes) {
144
- return onResult === undefined
145
- ? plainError(413, "payload_too_large")
146
- : onResult(plainError(413, "payload_too_large"))
147
- }
148
- try {
149
- const parsed = parseJsonGuarded(TEXT_DECODER.decode(bytes), protoPoisoning)
150
- return onResult === undefined ? parsed : onResult(parsed)
151
- } catch {
152
- return onResult === undefined
153
- ? plainError(400, "invalid_json")
154
- : onResult(plainError(400, "invalid_json"))
155
- }
156
- },
157
- () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON())),
158
- )
162
+ const refuse = () => (onResult === undefined ? INVALID_JSON() : onResult(INVALID_JSON()))
163
+ const bytesReader = raw.bytes
164
+ if (bytesReader !== undefined) return bytesReader.call(raw).then(settle, refuse)
165
+ return raw.arrayBuffer().then(fromBuffer(settle), refuse)
159
166
  }
160
167
  }
161
168
 
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { jsonError } from "./http.ts"
11
+ import { carryToCopy } from "./request-local.ts"
11
12
 
12
13
  interface BodySource {
13
14
  readonly headers: Pick<Headers, "get">
@@ -277,7 +278,9 @@ export function capBodyBytes(request: Request, maxBytes: number): Request {
277
278
  ),
278
279
  duplex: "half",
279
280
  }
280
- return new Request(request, init)
281
+ const capped = new Request(request, init)
282
+ carryToCopy(request, capped)
283
+ return capped
281
284
  }
282
285
 
283
286
  /** First matching own-or-inherited descriptor, so instance shadows and prototype accessors both resolve. */
@@ -4,7 +4,7 @@
4
4
  * Each entry names a symbol looked up with `Symbol.for(...)`, so the two packages agree ONLY by the
5
5
  * string. A rename on one side and not the other is a silent protocol break - no type error, just a
6
6
  * symbol that never matches and a body that stops taking the direct-write lane. This module is the
7
- * single source: core defines its own symbols from these literals, and `scripts/gen-node-outcome.ts`
7
+ * single source: core defines its own symbols from these literals, and `scripts/gen-vendored.ts`
8
8
  * copies the map verbatim into the adapter's generated contract, so @nifrajs/node reads the identical
9
9
  * strings without a runtime dependency on core (the same deployment shape the generated outcome type
10
10
  * preserves).
@@ -247,7 +247,10 @@ const toBase64Url = (buf: ArrayBuffer): string => {
247
247
  let bin = ""
248
248
  const bytes = new Uint8Array(buf)
249
249
  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!)
250
- return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
250
+ return btoa(bin)
251
+ .replace(/\+/g, "-")
252
+ .replace(/\//g, "_")
253
+ .replace(/={1,2}$/, "")
251
254
  }
252
255
 
253
256
  const BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
@@ -26,7 +26,7 @@ if (!copies.includes(here) && copies.push(here) > 1) {
26
26
 
27
27
  /** This copy's version: a literal like the package root's `VERSION` (no fs on the edge), which
28
28
  * `scripts/version.ts` rewrites on every release and `check:publish` asserts. */
29
- const COPY_VERSION = "5.7.0"
29
+ const COPY_VERSION = "5.9.0"
30
30
 
31
31
  /** Record this copy's version beside its URL. Called by each `server()` behind the development guard,
32
32
  * so a production bundle drops it with the version; a copy that built a server has recorded one. */