@nifrajs/core 5.9.0 → 5.11.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 (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/edge-kit.d.ts +2 -1
  3. package/dist/edge-kit.d.ts.map +1 -1
  4. package/dist/edge-kit.js +2 -1
  5. package/dist/edge-kit.js.map +1 -1
  6. package/dist/guard-deny.d.ts +38 -0
  7. package/dist/guard-deny.d.ts.map +1 -0
  8. package/dist/guard-deny.js +43 -0
  9. package/dist/guard-deny.js.map +1 -0
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/router/path-codec.d.ts +22 -0
  15. package/dist/router/path-codec.d.ts.map +1 -0
  16. package/dist/router/path-codec.js +87 -0
  17. package/dist/router/path-codec.js.map +1 -0
  18. package/dist/router/pattern.d.ts +21 -1
  19. package/dist/router/pattern.d.ts.map +1 -1
  20. package/dist/router/pattern.js +117 -0
  21. package/dist/router/pattern.js.map +1 -1
  22. package/dist/router/same-origin-path.d.ts +22 -0
  23. package/dist/router/same-origin-path.d.ts.map +1 -0
  24. package/dist/router/same-origin-path.js +37 -0
  25. package/dist/router/same-origin-path.js.map +1 -0
  26. package/dist/server/core-copies.js +1 -1
  27. package/dist/server/core-copies.js.map +1 -1
  28. package/dist/server/http.d.ts +1 -18
  29. package/dist/server/http.d.ts.map +1 -1
  30. package/dist/server/http.js +1 -33
  31. package/dist/server/http.js.map +1 -1
  32. package/dist/server/idle-timeout.d.ts +1 -1
  33. package/dist/server/idle-timeout.d.ts.map +1 -1
  34. package/dist/server/idle-timeout.js.map +1 -1
  35. package/dist/server/json-response.d.ts +14 -0
  36. package/dist/server/json-response.d.ts.map +1 -1
  37. package/dist/server/json-response.js +205 -0
  38. package/dist/server/json-response.js.map +1 -1
  39. package/dist/single-copy-register.js +5 -3
  40. package/dist/single-copy-register.js.map +1 -1
  41. package/dist/single-copy.d.ts +1 -1
  42. package/dist/single-copy.d.ts.map +1 -1
  43. package/dist/single-copy.js +49 -2
  44. package/dist/single-copy.js.map +1 -1
  45. package/package.json +6 -1
  46. package/src/edge-kit.ts +2 -1
  47. package/src/guard-deny.ts +61 -0
  48. package/src/index.ts +1 -1
  49. package/src/router/path-codec.ts +93 -0
  50. package/src/router/pattern.ts +127 -0
  51. package/src/router/same-origin-path.ts +36 -0
  52. package/src/server/core-copies.ts +1 -1
  53. package/src/server/http.ts +1 -33
  54. package/src/server/idle-timeout.ts +2 -4
  55. package/src/server/json-response.ts +209 -0
  56. package/src/single-copy-register.ts +5 -3
  57. package/src/single-copy.ts +51 -3
@@ -1,5 +1,214 @@
1
+ import { isRawJson } from "./json-shared.ts"
1
2
  import { retried } from "./json-text.ts"
2
3
 
4
+ /** A JSON value would exceed the caller's UTF-8 byte budget. */
5
+ export class JsonByteLimitError extends RangeError {
6
+ constructor() {
7
+ super("JSON output exceeds the byte limit")
8
+ this.name = "JsonByteLimitError"
9
+ }
10
+ }
11
+
12
+ const OMIT_JSON_VALUE = Symbol("nifra.omitJsonValue")
13
+
14
+ interface JsonBudget {
15
+ readonly maxBytes: number
16
+ usedBytes: number
17
+ }
18
+
19
+ function addJsonBytes(budget: JsonBudget, bytes: number): void {
20
+ if (bytes > budget.maxBytes - budget.usedBytes) throw new JsonByteLimitError()
21
+ budget.usedBytes += bytes
22
+ }
23
+
24
+ /** Count a quoted JSON string without allocating its escaped representation. */
25
+ function countQuotedJsonString(value: string, budget: JsonBudget): void {
26
+ addJsonBytes(budget, 2) // opening and closing quotes
27
+ for (let index = 0; index < value.length; index++) {
28
+ const code = value.charCodeAt(index)
29
+ if (
30
+ code === 0x22 ||
31
+ code === 0x5c ||
32
+ code === 0x08 ||
33
+ code === 0x09 ||
34
+ code === 0x0a ||
35
+ code === 0x0c ||
36
+ code === 0x0d
37
+ ) {
38
+ addJsonBytes(budget, 2)
39
+ } else if (code < 0x20) {
40
+ addJsonBytes(budget, 6) // \\u00xx
41
+ } else if (code >= 0xd800 && code <= 0xdbff) {
42
+ const next = value.charCodeAt(index + 1)
43
+ if (next >= 0xdc00 && next <= 0xdfff) {
44
+ addJsonBytes(budget, 4)
45
+ index++
46
+ } else {
47
+ addJsonBytes(budget, 6) // well-formed JSON escapes a lone surrogate
48
+ }
49
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
50
+ addJsonBytes(budget, 6)
51
+ } else {
52
+ addJsonBytes(budget, code < 0x80 ? 1 : code < 0x800 ? 2 : 3)
53
+ }
54
+ }
55
+ }
56
+
57
+ /** Count the UTF-8 bytes of an already-encoded JSON fragment. */
58
+ function countRawJsonBytes(value: string, budget: JsonBudget): void {
59
+ for (let index = 0; index < value.length; index++) {
60
+ const code = value.charCodeAt(index)
61
+ const next = value.charCodeAt(index + 1)
62
+ if (code >= 0xd800 && code <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) {
63
+ addJsonBytes(budget, 4)
64
+ index++
65
+ } else addJsonBytes(budget, code < 0x80 ? 1 : code < 0x800 ? 2 : 3)
66
+ }
67
+ }
68
+
69
+ function ownJsonValue(value: object, key: string): unknown {
70
+ const toJSON = Reflect.get(value, "toJSON")
71
+ return typeof toJSON === "function" ? Reflect.apply(toJSON, value, [key]) : value
72
+ }
73
+
74
+ function normalizedJsonValue(
75
+ value: unknown,
76
+ key: string,
77
+ budget: JsonBudget,
78
+ ancestors: Set<object>,
79
+ ): unknown | typeof OMIT_JSON_VALUE {
80
+ if (typeof value === "object" && value !== null && isRawJson?.(value) === true) {
81
+ const raw = Reflect.get(value, "rawJSON")
82
+ // biome-ignore lint/plugin/requireSafetyCommentForTypeAssertion: The runtime guard below verifies this optional JSON.rawJSON extension is callable before use.
83
+ const rawJSON = (JSON as JSON & { readonly rawJSON?: (text: string) => unknown }).rawJSON
84
+ if (typeof raw !== "string" || typeof rawJSON !== "function")
85
+ throw new TypeError("Could not serialize JSON.rawJSON")
86
+ countRawJsonBytes(raw, budget)
87
+ return rawJSON(raw)
88
+ }
89
+ if ((typeof value === "object" && value !== null) || typeof value === "function") {
90
+ value = ownJsonValue(value, key)
91
+ } else if (typeof value === "bigint") {
92
+ const boxed = Object(value)
93
+ const toJSON = Reflect.get(boxed, "toJSON")
94
+ if (typeof toJSON === "function") value = Reflect.apply(toJSON, boxed, [key])
95
+ }
96
+
97
+ if (value === null) {
98
+ addJsonBytes(budget, 4)
99
+ return null
100
+ }
101
+ if (typeof value === "string") {
102
+ countQuotedJsonString(value, budget)
103
+ return value
104
+ }
105
+ if (typeof value === "boolean") {
106
+ addJsonBytes(budget, value ? 4 : 5)
107
+ return value
108
+ }
109
+ if (typeof value === "number") {
110
+ const text = JSON.stringify(value)!
111
+ addJsonBytes(budget, text.length) // JSON numbers are ASCII.
112
+ return value
113
+ }
114
+ if (typeof value === "undefined" || typeof value === "function" || typeof value === "symbol")
115
+ return OMIT_JSON_VALUE
116
+ if (typeof value === "bigint") throw new TypeError("Do not know how to serialize a BigInt")
117
+
118
+ // JSON.stringify unwraps primitive wrapper objects before writing them.
119
+ try {
120
+ return normalizedJsonValue(Number.prototype.valueOf.call(value), key, budget, ancestors)
121
+ } catch (error) {
122
+ if (error instanceof JsonByteLimitError) throw error
123
+ }
124
+ try {
125
+ return normalizedJsonValue(String.prototype.valueOf.call(value), key, budget, ancestors)
126
+ } catch (error) {
127
+ if (error instanceof JsonByteLimitError) throw error
128
+ }
129
+ try {
130
+ return normalizedJsonValue(Boolean.prototype.valueOf.call(value), key, budget, ancestors)
131
+ } catch (error) {
132
+ if (error instanceof JsonByteLimitError) throw error
133
+ }
134
+ try {
135
+ BigInt.prototype.valueOf.call(value)
136
+ } catch {
137
+ // Not a boxed BigInt; continue with ordinary object serialization.
138
+ return normalizeJsonObject(value, budget, ancestors)
139
+ }
140
+ throw new TypeError("Do not know how to serialize a BigInt")
141
+ }
142
+
143
+ function normalizeJsonObject(
144
+ object: object,
145
+ budget: JsonBudget,
146
+ ancestors: Set<object>,
147
+ ): unknown | typeof OMIT_JSON_VALUE {
148
+ if (ancestors.has(object)) throw new TypeError("Converting circular structure to JSON")
149
+ ancestors.add(object)
150
+ try {
151
+ if (Array.isArray(object)) {
152
+ addJsonBytes(budget, 1) // [
153
+ const result: unknown[] = Object.setPrototypeOf([], null)
154
+ const length = object.length
155
+ for (let index = 0; index < length; index++) {
156
+ if (index > 0) addJsonBytes(budget, 1)
157
+ const key = String(index)
158
+ const item = normalizedJsonValue(Reflect.get(object, key), key, budget, ancestors)
159
+ if (item === OMIT_JSON_VALUE) {
160
+ addJsonBytes(budget, 4)
161
+ result[index] = null
162
+ } else result[index] = item
163
+ }
164
+ addJsonBytes(budget, 1) // ]
165
+ return result
166
+ }
167
+
168
+ addJsonBytes(budget, 1) // {
169
+ const result: Record<string, unknown> = Object.create(null)
170
+ let first = true
171
+ for (const property of Object.keys(object)) {
172
+ const item = normalizedJsonValue(Reflect.get(object, property), property, budget, ancestors)
173
+ if (item === OMIT_JSON_VALUE) continue
174
+ if (!first) addJsonBytes(budget, 1)
175
+ countQuotedJsonString(property, budget)
176
+ addJsonBytes(budget, 1) // :
177
+ first = false
178
+ Object.defineProperty(result, property, {
179
+ configurable: true,
180
+ enumerable: true,
181
+ value: item,
182
+ writable: true,
183
+ })
184
+ }
185
+ addJsonBytes(budget, 1) // }
186
+ return result
187
+ } finally {
188
+ ancestors.delete(object)
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Serialize a JSON value only when its exact UTF-8 size fits `maxBytes`. The preflight snapshots
194
+ * enumerable values and `toJSON` results while counting, so an oversized output is rejected before
195
+ * constructing the escaped JSON string or its encoded byte buffer. Returns `undefined` for a root
196
+ * value with no JSON representation, matching `JSON.stringify`.
197
+ */
198
+ export function boundedJsonText(
199
+ value: unknown,
200
+ maxBytes: number,
201
+ ): { readonly text: string; readonly byteLength: number } | undefined {
202
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
203
+ throw new RangeError("maxBytes must be a non-negative safe integer")
204
+ const budget: JsonBudget = { maxBytes, usedBytes: 0 }
205
+ const normalized = normalizedJsonValue(value, "", budget, new Set())
206
+ if (normalized === OMIT_JSON_VALUE) return undefined
207
+ const text = JSON.stringify(normalized)
208
+ if (text === undefined) return undefined
209
+ return { text, byteLength: budget.usedBytes }
210
+ }
211
+
3
212
  /** `Response.json`, with each `bigint` written as its decimal string, as `jsonText` writes it. */
4
213
  export function jsonResponse(value: unknown, init?: ResponseInit): Response {
5
214
  try {
@@ -14,10 +14,12 @@
14
14
  * A resolver only affects imports that have not resolved yet, and a module's own imports resolve
15
15
  * before its body runs. Registering from inside application code would therefore miss exactly the
16
16
  * imports it exists to redirect. `preload` runs before the entry point, which is the only moment that
17
- * covers the whole graph.
17
+ * covers the whole graph. When Bun starts a filtered workspace script below the app root, the
18
+ * registrar finds the nearest ancestor package declaring `nifra.singleCopy` and passes that root to
19
+ * the resolver instead of planning against the workspace package's manifest.
18
20
  *
19
- * `nifra check` reads this specifier out of `bunfig.toml` as the proof that the runtime arm is armed,
20
- * so the string above is a contract rather than a suggestion.
21
+ * `nifra check` recognizes this public specifier and verifies checkout-local relative preloads before
22
+ * treating them as proof that the runtime arm is armed.
21
23
  *
22
24
  * A declared package that cannot be collapsed (two versions, or a file with no counterpart in the
23
25
  * app's copy) prints one warning per package naming both copies. Set
@@ -130,7 +130,7 @@ export interface SingleCopyPlan {
130
130
  }
131
131
 
132
132
  export interface SingleCopyOptions {
133
- /** The app root whose copy wins. Defaults to `process.cwd()`. */
133
+ /** The app root whose copy wins. Defaults to its nearest declaration, or `process.cwd()` if none. */
134
134
  readonly cwd?: string
135
135
  /** Package names or `@scope/*` patterns. Defaults to the `nifra.singleCopy` declaration. */
136
136
  readonly packages?: readonly string[]
@@ -179,6 +179,21 @@ const singleCopyField = (cwd: string): unknown => {
179
179
  return isRecord(nifra) ? nifra.singleCopy : undefined
180
180
  }
181
181
 
182
+ /** Find the declaring app when a Bun workspace script runs from a package directory. */
183
+ const singleCopyProjectRoot = (cwd: string): string => {
184
+ const start = resolve(cwd)
185
+ let current = start
186
+ for (let depth = 0; depth < MAX_ANCESTOR_DIRS; depth++) {
187
+ const pkg = readJsonSync(join(current, "package.json"))
188
+ const nifra = pkg?.nifra
189
+ if (isRecord(nifra) && Object.hasOwn(nifra, "singleCopy")) return realpathOrSelf(current)
190
+ const parent = dirname(current)
191
+ if (parent === current) break
192
+ current = parent
193
+ }
194
+ return realpathOrSelf(start)
195
+ }
196
+
182
197
  /**
183
198
  * The declaration, read from `package.json` - deliberately NOT from `nifra.config.ts`.
184
199
  *
@@ -266,6 +281,34 @@ function preloadEntries(value: string): readonly string[] {
266
281
  return entries
267
282
  }
268
283
 
284
+ /** Accept the public entry, or a relative source preload that is itself the registrar. */
285
+ function isSingleCopyRegistrar(entry: string, cwd: string): boolean {
286
+ if (entry === SINGLE_COPY_REGISTER_SPECIFIER) return true
287
+ if (!entry.startsWith(".")) return false
288
+ let source: string
289
+ try {
290
+ source = readFileSync(resolve(cwd, entry), "utf8")
291
+ } catch {
292
+ return false
293
+ }
294
+ const lines = source
295
+ .replaceAll("\r\n", "\n")
296
+ .split("\n")
297
+ .map((line) => line.trim())
298
+ const importsRegistrar = lines.some((line) => {
299
+ const statement = line.endsWith(";") ? line.slice(0, -1) : line
300
+ return (
301
+ statement === 'import { registerSingleCopy } from "./single-copy"' ||
302
+ statement === "import { registerSingleCopy } from './single-copy'"
303
+ )
304
+ })
305
+ const callsRegistrar = lines.some((line) => {
306
+ const statement = line.endsWith(";") ? line.slice(0, -1) : line
307
+ return statement === "registerSingleCopy()"
308
+ })
309
+ return importsRegistrar && callsRegistrar
310
+ }
311
+
269
312
  /**
270
313
  * Read the runtime proof out of `bunfig.toml`.
271
314
  *
@@ -294,7 +337,10 @@ export function readSingleCopyRegistration(cwd: string): SingleCopyRegistration
294
337
  section = heading.trim()
295
338
  continue
296
339
  }
297
- if (value === undefined || !preloadEntries(value).includes(SINGLE_COPY_REGISTER_SPECIFIER))
340
+ if (
341
+ value === undefined ||
342
+ !preloadEntries(value).some((entry) => isSingleCopyRegistrar(entry, cwd))
343
+ )
298
344
  continue
299
345
  if (section === "test") test = true
300
346
  else if (section === "") run = true
@@ -726,7 +772,9 @@ const strictFromEnv = (): boolean => {
726
772
  * anything loads, and a `no-counterpart` file fails its own import.
727
773
  */
728
774
  export function registerSingleCopy(options: SingleCopyOptions = {}): SingleCopyPlan {
729
- const root = realpathOrSelf(resolve(options.cwd ?? process.cwd()))
775
+ // Bun runs `--filter` scripts from the workspace package directory. Resolve the declaration root
776
+ // first so the preload still pins packages against the application tree that owns bunfig.toml.
777
+ const root = realpathOrSelf(resolve(options.cwd ?? singleCopyProjectRoot(process.cwd())))
730
778
  const strict = options.strict ?? (readSingleCopyStrict(root) || strictFromEnv())
731
779
  const warned = processSet(WARNED)
732
780
  const emit = (key: string, message: string): void => {