@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.
- package/CHANGELOG.md +30 -0
- package/dist/edge-kit.d.ts +2 -1
- package/dist/edge-kit.d.ts.map +1 -1
- package/dist/edge-kit.js +2 -1
- package/dist/edge-kit.js.map +1 -1
- package/dist/guard-deny.d.ts +38 -0
- package/dist/guard-deny.d.ts.map +1 -0
- package/dist/guard-deny.js +43 -0
- package/dist/guard-deny.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/router/path-codec.d.ts +22 -0
- package/dist/router/path-codec.d.ts.map +1 -0
- package/dist/router/path-codec.js +87 -0
- package/dist/router/path-codec.js.map +1 -0
- package/dist/router/pattern.d.ts +21 -1
- package/dist/router/pattern.d.ts.map +1 -1
- package/dist/router/pattern.js +117 -0
- package/dist/router/pattern.js.map +1 -1
- package/dist/router/same-origin-path.d.ts +22 -0
- package/dist/router/same-origin-path.d.ts.map +1 -0
- package/dist/router/same-origin-path.js +37 -0
- package/dist/router/same-origin-path.js.map +1 -0
- package/dist/server/core-copies.js +1 -1
- package/dist/server/core-copies.js.map +1 -1
- package/dist/server/http.d.ts +1 -18
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +1 -33
- package/dist/server/http.js.map +1 -1
- package/dist/server/idle-timeout.d.ts +1 -1
- package/dist/server/idle-timeout.d.ts.map +1 -1
- package/dist/server/idle-timeout.js.map +1 -1
- package/dist/server/json-response.d.ts +14 -0
- package/dist/server/json-response.d.ts.map +1 -1
- package/dist/server/json-response.js +205 -0
- package/dist/server/json-response.js.map +1 -1
- package/dist/single-copy-register.js +5 -3
- package/dist/single-copy-register.js.map +1 -1
- package/dist/single-copy.d.ts +1 -1
- package/dist/single-copy.d.ts.map +1 -1
- package/dist/single-copy.js +49 -2
- package/dist/single-copy.js.map +1 -1
- package/package.json +6 -1
- package/src/edge-kit.ts +2 -1
- package/src/guard-deny.ts +61 -0
- package/src/index.ts +1 -1
- package/src/router/path-codec.ts +93 -0
- package/src/router/pattern.ts +127 -0
- package/src/router/same-origin-path.ts +36 -0
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +1 -33
- package/src/server/idle-timeout.ts +2 -4
- package/src/server/json-response.ts +209 -0
- package/src/single-copy-register.ts +5 -3
- 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`
|
|
20
|
-
*
|
|
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
|
package/src/single-copy.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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 => {
|