@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 = JSON.stringify(value, replacer === undefined
14
- ? frameworkJsonV1Replacer
15
- : function (key, item) {
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
- const source = this[key];
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
- throw new SyntaxError(`framework-json-v1 duplicate property '${property}'.`);
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 start = this.index++;
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 (this.index >= this.text.length)
279
+ if (index >= text.length) {
280
+ this.index = index;
257
281
  this.fail('unterminated string');
258
- const character = this.text[this.index++];
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(this.text.slice(start, this.index))
264
- : this.text.slice(start + 1, this.index - 1);
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 (this.index >= this.text.length)
294
+ if (index >= text.length) {
295
+ this.index = index;
269
296
  this.fail('unterminated escape');
270
- const escaped = this.text[this.index++];
271
- if (escaped === 'u')
272
- this.index += 4;
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.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zlink-systems/stream-wire",
3
- "version": "0.30.3",
3
+ "version": "0.32.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",