libshv-js 3.4.1 → 3.5.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.
@@ -16,7 +16,7 @@ jobs:
16
16
  name: CPON/Chainpack conversions
17
17
  runs-on: ubuntu-22.04
18
18
  steps:
19
- - uses: actions/checkout@v3
19
+ - uses: actions/checkout@v4
20
20
 
21
21
  - name: Install dependencies
22
22
  run: npm install
@@ -25,4 +25,6 @@ jobs:
25
25
  run: npx tsc
26
26
 
27
27
  - name: Run tests
28
- run: node test.js
28
+ run: |
29
+ node test.js
30
+ node test_json.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libshv-js",
3
- "version": "3.4.1",
3
+ "version": "3.5.0",
4
4
  "description": "Typescript implementation of libshv",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -21,6 +21,7 @@
21
21
  "./chainpack.ts": "./src/chainpack.ts",
22
22
  "./cpcontext.ts": "./src/cpcontext.ts",
23
23
  "./cpon.ts": "./src/cpon.ts",
24
+ "./json.ts": "./src/json.ts",
24
25
  "./rpcmessage.ts": "./src/rpcmessage.ts",
25
26
  "./rpcvalue.ts": "./src/rpcvalue.ts",
26
27
  "./ws-client.ts": "./src/ws-client.ts",
package/src/chainpack.ts CHANGED
@@ -178,7 +178,7 @@ class ChainPackReader {
178
178
 
179
179
  msec += SHV_EPOCH_MSEC;
180
180
  msec -= offset * 60_000;
181
- return withOffset(new Date(msec), offset);
181
+ return withOffset(new Date(msec), offset ?? undefined);
182
182
  }
183
183
 
184
184
  case PackingSchema.Map: {
@@ -559,14 +559,14 @@ class ChainPackWriter {
559
559
  }
560
560
 
561
561
  let bi = BigInt(msecs);
562
- if (dt.utc_offset !== undefined) {
562
+ if (dt.utc_offset !== undefined && dt.utc_offset !== 0) {
563
563
  bi <<= 7n;
564
564
  bi |= BigInt((dt.utc_offset / 15) & 0x7F);
565
565
  }
566
566
 
567
567
  bi <<= 2n;
568
568
 
569
- if (dt.utc_offset !== undefined) {
569
+ if (dt.utc_offset !== undefined && dt.utc_offset !== 0) {
570
570
  bi |= 1n;
571
571
  }
572
572
 
package/src/cpon.ts CHANGED
@@ -2,6 +2,33 @@
2
2
  import {type RpcValue, type RpcValueType, type DateTime, type List, Decimal, Double, type IMap, type Int, type MetaMap, RpcValueWithMetaData, type ShvMap, UInt, withOffset, shvMapType, isShvMap, isIMap} from './rpcvalue';
3
3
  import {PackContext, UnpackContext} from './cpcontext';
4
4
 
5
+ export const stringifyDate = (dt: DateTime) => {
6
+ const epochMsec = dt.getTime();
7
+ let utcOffset = dt.utc_offset;
8
+ const localMsec = epochMsec + (60_000 * (utcOffset ?? 0));
9
+ let res = new Date(localMsec).toISOString();
10
+ const rtrim = (localMsec % 1000) ? 1 : 5;
11
+ res = res.slice(0, Math.max(0, res.length - rtrim));
12
+
13
+ if (utcOffset === undefined || utcOffset === 0) {
14
+ res += 'Z';
15
+ } else {
16
+ if (utcOffset < 0) {
17
+ res += '-';
18
+ utcOffset = -utcOffset;
19
+ } else {
20
+ res += '+';
21
+ }
22
+
23
+ res += (Math.trunc(utcOffset / 60)).toString().padStart(2, '0');
24
+ if (utcOffset % 60) {
25
+ res += (utcOffset % 60).toString().padStart(2, '0');
26
+ }
27
+ }
28
+
29
+ return res;
30
+ };
31
+
5
32
  const hexify = (byte: number) => {
6
33
  if (byte < 10) {
7
34
  return 48 + byte;
@@ -222,6 +249,25 @@ class CponReader {
222
249
  }
223
250
 
224
251
  readDateTime() {
252
+ this.ctx.getByte(); // eat '"'
253
+ let b = this.ctx.peekByte();
254
+ if (b === '"'.codePointAt(0)) {
255
+ // d"" invalid data time
256
+ this.ctx.getByte();
257
+ throw new TypeError('Malformed empty date separator in DateTime');
258
+ }
259
+
260
+ const date = this.readDateTimeInner();
261
+
262
+ b = this.ctx.getByte();
263
+ if (b !== '"'.codePointAt(0)) {
264
+ throw new TypeError('DateTime literal should be terminated by \'"\'.');
265
+ }
266
+
267
+ return date;
268
+ }
269
+
270
+ readDateTimeInner() {
225
271
  let year = 0;
226
272
  let month = 0;
227
273
  let day = 1;
@@ -231,17 +277,9 @@ class CponReader {
231
277
  let msec = 0;
232
278
  let utcOffset = 0;
233
279
 
234
- this.ctx.getByte(); // eat '"'
235
- let b = this.ctx.peekByte();
236
- if (b === '"'.codePointAt(0)) {
237
- // d"" invalid data time
238
- this.ctx.getByte();
239
- throw new TypeError('Malformed empty date separator in DateTime');
240
- }
241
-
242
280
  year = Number(this.readInt());
243
281
 
244
- b = this.ctx.getByte();
282
+ let b = this.ctx.getByte();
245
283
  if (b !== '-'.codePointAt(0)) {
246
284
  throw new TypeError('Malformed year-month separator in DateTime');
247
285
  }
@@ -307,15 +345,10 @@ class CponReader {
307
345
  }
308
346
  }
309
347
 
310
- b = this.ctx.getByte();
311
- if (b !== '"'.codePointAt(0)) {
312
- throw new TypeError('DateTime literal should be terminated by \'"\'.');
313
- }
314
-
315
348
  // let epoch_sec = CponReader.timegm(year, month, mday, hour, min, sec);
316
349
  let epochMsec = Date.UTC(year, month - 1, day, hour, min, sec, msec);
317
350
  epochMsec -= utcOffset * 60_000;
318
- return withOffset(new Date(epochMsec), utcOffset);
351
+ return withOffset(new Date(epochMsec), utcOffset ?? undefined);
319
352
  }
320
353
 
321
354
  readCString() {
@@ -775,41 +808,8 @@ class CponWriter {
775
808
  }
776
809
 
777
810
  writeDateTime(dt: DateTime) {
778
- if (!dt) {
779
- this.ctx.writeStringUtf8('d""');
780
- return;
781
- }
782
-
783
- const epochMsec = dt.getTime();
784
- let utcOffset = dt.utc_offset;
785
- const localMsec = epochMsec + (60_000 * (utcOffset ?? 0));
786
- let s = new Date(localMsec).toISOString();
787
- const rtrim = (localMsec % 1000) ? 1 : 5;
788
811
  this.ctx.writeStringUtf8('d"');
789
- for (let i = 0; i < s.length - rtrim; i++) {
790
- this.ctx.putByte(s.codePointAt(i)!);
791
- }
792
-
793
- if (utcOffset === undefined || utcOffset === 0) {
794
- this.ctx.writeStringUtf8('Z');
795
- } else {
796
- if (utcOffset < 0) {
797
- this.ctx.writeStringUtf8('-');
798
- utcOffset = -utcOffset;
799
- } else {
800
- this.ctx.writeStringUtf8('+');
801
- }
802
-
803
- s = (Math.trunc(utcOffset / 60)).toString().padStart(2, '0');
804
- if (utcOffset % 60) {
805
- s += (utcOffset % 60).toString().padStart(2, '0');
806
- }
807
-
808
- for (let i = 0; i < s.length; i++) {
809
- this.ctx.putByte(s.codePointAt(i)!);
810
- }
811
- }
812
-
812
+ this.ctx.writeStringUtf8(stringifyDate(dt));
813
813
  this.ctx.writeStringUtf8('"');
814
814
  }
815
815
 
@@ -835,10 +835,6 @@ class CponWriter {
835
835
  this.doIndentIfNotOneliner(map);
836
836
  let first = true;
837
837
  for (const [key, value] of Object.entries<RpcValue>(map)) {
838
- if (value === undefined) {
839
- continue;
840
- }
841
-
842
838
  if (!first) {
843
839
  this.ctx.putByte(','.codePointAt(0)!);
844
840
  this.doIndentIfNotOneliner(map);
package/src/json.ts ADDED
@@ -0,0 +1,136 @@
1
+ import {UnpackContext} from './cpcontext';
2
+ import {CponReader, stringifyDate} from './cpon';
3
+ import {Decimal, Double, isIMap, isShvMap, makeIMap, makeMap, makeMetaMap, RpcValueWithMetaData, UInt, type RpcValue} from './rpcvalue';
4
+
5
+ // eslint-disable-next-line @typescript-eslint/ban-types -- Zod needs the default argument, otherwise it'll infer as UInt<unknown>
6
+ const decodeMap = (x: object) => makeMap(Object.fromEntries(Object.entries(x).map(([key, value]) => [key, decodeValueJson(value)])));
7
+
8
+ const decodeValueJson = (x: unknown): RpcValue => {
9
+ switch (true) {
10
+ case Array.isArray(x): {
11
+ const list = x as unknown[];
12
+ const handleType = (typeName: unknown, typeValue: unknown) => {
13
+ switch (true) {
14
+ case typeName === 'Blob': {
15
+ if (typeof typeValue !== 'string' || typeValue.length % 2 !== 0) {
16
+ throw new TypeError(`Invalid JSON-encoded Blob: '${JSON.stringify(typeValue)}'`);
17
+ }
18
+
19
+ const buffer = new ArrayBuffer(typeValue.length / 2);
20
+ const array = new Uint8Array(buffer);
21
+
22
+ // I can assume even length because of the previous check.
23
+ for (let i = 0; i < typeValue.length / 2; i++) {
24
+ array[i] = Number.parseInt(typeValue.slice(i * 2, (i * 2) + 2), 16);
25
+ }
26
+
27
+ return buffer;
28
+ }
29
+
30
+ case typeName === 'IMap':
31
+ if (typeof typeValue !== 'object' || typeValue === null) {
32
+ throw new TypeError(`Invalid JSON-encoded IMap: '${JSON.stringify(typeValue)}'`);
33
+ }
34
+
35
+ if (Object.keys(typeValue).some(key => !/^-?\d+$/.test(key))) {
36
+ throw new TypeError(`Invalid JSON-encoded IMap (non-Int key): '${JSON.stringify(typeValue)}'`);
37
+ }
38
+
39
+ return makeIMap(decodeMap(typeValue));
40
+ case typeName === 'DateTime':
41
+ if (typeof typeValue !== 'string') {
42
+ throw new TypeError(`Invalid JSON-encoded DateTime: '${JSON.stringify(typeValue)}'`);
43
+ }
44
+
45
+ // Kinda lame that I'm encoding the datetime string, but this is the easiest way right now.
46
+ return new CponReader(new UnpackContext(new TextEncoder().encode(typeValue).buffer)).readDateTimeInner();
47
+ default:
48
+ throw new TypeError(`Invalid JSON-encoded type name: '${JSON.stringify(typeName)}'`);
49
+ }
50
+ };
51
+
52
+ switch (true) {
53
+ case list.length === 3 && list[0] === JSON_TAG_TYPE:
54
+ return handleType(list[1], list[2]);
55
+ case list.length === 3 && list[0] === JSON_TAG_META:
56
+ case list.length === 5 && list[0] === JSON_TAG_META && list[2] === JSON_TAG_TYPE: {
57
+ if (typeof list[1] !== 'object' || list[1] === null) {
58
+ throw new TypeError(`Invalid JSON-encoded MetaData: '${JSON.stringify(list[1])}'`);
59
+ }
60
+
61
+ const value = (() => {
62
+ if (list.length === 3) {
63
+ const decoded = decodeValueJson(list[2]);
64
+ if (decoded instanceof RpcValueWithMetaData) {
65
+ throw new TypeError('Logic error: got RpcValueWithMetaData as the value in JSON-encoded RpcValueWithMetaData, please report this bug to the maintainer.');
66
+ }
67
+
68
+ return decoded;
69
+ }
70
+
71
+ return handleType(list[3], list[4]);
72
+ })();
73
+
74
+ return new RpcValueWithMetaData(makeMetaMap(decodeMap(list[1])), value);
75
+ }
76
+
77
+ default:
78
+ return list.map(value => decodeValueJson(value));
79
+ }
80
+ }
81
+
82
+ case x === null:
83
+ return undefined;
84
+ case typeof x === 'object':
85
+ return decodeMap(x);
86
+ case typeof x === 'number':
87
+ case typeof x === 'boolean':
88
+ case typeof x === 'string':
89
+ return x;
90
+ default:
91
+ throw new Error(`Unknown JSON-encoded value: ${JSON.stringify(x)}`);
92
+ }
93
+ };
94
+
95
+ export const fromJson = (str: string): RpcValue | Error => decodeValueJson(JSON.parse(str));
96
+
97
+ const JSON_TAG_META = '!shvMeta';
98
+ const JSON_TAG_TYPE = '!shvType';
99
+
100
+ const encodeValueJson = (x: RpcValue): unknown => {
101
+ switch (true) {
102
+ case x instanceof RpcValueWithMetaData: {
103
+ const res = [JSON_TAG_META, encodeValueJson(makeMap(x.meta))] as const;
104
+ const encoded = encodeValueJson(x.value);
105
+ if (Array.isArray(encoded) && encoded[0] === JSON_TAG_TYPE) {
106
+ return [...res, ...encoded as unknown[]];
107
+ }
108
+
109
+ return [...res, encoded];
110
+ }
111
+
112
+ case Array.isArray(x):
113
+ return x.map(value => encodeValueJson(value));
114
+ case isShvMap(x): {
115
+ const map = x as Record<string, RpcValue>;
116
+ return Object.fromEntries(Object.entries(map).map(([key, value]) => [key, encodeValueJson(value)] as const));
117
+ }
118
+
119
+ case isIMap(x):
120
+ return [JSON_TAG_TYPE, 'IMap', encodeValueJson(makeMap(x))];
121
+ case x instanceof ArrayBuffer:
122
+ return [JSON_TAG_TYPE, 'Blob', new Uint8Array(x).reduce((acc, v) => acc + v.toString(16).padStart(2, '0'), '')];
123
+ case x instanceof Date:
124
+ return [JSON_TAG_TYPE, 'DateTime', stringifyDate(x)];
125
+ case x === undefined:
126
+ return null;
127
+ case x instanceof Decimal:
128
+ case x instanceof UInt:
129
+ case x instanceof Double:
130
+ throw new TypeError('Decimal/Double/UInt not supported in JSON');
131
+ default:
132
+ return x;
133
+ }
134
+ };
135
+
136
+ export const toJson = (value: RpcValue): string | Error => JSON.stringify(encodeValueJson(value));
package/src/zod.ts CHANGED
@@ -11,9 +11,10 @@ export const uint = () => z.instanceof(UInt<number>);
11
11
  export const double = () => z.instanceof(Double);
12
12
  export const decimal = () => z.instanceof(Decimal);
13
13
  export const blob = () => z.instanceof(ArrayBuffer);
14
+ export const list = () => z.array(z.lazy(rpcvalue));
14
15
 
15
16
  const withMetaInstanceParser = z.instanceof(RpcValueWithMetaData);
16
- export const rpcvalue = () => z.union([
17
+ export const rpcvalue: () => ZodType<RpcValue, any, any> = () => z.union([
17
18
  z.undefined(),
18
19
  z.boolean(),
19
20
  z.number(),
@@ -23,7 +24,7 @@ export const rpcvalue = () => z.union([
23
24
  blob(),
24
25
  z.string(),
25
26
  z.date(),
26
- z.array(z.any()),
27
+ list(),
27
28
  map(),
28
29
  imap(),
29
30
  withMetaInstanceParser,
package/test.ts CHANGED
@@ -64,6 +64,7 @@ for (const lst of [
64
64
  ['<4:"svete">i{2:<4:"svete">[0,1]}', null],
65
65
  ['d"2019-05-03T11:30:00-0700"', 'd"2019-05-03T11:30:00-07"'],
66
66
  ['d"2018-02-02T00:00:00Z"', null],
67
+ ['d"2024-10-30T16:30:57.890Z"', null],
67
68
  ['d"2027-05-03T11:30:12.345+01"', null],
68
69
  ]) {
69
70
  const cpon1 = lst[0];
package/test_json.ts ADDED
@@ -0,0 +1,44 @@
1
+ import { toCpon } from "./src/cpon";
2
+ import { fromJson, toJson } from "./src/json";
3
+ import {makeIMap, makeMap, makeMetaMap, RpcValue, RpcValueWithMetaData, withOffset} from "./src/rpcvalue";
4
+
5
+ const testJsonRoundTrip = (json: string, expected: RpcValue) => {
6
+ const parsed = fromJson(json);
7
+ if (parsed instanceof Error) {
8
+ throw parsed;
9
+ }
10
+ const parsedCpon = toCpon(parsed);
11
+ const expectedCpon = toCpon(expected);
12
+ if (parsedCpon !== expectedCpon) {
13
+ throw new Error(`Assertion error:\nExpected: '${expectedCpon}'\nActual: '${parsedCpon}'`);
14
+ }
15
+
16
+ const serializedJson = toJson(expected);
17
+ if (serializedJson !== json.replaceAll(' ', '')) {
18
+ throw new Error(`Assertion error:\nExpected: '${json}'\nActual: '${serializedJson}'`);
19
+ }
20
+
21
+ };
22
+
23
+ const MINUTE = 1;
24
+ const HOUR = 60 * MINUTE;
25
+
26
+ const tests = [
27
+ ['{}', makeMap({})],
28
+ ['{"null-field": null}', makeMap({"null-field": undefined})],
29
+ ['["!shvType", "Blob", ""]', new Uint8Array().buffer],
30
+ ['["!shvType", "Blob", "6162a1"]', new Uint8Array([0x61, 0x62, 0xa1]).buffer],
31
+ ['["!shvType", "IMap", {}]', makeIMap({})],
32
+ ['["!shvType", "IMap", {"1": 2}]', makeIMap({1: 2})],
33
+ ['["!shvType", "IMap", {"1": 2, "2": "foo"}]', makeIMap({1: 2, 2: "foo"})],
34
+ ['["!shvType", "DateTime", "2021-11-08T01:02:03+05"]', withOffset(new Date("2021-11-08T01:02:03+05:00"), 5 * HOUR)],
35
+ ['["!shvType", "DateTime", "2021-11-08T01:02:03-0815"]', withOffset(new Date("2021-11-08T01:02:03-08:15"), -8 * HOUR - 15 * MINUTE)],
36
+ ['["!shvType", "DateTime", "2021-11-08T01:02:03.456-0815"]', withOffset(new Date("2021-11-08T01:02:03.456-08:15"), -8 * HOUR - 15 * MINUTE)],
37
+ ['["!shvMeta", {"1":2}, 42]', new RpcValueWithMetaData(makeMetaMap({1: 2}), 42)],
38
+ ['["!shvMeta", {"1":2}, "!shvType", "IMap", {"42": 7}]', new RpcValueWithMetaData(makeMetaMap({1: 2}), makeIMap({42: 7}))],
39
+ ['["!shvMeta", {"1": 2, "foo": "bar"}, [1,2,3]]', new RpcValueWithMetaData(makeMetaMap({1: 2, "foo": "bar"}), [1, 2, 3])],
40
+ ] as const;
41
+
42
+ tests.forEach(([inputJson, expectedRpcValue]) => testJsonRoundTrip(inputJson, expectedRpcValue));
43
+
44
+ console.log('OK');