@zlink-systems/stream-wire 0.30.3 → 0.32.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.
|
@@ -2,6 +2,7 @@ import type { ZLinkJsonSchema } from './json-contract';
|
|
|
2
2
|
export declare const FRAMEWORK_JSON_V1_PROFILE = "framework-json-v1";
|
|
3
3
|
export interface FrameworkJsonParseOptions {
|
|
4
4
|
readonly rejectPropertyName?: (name: string) => boolean;
|
|
5
|
+
readonly onDuplicateProperty?: (name: string, path: readonly string[]) => void;
|
|
5
6
|
}
|
|
6
7
|
export declare function stringifyFrameworkJsonV1(value: unknown, schema?: ZLinkJsonSchema, replacer?: (this: unknown, key: string, value: unknown) => unknown): string;
|
|
7
8
|
export declare function parseFrameworkJsonV1(text: string, options?: FrameworkJsonParseOptions, schema?: ZLinkJsonSchema, reviver?: (this: unknown, key: string, value: unknown) => unknown): unknown;
|
|
@@ -2,6 +2,7 @@ import type { ZLinkJsonSchema } from './json-contract';
|
|
|
2
2
|
export declare const FRAMEWORK_JSON_V1_PROFILE = "framework-json-v1";
|
|
3
3
|
export interface FrameworkJsonParseOptions {
|
|
4
4
|
readonly rejectPropertyName?: (name: string) => boolean;
|
|
5
|
+
readonly onDuplicateProperty?: (name: string, path: readonly string[]) => void;
|
|
5
6
|
}
|
|
6
7
|
export declare function stringifyFrameworkJsonV1(value: unknown, schema?: ZLinkJsonSchema, replacer?: (this: unknown, key: string, value: unknown) => unknown): string;
|
|
7
8
|
export declare function parseFrameworkJsonV1(text: string, options?: FrameworkJsonParseOptions, schema?: ZLinkJsonSchema, reviver?: (this: unknown, key: string, value: unknown) => unknown): unknown;
|
|
@@ -10,22 +10,28 @@ function stringifyFrameworkJsonV1(value, schema, replacer) {
|
|
|
10
10
|
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
|
11
11
|
throw new TypeError('framework-json-v1 payload is not a JSON value.');
|
|
12
12
|
}
|
|
13
|
-
const encoded =
|
|
14
|
-
? frameworkJsonV1Replacer
|
|
15
|
-
:
|
|
16
|
-
const transformed = replacer.call(this, key, item);
|
|
17
|
-
return frameworkJsonV1Replacer.call({ [key]: transformed }, key, transformed);
|
|
18
|
-
});
|
|
13
|
+
const encoded = replacer === undefined
|
|
14
|
+
? JSON.stringify(value, frameworkJsonV1Replacer)
|
|
15
|
+
: stringifyWithReplacer(value, replacer);
|
|
19
16
|
if (schema !== undefined) {
|
|
20
17
|
validateFrameworkJsonV1Value(JSON.parse(encoded), schema, '$', false);
|
|
21
18
|
}
|
|
22
19
|
return encoded;
|
|
23
20
|
}
|
|
21
|
+
// Keep custom callback captures off the default encoding path.
|
|
22
|
+
function stringifyWithReplacer(value, replacer) {
|
|
23
|
+
return JSON.stringify(value, function (key, item) {
|
|
24
|
+
const transformed = replacer.call(this, key, item);
|
|
25
|
+
return frameworkJsonV1Value(transformed, transformed);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
24
28
|
// Module-level so JSON.stringify does not allocate a fresh closure per
|
|
25
29
|
// encoded message. On the root call `this` is the {'': value} wrapper, so
|
|
26
30
|
// this[key] resolves the raw (pre-toJSON) source for every key including ''.
|
|
27
31
|
function frameworkJsonV1Replacer(key, item) {
|
|
28
|
-
|
|
32
|
+
return frameworkJsonV1Value(this[key], item);
|
|
33
|
+
}
|
|
34
|
+
function frameworkJsonV1Value(source, item) {
|
|
29
35
|
if (typeof source === 'number' && !Number.isFinite(source)) {
|
|
30
36
|
throw new TypeError('framework-json-v1 only accepts finite JSON numbers.');
|
|
31
37
|
}
|
|
@@ -169,24 +175,27 @@ class JsonPropertyScanner {
|
|
|
169
175
|
text;
|
|
170
176
|
options;
|
|
171
177
|
index = 0;
|
|
178
|
+
firstEscape;
|
|
172
179
|
constructor(text, options) {
|
|
173
180
|
this.text = text;
|
|
174
181
|
this.options = options;
|
|
182
|
+
const escape = text.indexOf('\\');
|
|
183
|
+
this.firstEscape = escape === -1 ? text.length : escape;
|
|
175
184
|
}
|
|
176
185
|
scan() {
|
|
177
186
|
this.skipWhitespace();
|
|
178
|
-
this.scanValue();
|
|
187
|
+
this.scanValue([]);
|
|
179
188
|
this.skipWhitespace();
|
|
180
189
|
if (this.index !== this.text.length)
|
|
181
190
|
this.fail('trailing data');
|
|
182
191
|
}
|
|
183
|
-
scanValue() {
|
|
192
|
+
scanValue(path) {
|
|
184
193
|
this.skipWhitespace();
|
|
185
194
|
const token = this.text[this.index];
|
|
186
195
|
if (token === '{')
|
|
187
|
-
return this.scanObject();
|
|
196
|
+
return this.scanObject(path);
|
|
188
197
|
if (token === '[')
|
|
189
|
-
return this.scanArray();
|
|
198
|
+
return this.scanArray(path);
|
|
190
199
|
if (token === '"') {
|
|
191
200
|
this.scanString();
|
|
192
201
|
return;
|
|
@@ -199,7 +208,7 @@ class JsonPropertyScanner {
|
|
|
199
208
|
return this.scanLiteral('null');
|
|
200
209
|
this.scanNumber();
|
|
201
210
|
}
|
|
202
|
-
scanObject() {
|
|
211
|
+
scanObject(path) {
|
|
203
212
|
this.index++;
|
|
204
213
|
this.skipWhitespace();
|
|
205
214
|
if (this.text[this.index] === '}') {
|
|
@@ -213,7 +222,12 @@ class JsonPropertyScanner {
|
|
|
213
222
|
this.fail('object property name');
|
|
214
223
|
const property = this.scanString();
|
|
215
224
|
if (properties.has(property)) {
|
|
216
|
-
|
|
225
|
+
if (this.options.onDuplicateProperty !== undefined) {
|
|
226
|
+
this.options.onDuplicateProperty(property, path);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
throw new SyntaxError(`framework-json-v1 duplicate property '${property}'.`);
|
|
230
|
+
}
|
|
217
231
|
}
|
|
218
232
|
properties.add(property);
|
|
219
233
|
if (this.options.rejectPropertyName?.(property) === true) {
|
|
@@ -223,7 +237,7 @@ class JsonPropertyScanner {
|
|
|
223
237
|
if (this.text[this.index] !== ':')
|
|
224
238
|
this.fail("':' after object property");
|
|
225
239
|
this.index++;
|
|
226
|
-
this.scanValue();
|
|
240
|
+
this.scanValue(this.options.onDuplicateProperty === undefined ? path : [...path, property]);
|
|
227
241
|
this.skipWhitespace();
|
|
228
242
|
const delimiter = this.text[this.index++];
|
|
229
243
|
if (delimiter === '}')
|
|
@@ -232,7 +246,7 @@ class JsonPropertyScanner {
|
|
|
232
246
|
this.fail("',' or '}' after object property");
|
|
233
247
|
}
|
|
234
248
|
}
|
|
235
|
-
scanArray() {
|
|
249
|
+
scanArray(path) {
|
|
236
250
|
this.index++;
|
|
237
251
|
this.skipWhitespace();
|
|
238
252
|
if (this.text[this.index] === ']') {
|
|
@@ -240,7 +254,7 @@ class JsonPropertyScanner {
|
|
|
240
254
|
return;
|
|
241
255
|
}
|
|
242
256
|
for (;;) {
|
|
243
|
-
this.scanValue();
|
|
257
|
+
this.scanValue(this.options.onDuplicateProperty === undefined ? path : [...path, '[]']);
|
|
244
258
|
this.skipWhitespace();
|
|
245
259
|
const delimiter = this.text[this.index++];
|
|
246
260
|
if (delimiter === ']')
|
|
@@ -250,26 +264,39 @@ class JsonPropertyScanner {
|
|
|
250
264
|
}
|
|
251
265
|
}
|
|
252
266
|
scanString() {
|
|
253
|
-
const
|
|
267
|
+
const text = this.text;
|
|
268
|
+
const start = this.index;
|
|
269
|
+
let index = start + 1;
|
|
270
|
+
// Before the first backslash of the document every token ends at the
|
|
271
|
+
// next quote, so native search finds it; later tokens walk the escapes.
|
|
272
|
+
const end = text.indexOf('"', index);
|
|
273
|
+
if (end !== -1 && end < this.firstEscape) {
|
|
274
|
+
this.index = end + 1;
|
|
275
|
+
return text.slice(index, end);
|
|
276
|
+
}
|
|
254
277
|
let sawEscape = false;
|
|
255
278
|
for (;;) {
|
|
256
|
-
if (
|
|
279
|
+
if (index >= text.length) {
|
|
280
|
+
this.index = index;
|
|
257
281
|
this.fail('unterminated string');
|
|
258
|
-
|
|
282
|
+
}
|
|
283
|
+
const character = text[index++];
|
|
259
284
|
if (character === '"') {
|
|
285
|
+
this.index = index;
|
|
260
286
|
// Without escapes the token content is verbatim; the outer
|
|
261
287
|
// JSON.parse pass still rejects any raw control characters.
|
|
262
288
|
return sawEscape
|
|
263
|
-
? JSON.parse(
|
|
264
|
-
:
|
|
289
|
+
? JSON.parse(text.slice(start, index))
|
|
290
|
+
: text.slice(start + 1, index - 1);
|
|
265
291
|
}
|
|
266
292
|
if (character === '\\') {
|
|
267
293
|
sawEscape = true;
|
|
268
|
-
if (
|
|
294
|
+
if (index >= text.length) {
|
|
295
|
+
this.index = index;
|
|
269
296
|
this.fail('unterminated escape');
|
|
270
|
-
|
|
271
|
-
if (
|
|
272
|
-
|
|
297
|
+
}
|
|
298
|
+
if (text[index++] === 'u')
|
|
299
|
+
index += 4;
|
|
273
300
|
}
|
|
274
301
|
}
|
|
275
302
|
}
|
|
@@ -4,6 +4,10 @@ exports.defineZLinkPacketJsonContract = defineZLinkPacketJsonContract;
|
|
|
4
4
|
exports.readZLinkPacketJsonContract = readZLinkPacketJsonContract;
|
|
5
5
|
exports.isPrototypeKey = isPrototypeKey;
|
|
6
6
|
const MAX_CONTRACT_SCHEMA_DEPTH = 64;
|
|
7
|
+
function isIntegerEnumName(name) {
|
|
8
|
+
// framework-json-v1 permits only declared enum names, never ordinal strings.
|
|
9
|
+
return /^[+-]?\d+$/u.test(name);
|
|
10
|
+
}
|
|
7
11
|
const ZLINK_PACKET_JSON_CONTRACTS = Symbol.for('@zlink-systems/framework:packet-json-contracts');
|
|
8
12
|
function defineZLinkPacketJsonContract(packetName, contract) {
|
|
9
13
|
if (packetName.trim().length === 0) {
|
|
@@ -59,8 +63,8 @@ function normalizeJsonSchema(schema, parents, depth) {
|
|
|
59
63
|
case 'enum': {
|
|
60
64
|
if (!Array.isArray(normalizedSchema.names) ||
|
|
61
65
|
normalizedSchema.names.length === 0 ||
|
|
62
|
-
normalizedSchema.names.some((name) => typeof name !== 'string' || name.length === 0)) {
|
|
63
|
-
throw new TypeError('ZLink packet enum schema requires non-empty string names.');
|
|
66
|
+
normalizedSchema.names.some((name) => typeof name !== 'string' || name.length === 0 || isIntegerEnumName(name))) {
|
|
67
|
+
throw new TypeError('ZLink packet enum schema requires non-empty, non-numeric string names.');
|
|
64
68
|
}
|
|
65
69
|
if (new Set(normalizedSchema.names).size !== normalizedSchema.names.length) {
|
|
66
70
|
throw new TypeError('ZLink packet enum schema names must be unique.');
|