libshv-js 3.4.2 → 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.
- package/.github/workflows/test.yml +4 -2
- package/package.json +2 -1
- package/src/cpon.ts +48 -52
- package/src/json.ts +136 -0
- package/src/zod.ts +3 -2
- package/test_json.ts +44 -0
|
@@ -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@
|
|
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:
|
|
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.
|
|
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/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,11 +345,6 @@ 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;
|
|
@@ -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
|
-
|
|
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
|
-
|
|
27
|
+
list(),
|
|
27
28
|
map(),
|
|
28
29
|
imap(),
|
|
29
30
|
withMetaInstanceParser,
|
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');
|