@genrojs/tytx 0.16.1
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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +334 -0
- package/js/README.md +79 -0
- package/js/src/decode.js +128 -0
- package/js/src/encode.js +162 -0
- package/js/src/http.js +101 -0
- package/js/src/index.d.ts +38 -0
- package/js/src/index.js +63 -0
- package/js/src/msgpack.js +185 -0
- package/js/src/platform/dependencies.js +12 -0
- package/js/src/qs.js +98 -0
- package/js/src/registry.js +463 -0
- package/js/src/utils.js +164 -0
- package/js/src/xml.js +228 -0
- package/package.json +52 -0
package/js/src/utils.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
|
|
2
|
+
/**
|
|
3
|
+
* TYTX Utilities.
|
|
4
|
+
*
|
|
5
|
+
* Provides:
|
|
6
|
+
* - walk: recursive data structure transformation
|
|
7
|
+
* - tytxEquivalent: semantic equivalence for roundtrip testing
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { getTypeEntry, SUFFIX_TO_TYPE } from './registry.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Encode a scalar value to TYTX string with suffix.
|
|
14
|
+
*
|
|
15
|
+
* @param {any} value - JavaScript scalar value (Decimal, Date, boolean, number)
|
|
16
|
+
* @param {boolean} forceSuffix - If true, force suffix for all types (including int/bool/float)
|
|
17
|
+
* @returns {[boolean, string]} [encoded, result]
|
|
18
|
+
* - (true, "serialized::SUFFIX") if type is registered and needs suffix
|
|
19
|
+
* - (false, String(value)) if type not registered or jsonNative without force
|
|
20
|
+
*/
|
|
21
|
+
function rawEncode(value, forceSuffix = false) {
|
|
22
|
+
const entry = getTypeEntry(value);
|
|
23
|
+
if (entry === null) {
|
|
24
|
+
return [false, String(value)];
|
|
25
|
+
}
|
|
26
|
+
const [suffix, serializer, jsonNative] = entry;
|
|
27
|
+
if (jsonNative && !forceSuffix) {
|
|
28
|
+
return [false, String(value)];
|
|
29
|
+
}
|
|
30
|
+
return [true, `${serializer(value)}::${suffix}`];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Decode a string with TYTX suffix.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} s - String possibly ending with ::XX where XX is a registered suffix
|
|
37
|
+
* @returns {[boolean, any]} [decoded, value]
|
|
38
|
+
* - (true, decodedValue) if suffix found and decoded
|
|
39
|
+
* - (false, originalValue) if no valid suffix
|
|
40
|
+
*/
|
|
41
|
+
function rawDecode(s) {
|
|
42
|
+
if (!s.includes('::')) {
|
|
43
|
+
return [false, s];
|
|
44
|
+
}
|
|
45
|
+
const lastIndex = s.lastIndexOf('::');
|
|
46
|
+
const value = s.slice(0, lastIndex);
|
|
47
|
+
const suffix = s.slice(lastIndex + 2);
|
|
48
|
+
const entry = SUFFIX_TO_TYPE[suffix];
|
|
49
|
+
if (entry === undefined) {
|
|
50
|
+
return [false, s];
|
|
51
|
+
}
|
|
52
|
+
const [, decoder] = entry;
|
|
53
|
+
return [true, decoder(value)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Walk a data structure and apply callback to values matching filtercb.
|
|
58
|
+
*
|
|
59
|
+
* @param {any} data - Data structure to walk
|
|
60
|
+
* @param {function} callback - Function to apply to matching values
|
|
61
|
+
* @param {function} filtercb - Filter function. Applies callback when filtercb(value) is true.
|
|
62
|
+
* @returns {any} Transformed data
|
|
63
|
+
*/
|
|
64
|
+
function walk(data, callback, filtercb) {
|
|
65
|
+
if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
|
|
66
|
+
const result = {};
|
|
67
|
+
for (const [k, v] of Object.entries(data)) {
|
|
68
|
+
result[k] = walk(v, callback, filtercb);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
if (Array.isArray(data)) {
|
|
73
|
+
return data.map(item => walk(item, callback, filtercb));
|
|
74
|
+
}
|
|
75
|
+
if (filtercb(data)) {
|
|
76
|
+
return callback(data);
|
|
77
|
+
}
|
|
78
|
+
return data;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Truncate Date to milliseconds (TYTX precision).
|
|
83
|
+
* @param {Date} dt
|
|
84
|
+
* @returns {Date}
|
|
85
|
+
*/
|
|
86
|
+
function _truncateToMillis(dt) {
|
|
87
|
+
// JavaScript Date already has millisecond precision, no truncation needed
|
|
88
|
+
return dt;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if two Dates represent the same instant in time.
|
|
93
|
+
*
|
|
94
|
+
* TYTX serializes all datetimes as UTC (DHZ) with millisecond precision.
|
|
95
|
+
* This function handles the semantic equivalence for roundtrip comparison.
|
|
96
|
+
*
|
|
97
|
+
* @param {Date} a - Original Date (before roundtrip)
|
|
98
|
+
* @param {Date} b - Decoded Date (after roundtrip)
|
|
99
|
+
* @returns {boolean} True if both represent the same instant in time
|
|
100
|
+
*/
|
|
101
|
+
function datetimeEquivalent(a, b) {
|
|
102
|
+
// JavaScript Dates are always comparable directly in milliseconds
|
|
103
|
+
return a.getTime() === b.getTime();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Check if two values are semantically equivalent after TYTX roundtrip.
|
|
108
|
+
*
|
|
109
|
+
* Handles special cases:
|
|
110
|
+
* - Date: timestamp equivalence
|
|
111
|
+
* - dict/list: recursive comparison
|
|
112
|
+
* - other types: standard equality
|
|
113
|
+
*
|
|
114
|
+
* @param {any} a - Original value (before roundtrip)
|
|
115
|
+
* @param {any} b - Decoded value (after roundtrip)
|
|
116
|
+
* @returns {boolean} True if values are semantically equivalent
|
|
117
|
+
*/
|
|
118
|
+
function tytxEquivalent(a, b) {
|
|
119
|
+
// Fast path: identical values
|
|
120
|
+
if (a === b) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Date special case
|
|
125
|
+
if (a instanceof Date && b instanceof Date) {
|
|
126
|
+
return datetimeEquivalent(a, b);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// dict: recursive comparison (needed to find nested Dates)
|
|
130
|
+
if (a !== null && typeof a === 'object' && !Array.isArray(a) &&
|
|
131
|
+
b !== null && typeof b === 'object' && !Array.isArray(b)) {
|
|
132
|
+
const keysA = Object.keys(a);
|
|
133
|
+
const keysB = Object.keys(b);
|
|
134
|
+
if (keysA.length !== keysB.length) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const keysSetB = new Set(keysB);
|
|
138
|
+
for (const k of keysA) {
|
|
139
|
+
if (!keysSetB.has(k)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return keysA.every(k => tytxEquivalent(a[k], b[k]));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// list: recursive comparison (needed to find nested Dates)
|
|
147
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
148
|
+
if (a.length !== b.length) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return a.every((ai, i) => tytxEquivalent(ai, b[i]));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export {
|
|
158
|
+
rawEncode,
|
|
159
|
+
rawDecode,
|
|
160
|
+
walk,
|
|
161
|
+
datetimeEquivalent,
|
|
162
|
+
tytxEquivalent,
|
|
163
|
+
_truncateToMillis,
|
|
164
|
+
};
|
package/js/src/xml.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - Licensed under Apache License 2.0
|
|
2
|
+
/**
|
|
3
|
+
* TYTX XML Encoding/Decoding.
|
|
4
|
+
*
|
|
5
|
+
* XML format follows the structure:
|
|
6
|
+
* {"tag": {"attrs": {...}, "value": ...}}
|
|
7
|
+
*
|
|
8
|
+
* Where:
|
|
9
|
+
* - attrs: dict of attributes (hydrated with type suffixes)
|
|
10
|
+
* - value: scalar, dict of children, list, or null
|
|
11
|
+
*
|
|
12
|
+
* Type suffixes are used in both text content and attributes:
|
|
13
|
+
* <price>100.50::N</price>
|
|
14
|
+
* <order id="123::L" created="2025-01-15::D">...</order>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { toTytx } from './encode.js';
|
|
18
|
+
import { fromTytx } from './decode.js';
|
|
19
|
+
import { NodeDOMParser, NodeXMLSerializer } from './platform/dependencies.js';
|
|
20
|
+
|
|
21
|
+
// XML DOM support - use @xmldom/xmldom for Node.js
|
|
22
|
+
let DOMParser, XMLSerializer;
|
|
23
|
+
if (typeof window !== 'undefined' && window.DOMParser) {
|
|
24
|
+
// Browser environment
|
|
25
|
+
DOMParser = window.DOMParser;
|
|
26
|
+
XMLSerializer = window.XMLSerializer;
|
|
27
|
+
} else {
|
|
28
|
+
DOMParser = NodeDOMParser;
|
|
29
|
+
XMLSerializer = NodeXMLSerializer;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if item is a valid XML element format: {tag: {"value": ...}}
|
|
34
|
+
*
|
|
35
|
+
* A valid XML element is a dict with exactly one key (the tag),
|
|
36
|
+
* whose value is a dict containing at least a "value" key.
|
|
37
|
+
*
|
|
38
|
+
* @param {any} item
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function _isXmlElement(item) {
|
|
42
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const keys = Object.keys(item);
|
|
46
|
+
if (keys.length !== 1) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
const itemData = item[keys[0]];
|
|
50
|
+
return itemData !== null && typeof itemData === 'object' && 'value' in itemData;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Serialize a dict with 'value' key (and optional 'attrs') to XML element.
|
|
55
|
+
*
|
|
56
|
+
* @param {Document} doc - XML Document
|
|
57
|
+
* @param {string} tag - Element tag name
|
|
58
|
+
* @param {Object} data - Dict with 'value' key and optional 'attrs' key
|
|
59
|
+
* @returns {Element} XML Element
|
|
60
|
+
*/
|
|
61
|
+
function _serializeElement(doc, tag, data) {
|
|
62
|
+
|
|
63
|
+
const element = doc.createElement(tag);
|
|
64
|
+
|
|
65
|
+
const attrs = data.attrs || {};
|
|
66
|
+
const value = data.value;
|
|
67
|
+
|
|
68
|
+
// Set attributes
|
|
69
|
+
for (const [attrName, attrValue] of Object.entries(attrs)) {
|
|
70
|
+
element.setAttribute(attrName, toTytx(attrValue, null, { _forceSuffix: true }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Set value
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
// List of children
|
|
76
|
+
for (const item of value) {
|
|
77
|
+
if (_isXmlElement(item)) {
|
|
78
|
+
const [itemTag] = Object.keys(item);
|
|
79
|
+
const itemData = item[itemTag];
|
|
80
|
+
const childElement = _serializeElement(doc, itemTag, itemData);
|
|
81
|
+
element.appendChild(childElement);
|
|
82
|
+
} else {
|
|
83
|
+
element.textContent = toTytx(value);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
element.textContent = toTytx(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return element;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Encode a JavaScript value to TYTX XML string.
|
|
96
|
+
*
|
|
97
|
+
* @param {any} value - Data to encode
|
|
98
|
+
* @returns {string} XML string with typed values marked
|
|
99
|
+
*/
|
|
100
|
+
function toXml(value) {
|
|
101
|
+
if (!DOMParser) {
|
|
102
|
+
throw new Error('XML support requires @xmldom/xmldom package in Node.js');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
// Check if value is valid XML element format
|
|
107
|
+
if (_isXmlElement(value)) {
|
|
108
|
+
// Valid XML format: {tag: {"value": ...}}
|
|
109
|
+
const [rootTag] = Object.keys(value);
|
|
110
|
+
const rootData = value[rootTag];
|
|
111
|
+
|
|
112
|
+
const doc = new DOMParser().parseFromString('<root/>', 'text/xml');
|
|
113
|
+
const element = _serializeElement(doc, rootTag, rootData);
|
|
114
|
+
|
|
115
|
+
const serializer = new XMLSerializer();
|
|
116
|
+
return serializer.serializeToString(element);
|
|
117
|
+
} else {
|
|
118
|
+
// Not valid XML format: serialize as JSON
|
|
119
|
+
return toTytx(value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Deserialize XML element to dict with 'attrs' and 'value' keys.
|
|
125
|
+
*
|
|
126
|
+
* @param {Element} element
|
|
127
|
+
* @returns {Object} Dict with 'attrs' and 'value' keys
|
|
128
|
+
*/
|
|
129
|
+
function fromXmlnode(element) {
|
|
130
|
+
|
|
131
|
+
// Hydrate attributes
|
|
132
|
+
const attrs = {};
|
|
133
|
+
for (let i = 0; i < element.attributes.length; i++) {
|
|
134
|
+
const attr = element.attributes[i];
|
|
135
|
+
attrs[attr.name] = fromTytx(attr.value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Process children
|
|
139
|
+
const children = [];
|
|
140
|
+
for (let i = 0; i < element.childNodes.length; i++) {
|
|
141
|
+
const node = element.childNodes[i];
|
|
142
|
+
if (node.nodeType === 1) { // ELEMENT_NODE
|
|
143
|
+
children.push(node);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (children.length > 0) {
|
|
148
|
+
if (children.length === 1) {
|
|
149
|
+
// Single child: return as dict {tag: {...}}
|
|
150
|
+
const child = children[0];
|
|
151
|
+
const childData = fromXmlnode(child);
|
|
152
|
+
return { attrs, value: { [child.tagName]: childData } };
|
|
153
|
+
} else {
|
|
154
|
+
// Multiple children: return as list [{tag: {...}}, ...]
|
|
155
|
+
const valueList = [];
|
|
156
|
+
for (const child of children) {
|
|
157
|
+
const childData = fromXmlnode(child);
|
|
158
|
+
valueList.push({ [child.tagName]: childData });
|
|
159
|
+
}
|
|
160
|
+
return { attrs, value: valueList };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Leaf node
|
|
165
|
+
return { attrs, value: fromTytx(element.textContent) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Decode a TYTX XML string to JavaScript value.
|
|
170
|
+
*
|
|
171
|
+
* If the root element is 'tytx_root', it is automatically unwrapped
|
|
172
|
+
* and the inner value is returned directly.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} data - XML string with typed values
|
|
175
|
+
* @returns {Object|any} If root is 'tytx_root': the unwrapped value.
|
|
176
|
+
* Otherwise: Dict in format {"tag": {"attrs": {...}, "value": ...}}
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* fromXml('<order id="123::L"><total>100.50::N</total></order>')
|
|
180
|
+
* // {
|
|
181
|
+
* // "order": {
|
|
182
|
+
* // "attrs": {"id": 123},
|
|
183
|
+
* // "value": {"total": {"attrs": {}, "value": Decimal("100.50")}}
|
|
184
|
+
* // }
|
|
185
|
+
* // }
|
|
186
|
+
*
|
|
187
|
+
* fromXml('<tytx_root><price>100.50::N</price></tytx_root>')
|
|
188
|
+
* // {"price": {"attrs": {}, "value": Decimal("100.50")}}
|
|
189
|
+
*/
|
|
190
|
+
function fromXml(data) {
|
|
191
|
+
if (!DOMParser) {
|
|
192
|
+
throw new Error('XML support requires @xmldom/xmldom package in Node.js');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
const parser = new DOMParser();
|
|
197
|
+
const doc = parser.parseFromString(data, 'text/xml');
|
|
198
|
+
let root = doc.documentElement;
|
|
199
|
+
|
|
200
|
+
// Unwrap tytx_root: work on inner content
|
|
201
|
+
if (root.tagName === 'tytx_root') {
|
|
202
|
+
// Get first element child
|
|
203
|
+
let firstElementChild = null;
|
|
204
|
+
for (let i = 0; i < root.childNodes.length; i++) {
|
|
205
|
+
if (root.childNodes[i].nodeType === 1) {
|
|
206
|
+
firstElementChild = root.childNodes[i];
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!firstElementChild) {
|
|
212
|
+
return fromTytx(root.textContent);
|
|
213
|
+
}
|
|
214
|
+
root = firstElementChild;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// From here: root is the real node
|
|
218
|
+
const result = fromXmlnode(root);
|
|
219
|
+
return { [root.tagName]: result };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export {
|
|
223
|
+
toXml,
|
|
224
|
+
fromXml,
|
|
225
|
+
fromXmlnode,
|
|
226
|
+
_isXmlElement,
|
|
227
|
+
_serializeElement,
|
|
228
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@genrojs/tytx",
|
|
3
|
+
"version": "0.16.1",
|
|
4
|
+
"description": "Typed data interchange between Python and JavaScript over JSON, XML and MessagePack: Decimal, dates and custom types arrive with their type.",
|
|
5
|
+
"main": "js/src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./js/src/index.d.ts",
|
|
10
|
+
"default": "./js/src/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node --test js/test/test_extended.js js/test/test_registry.js js/test/test_registry_lookup.js",
|
|
15
|
+
"test:all": "node --test js/test/test_*.js",
|
|
16
|
+
"test:browser": "node --test js/test/test_browser_bundle.js"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"tytx",
|
|
20
|
+
"typed",
|
|
21
|
+
"serialization",
|
|
22
|
+
"json",
|
|
23
|
+
"xml",
|
|
24
|
+
"msgpack"
|
|
25
|
+
],
|
|
26
|
+
"author": "Genropy Team",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/genro-org/genro-tytx"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"esbuild": "^0.27.2"
|
|
34
|
+
},
|
|
35
|
+
"types": "./js/src/index.d.ts",
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@xmldom/xmldom": "^0.9.12",
|
|
38
|
+
"big.js": "^6.2.1",
|
|
39
|
+
"decimal.js": "^10.4.3",
|
|
40
|
+
"@msgpack/msgpack": "^3.1.3"
|
|
41
|
+
},
|
|
42
|
+
"files": [
|
|
43
|
+
"js/src/**/*.js",
|
|
44
|
+
"js/src/**/*.d.ts",
|
|
45
|
+
"README.md",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"NOTICE"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|