circuitjson-toolkit 1.2.1 → 1.4.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/README.md +57 -0
- package/docs/api.md +106 -0
- package/docs/release-notes-v1.3.0.md +45 -0
- package/docs/release-notes-v1.4.0.md +84 -0
- package/docs/testing.md +14 -0
- package/package.json +4 -2
- package/src/core/Parser.mjs +1 -1
- package/src/core/context/BinaryDataSnapshot.mjs +242 -43
- package/src/core/context/CircuitJsonDocumentContext.mjs +85 -3
- package/src/core/context/CircuitJsonExtensionBoundary.mjs +312 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +129 -60
- package/src/core/context/CircuitJsonValidationProof.mjs +126 -12
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +235 -2
- package/src/core/context/StructuredCloneAdoption.mjs +975 -0
- package/src/core/context/StructuredCloneCollectionNormalizer.mjs +86 -0
- package/src/core/context/StructuredCloneTextAccounting.mjs +55 -0
- package/src/core/context/StructuredDataSnapshot.mjs +64 -6
- package/src/core/contracts/DocumentResult.mjs +38 -6
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const NO_STANDARD_COLLECTION = Symbol('NoStandardCollection')
|
|
2
|
+
const MAP_ENTRIES = Map.prototype.entries
|
|
3
|
+
const MAP_ITERATOR_NEXT = Object.getPrototypeOf(new Map().entries()).next
|
|
4
|
+
const MAP_SIZE = Object.getOwnPropertyDescriptor(Map.prototype, 'size')?.get
|
|
5
|
+
const SET_ITERATOR_NEXT = Object.getPrototypeOf(new Set().values()).next
|
|
6
|
+
const SET_SIZE = Object.getOwnPropertyDescriptor(Set.prototype, 'size')?.get
|
|
7
|
+
const SET_VALUES = Set.prototype.values
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes standard structured-clone collections in bounded entry slices.
|
|
11
|
+
*/
|
|
12
|
+
export class StructuredCloneCollectionNormalizer {
|
|
13
|
+
/**
|
|
14
|
+
* Returns the private sentinel for a non-collection candidate.
|
|
15
|
+
* @returns {symbol} Non-collection sentinel.
|
|
16
|
+
*/
|
|
17
|
+
static get notHandled() {
|
|
18
|
+
return NO_STANDARD_COLLECTION
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes one Map or Set through captured platform iteration.
|
|
23
|
+
* @param {object} value Collection candidate.
|
|
24
|
+
* @param {object | null} prototype Captured prototype.
|
|
25
|
+
* @param {PropertyKey[]} keys Captured own keys.
|
|
26
|
+
* @param {number} depth Current collection depth.
|
|
27
|
+
* @param {{ adopt: (value: unknown, depth: number) => Generator<void, unknown, void>, checkpoint: () => Generator<void, void, void>, collect: (value: unknown) => Generator<void, void, void>, remember: (source: object, result: unknown[]) => void, reserve: (count: number) => void }} operations Traversal operations.
|
|
28
|
+
* @returns {Generator<void, unknown, void>} Normalized collection or sentinel.
|
|
29
|
+
*/
|
|
30
|
+
static *normalize(value, prototype, keys, depth, operations) {
|
|
31
|
+
const map = prototype === Map.prototype
|
|
32
|
+
const set = prototype === Set.prototype
|
|
33
|
+
if (!map && !set) return NO_STANDARD_COLLECTION
|
|
34
|
+
if (keys.length) {
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
`Canonical asset source ${map ? 'maps' : 'sets'} may not have custom properties.`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
const sizeGetter = map ? MAP_SIZE : SET_SIZE
|
|
40
|
+
if (typeof sizeGetter !== 'function') {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
`Canonical asset source contains an invalid ${map ? 'map' : 'set'}.`
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
let size
|
|
46
|
+
let iterator
|
|
47
|
+
try {
|
|
48
|
+
size = Reflect.apply(sizeGetter, value, [])
|
|
49
|
+
iterator = Reflect.apply(map ? MAP_ENTRIES : SET_VALUES, value, [])
|
|
50
|
+
} catch {
|
|
51
|
+
throw new TypeError(
|
|
52
|
+
`Canonical asset source contains an invalid ${map ? 'map' : 'set'}.`
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
operations.reserve(1)
|
|
56
|
+
operations.reserve(map ? size * 2 : size)
|
|
57
|
+
const result = []
|
|
58
|
+
operations.remember(value, result)
|
|
59
|
+
const iteratorNext = map ? MAP_ITERATOR_NEXT : SET_ITERATOR_NEXT
|
|
60
|
+
for (let index = 0; index < size; index += 1) {
|
|
61
|
+
const row = Reflect.apply(iteratorNext, iterator, [])
|
|
62
|
+
if (row.done || (map && !Array.isArray(row.value))) {
|
|
63
|
+
throw new TypeError(
|
|
64
|
+
`Canonical asset source ${map ? 'map' : 'set'} changed during inspection.`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
result[index] = map
|
|
68
|
+
? [
|
|
69
|
+
yield* operations.adopt(row.value[0], depth + 1),
|
|
70
|
+
yield* operations.adopt(row.value[1], depth + 1)
|
|
71
|
+
]
|
|
72
|
+
: yield* operations.adopt(row.value, depth + 1)
|
|
73
|
+
yield* operations.checkpoint()
|
|
74
|
+
}
|
|
75
|
+
if (!Reflect.apply(iteratorNext, iterator, []).done) {
|
|
76
|
+
throw new TypeError(
|
|
77
|
+
`Canonical asset source ${map ? 'map' : 'set'} changed during inspection.`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
yield* operations.collect(result)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Object.freeze(StructuredCloneCollectionNormalizer.prototype)
|
|
86
|
+
Object.freeze(StructuredCloneCollectionNormalizer)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const TEXT_ACCOUNTING_CHUNK_CHARACTERS = 64 * 1_024
|
|
2
|
+
const STRING_CHAR_CODE_AT = String.prototype.charCodeAt
|
|
3
|
+
const STRING_SLICE = String.prototype.slice
|
|
4
|
+
const TEXT_ENCODER = new TextEncoder()
|
|
5
|
+
const TEXT_ENCODER_ENCODE = TextEncoder.prototype.encode
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Accounts immutable structured-clone text without one long UTF-8 scan.
|
|
9
|
+
*/
|
|
10
|
+
export class StructuredCloneTextAccounting {
|
|
11
|
+
/**
|
|
12
|
+
* Measures one string across bounded encoding slices.
|
|
13
|
+
* @param {string} value Immutable text value.
|
|
14
|
+
* @param {{ bytes: number, label: string, maxBytes: number }} state Shared accounting state.
|
|
15
|
+
* @param {{ checkpoint: () => Generator<void, void, void>, reserve: (count: number) => void }} operations Traversal operations.
|
|
16
|
+
* @returns {Generator<void, void, void>} Bounded text accounting pass.
|
|
17
|
+
*/
|
|
18
|
+
static *reserve(value, state, operations) {
|
|
19
|
+
if (state.maxBytes === Number.MAX_SAFE_INTEGER) return
|
|
20
|
+
let length = 0
|
|
21
|
+
let offset = 0
|
|
22
|
+
while (offset < value.length) {
|
|
23
|
+
let end = Math.min(
|
|
24
|
+
value.length,
|
|
25
|
+
offset + TEXT_ACCOUNTING_CHUNK_CHARACTERS
|
|
26
|
+
)
|
|
27
|
+
const trailing = Reflect.apply(STRING_CHAR_CODE_AT, value, [
|
|
28
|
+
end - 1
|
|
29
|
+
])
|
|
30
|
+
const next = Reflect.apply(STRING_CHAR_CODE_AT, value, [end])
|
|
31
|
+
if (
|
|
32
|
+
end < value.length &&
|
|
33
|
+
trailing >= 0xd800 &&
|
|
34
|
+
trailing <= 0xdbff &&
|
|
35
|
+
next >= 0xdc00 &&
|
|
36
|
+
next <= 0xdfff
|
|
37
|
+
) {
|
|
38
|
+
end -= 1
|
|
39
|
+
}
|
|
40
|
+
const chunk = Reflect.apply(STRING_SLICE, value, [offset, end])
|
|
41
|
+
length += Reflect.apply(TEXT_ENCODER_ENCODE, TEXT_ENCODER, [
|
|
42
|
+
chunk
|
|
43
|
+
]).byteLength
|
|
44
|
+
if (state.bytes + length > state.maxBytes) {
|
|
45
|
+
throw new TypeError(`${state.label} is too large.`)
|
|
46
|
+
}
|
|
47
|
+
offset = end
|
|
48
|
+
if (offset < value.length) yield* operations.checkpoint()
|
|
49
|
+
}
|
|
50
|
+
operations.reserve(length)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Object.freeze(StructuredCloneTextAccounting.prototype)
|
|
55
|
+
Object.freeze(StructuredCloneTextAccounting)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BinaryDataSnapshot } from './BinaryDataSnapshot.mjs'
|
|
2
|
+
import { StructuredCloneAdoption } from './StructuredCloneAdoption.mjs'
|
|
2
3
|
|
|
3
4
|
const METADATA_MAX_DEPTH = 256
|
|
4
5
|
const METADATA_MAX_ITEMS = 100_000
|
|
@@ -26,8 +27,8 @@ const SET_VALUES = Set.prototype.values
|
|
|
26
27
|
export class StructuredDataSnapshot {
|
|
27
28
|
/**
|
|
28
29
|
* Creates state shared by multiple metadata roots in one request.
|
|
29
|
-
* @param {{ label?: string, maxBytes?: number, maxItems?: number, preserveBinary?: boolean }} [limits] Capture limits.
|
|
30
|
-
* @returns {{ seen: Map<object, unknown>, accounted: Set<object>, bytes: number, items: number, label: string, maxBytes: number, maxItems: number, preserveBinary: boolean }} Capture state.
|
|
30
|
+
* @param {{ label?: string, maxBytes?: number, maxItems?: number, preserveBinary?: boolean, standardBuiltins?: boolean }} [limits] Capture limits and proven graph provenance.
|
|
31
|
+
* @returns {{ seen: Map<object, unknown>, accounted: Set<object>, bytes: number, items: number, label: string, maxBytes: number, maxItems: number, preserveBinary: boolean, standardBuiltins: boolean }} Capture state.
|
|
31
32
|
*/
|
|
32
33
|
static createState(limits = {}) {
|
|
33
34
|
const maxBytes =
|
|
@@ -52,7 +53,8 @@ export class StructuredDataSnapshot {
|
|
|
52
53
|
label: String(limits.label || 'Canonical asset source'),
|
|
53
54
|
maxBytes,
|
|
54
55
|
maxItems,
|
|
55
|
-
preserveBinary: limits.preserveBinary === true
|
|
56
|
+
preserveBinary: limits.preserveBinary === true,
|
|
57
|
+
standardBuiltins: limits.standardBuiltins === true
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
@@ -71,13 +73,66 @@ export class StructuredDataSnapshot {
|
|
|
71
73
|
!Number.isSafeInteger(state.maxBytes) ||
|
|
72
74
|
!Number.isSafeInteger(state.maxItems) ||
|
|
73
75
|
typeof state.label !== 'string' ||
|
|
74
|
-
typeof state.preserveBinary !== 'boolean'
|
|
76
|
+
typeof state.preserveBinary !== 'boolean' ||
|
|
77
|
+
typeof state.standardBuiltins !== 'boolean'
|
|
75
78
|
) {
|
|
76
79
|
throw new TypeError('Invalid source-metadata capture state.')
|
|
77
80
|
}
|
|
78
81
|
return StructuredDataSnapshot.#capture(value, state, 0)
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Adopts a proven standard structured-clone graph synchronously.
|
|
86
|
+
* @param {unknown} value Structured-cloned metadata candidate.
|
|
87
|
+
* @param {Record<string, any>} state Shared capture state.
|
|
88
|
+
* @returns {unknown} Adopted graph.
|
|
89
|
+
*/
|
|
90
|
+
static adoptStructuredClone(value, state) {
|
|
91
|
+
return StructuredCloneAdoption.adopt(
|
|
92
|
+
value,
|
|
93
|
+
state,
|
|
94
|
+
(candidate, captureState, depth) =>
|
|
95
|
+
StructuredDataSnapshot.#capture(candidate, captureState, depth)
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Adopts and seals a proven structured-clone graph in bounded work slices.
|
|
101
|
+
* @param {unknown} value Structured-cloned metadata candidate.
|
|
102
|
+
* @param {Record<string, any>} state Shared capture state.
|
|
103
|
+
* @param {() => Promise<void> | void} yieldControl Host scheduler.
|
|
104
|
+
* @returns {Promise<unknown>} Adopted and sealed graph.
|
|
105
|
+
*/
|
|
106
|
+
static async adoptStructuredCloneAsync(value, state, yieldControl) {
|
|
107
|
+
return StructuredCloneAdoption.adoptAndSealAsync(
|
|
108
|
+
value,
|
|
109
|
+
state,
|
|
110
|
+
(candidate, captureState, depth) =>
|
|
111
|
+
StructuredDataSnapshot.#capture(candidate, captureState, depth),
|
|
112
|
+
yieldControl
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Consumes the sealing plan produced by synchronous clone adoption.
|
|
118
|
+
* @param {unknown} value Adopted graph root.
|
|
119
|
+
* @returns {object | null} Trusted one-use sealing plan.
|
|
120
|
+
* @internal
|
|
121
|
+
*/
|
|
122
|
+
static consumeStructuredCloneAdoption(value) {
|
|
123
|
+
return StructuredCloneAdoption.consume(value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Seals one synchronously adopted graph from its trusted plan.
|
|
128
|
+
* @param {object} plan Trusted adoption plan.
|
|
129
|
+
* @returns {void}
|
|
130
|
+
* @internal
|
|
131
|
+
*/
|
|
132
|
+
static sealStructuredCloneAdoption(plan) {
|
|
133
|
+
StructuredCloneAdoption.seal(plan)
|
|
134
|
+
}
|
|
135
|
+
|
|
81
136
|
/**
|
|
82
137
|
* Accounts an already normalized metadata snapshot without cloning it.
|
|
83
138
|
* @param {unknown} value Owned metadata snapshot.
|
|
@@ -93,7 +148,8 @@ export class StructuredDataSnapshot {
|
|
|
93
148
|
!Number.isSafeInteger(state.maxBytes) ||
|
|
94
149
|
!Number.isSafeInteger(state.maxItems) ||
|
|
95
150
|
typeof state.label !== 'string' ||
|
|
96
|
-
typeof state.preserveBinary !== 'boolean'
|
|
151
|
+
typeof state.preserveBinary !== 'boolean' ||
|
|
152
|
+
typeof state.standardBuiltins !== 'boolean'
|
|
97
153
|
) {
|
|
98
154
|
throw new TypeError('Invalid source-metadata capture state.')
|
|
99
155
|
}
|
|
@@ -224,7 +280,9 @@ export class StructuredDataSnapshot {
|
|
|
224
280
|
if (state.seen.has(value)) return state.seen.get(value)
|
|
225
281
|
StructuredDataSnapshot.#reserve(state, 1)
|
|
226
282
|
|
|
227
|
-
const binary =
|
|
283
|
+
const binary = state.standardBuiltins
|
|
284
|
+
? BinaryDataSnapshot.describeStandard(value)
|
|
285
|
+
: BinaryDataSnapshot.describe(value)
|
|
228
286
|
if (binary) {
|
|
229
287
|
return StructuredDataSnapshot.#binary(value, binary, state)
|
|
230
288
|
}
|
|
@@ -17,16 +17,17 @@ export class DocumentResult {
|
|
|
17
17
|
* @returns {Record<string, any>} Canonical document result.
|
|
18
18
|
*/
|
|
19
19
|
static create(fields = {}) {
|
|
20
|
-
return DocumentResult.#create(fields, false)
|
|
20
|
+
return DocumentResult.#create(fields, false, false)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Creates the common envelope with the requested extension ownership mode.
|
|
25
25
|
* @param {Record<string, any>} fields Document fields.
|
|
26
26
|
* @param {boolean} readonlyExtensions Whether to capture immutable extensions now.
|
|
27
|
+
* @param {boolean} standardBuiltins Whether extension data is a toolkit-owned ordinary graph.
|
|
27
28
|
* @returns {Record<string, any>} Canonical document result.
|
|
28
29
|
*/
|
|
29
|
-
static #create(fields, readonlyExtensions) {
|
|
30
|
+
static #create(fields, readonlyExtensions, standardBuiltins) {
|
|
30
31
|
const source = DocumentResult.#source(fields)
|
|
31
32
|
const audit = CircuitJsonSerializedInputAudit.inspect(
|
|
32
33
|
fields.model,
|
|
@@ -46,7 +47,7 @@ export class DocumentResult {
|
|
|
46
47
|
extensions: DocumentResult.extensionForSource(
|
|
47
48
|
source.format,
|
|
48
49
|
fields.extensions,
|
|
49
|
-
{ readonly: readonlyExtensions }
|
|
50
|
+
{ readonly: readonlyExtensions, standardBuiltins }
|
|
50
51
|
),
|
|
51
52
|
assets: Array.isArray(fields.assets)
|
|
52
53
|
? fields.assets.map((asset) =>
|
|
@@ -75,7 +76,29 @@ export class DocumentResult {
|
|
|
75
76
|
* @returns {Record<string, any>} Proven canonical document result.
|
|
76
77
|
*/
|
|
77
78
|
static createValidated(fields = {}, runtime = {}) {
|
|
78
|
-
|
|
79
|
+
return DocumentResult.#createValidated(fields, runtime, false)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Creates a validated envelope by consuming toolkit-owned ordinary
|
|
84
|
+
* extension data without an intermediate defensive graph copy.
|
|
85
|
+
* @param {Record<string, any>} [fields] Toolkit-owned document fields.
|
|
86
|
+
* @param {{ sourceReference?: object }} [runtime] Runtime-only fields.
|
|
87
|
+
* @returns {Record<string, any>} Proven canonical document result.
|
|
88
|
+
*/
|
|
89
|
+
static createValidatedOwned(fields = {}, runtime = {}) {
|
|
90
|
+
return DocumentResult.#createValidated(fields, runtime, true)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Creates one validated envelope with explicit extension provenance.
|
|
95
|
+
* @param {Record<string, any>} fields Document fields.
|
|
96
|
+
* @param {{ sourceReference?: object }} runtime Runtime-only fields.
|
|
97
|
+
* @param {boolean} standardBuiltins Whether extension data is toolkit-owned.
|
|
98
|
+
* @returns {Record<string, any>} Proven canonical document result.
|
|
99
|
+
*/
|
|
100
|
+
static #createValidated(fields, runtime, standardBuiltins) {
|
|
101
|
+
const document = DocumentResult.#create(fields, true, standardBuiltins)
|
|
79
102
|
if (Object.hasOwn(runtime || {}, 'sourceReference')) {
|
|
80
103
|
Object.defineProperty(document, 'sourceReference', {
|
|
81
104
|
configurable: false,
|
|
@@ -84,7 +107,9 @@ export class DocumentResult {
|
|
|
84
107
|
writable: false
|
|
85
108
|
})
|
|
86
109
|
}
|
|
87
|
-
return CircuitJsonValidationProof.validateAndAttach(document
|
|
110
|
+
return CircuitJsonValidationProof.validateAndAttach(document, {
|
|
111
|
+
standardBuiltins
|
|
112
|
+
})
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
/**
|
|
@@ -113,7 +138,7 @@ export class DocumentResult {
|
|
|
113
138
|
* Selects and normalizes the extension namespace owned by a source.
|
|
114
139
|
* @param {string} format Source format.
|
|
115
140
|
* @param {unknown} extensions Extension candidates.
|
|
116
|
-
* @param {{ readonly?: boolean }} [options] Extension ownership options.
|
|
141
|
+
* @param {{ readonly?: boolean, standardBuiltins?: boolean }} [options] Extension ownership options.
|
|
117
142
|
* @returns {Record<string, any>} Normalized extension map.
|
|
118
143
|
*/
|
|
119
144
|
static extensionForSource(format, extensions, options = {}) {
|
|
@@ -122,6 +147,13 @@ export class DocumentResult {
|
|
|
122
147
|
const hasCandidate = candidate && typeof candidate === 'object'
|
|
123
148
|
if (!hasCandidate) return {}
|
|
124
149
|
if (options.readonly === true && hasCandidate) {
|
|
150
|
+
if (options.standardBuiltins === true) {
|
|
151
|
+
return CircuitJsonReadOnlyDocument.copyReadonlyExtensionValue(
|
|
152
|
+
DocumentResult.#normalizedExtension(format, candidate),
|
|
153
|
+
null,
|
|
154
|
+
{ standardBuiltins: true }
|
|
155
|
+
)
|
|
156
|
+
}
|
|
125
157
|
return CircuitJsonReadOnlyDocument.copyReadonlyExtensionValue(
|
|
126
158
|
candidate,
|
|
127
159
|
(snapshot) =>
|