@ayepi/codec 0.1.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.cjs +194 -0
- package/dist/index.d.cts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +189 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Philip Diffenderfer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @ayepi/codec
|
|
2
|
+
|
|
3
|
+
Rich, reversible JSON. A **tagged codec** that round-trips the values plain
|
|
4
|
+
`JSON.stringify`/`parse` silently lose or mangle — `Date`, `BigInt`, `Map`, `Set`,
|
|
5
|
+
`undefined`, `Error`, `RegExp`, `URL`, and the number specials `NaN`/`±Infinity` —
|
|
6
|
+
plus any custom types you register. Zero dependencies.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pnpm add @ayepi/codec
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { stringify, parse } from '@ayepi/codec'
|
|
14
|
+
|
|
15
|
+
const s = stringify({ when: new Date(), ids: new Set([1n, 2n]), missing: undefined })
|
|
16
|
+
const value = parse(s)
|
|
17
|
+
// value.when → Date
|
|
18
|
+
// value.ids → Set<bigint>
|
|
19
|
+
// value.missing === undefined (preserved, not dropped)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Bare `import` has **no side effects**.
|
|
23
|
+
|
|
24
|
+
## How it works
|
|
25
|
+
|
|
26
|
+
A value a codec handles is wrapped as `{ "$t": "<tag>", "value": <payload> }`.
|
|
27
|
+
Encoding/decoding is **recursive**, so a `Map` of `Date`s, a `Set` of objects, and
|
|
28
|
+
deeply nested mixes all round-trip. Ordinary JSON values (`null`, booleans, finite
|
|
29
|
+
numbers, strings, plain arrays/objects) pass through untouched.
|
|
30
|
+
|
|
31
|
+
If a *plain* object legitimately owns a `$t` property, it is **escaped** so it still
|
|
32
|
+
round-trips:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
parse(stringify({ $t: 'hello' })) // → { $t: 'hello' } ✅
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Custom types
|
|
39
|
+
|
|
40
|
+
Register a `TypeCodec` (a `tag`, a `test`, and `encode`/`decode`):
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createCodec } from '@ayepi/codec'
|
|
44
|
+
|
|
45
|
+
class Point { constructor(public x: number, public y: number) {} }
|
|
46
|
+
|
|
47
|
+
const codec = createCodec({
|
|
48
|
+
types: [{
|
|
49
|
+
tag: 'Point',
|
|
50
|
+
test: (v) => v instanceof Point,
|
|
51
|
+
encode: (p: Point) => [p.x, p.y],
|
|
52
|
+
decode: ([x, y]: [number, number]) => new Point(x, y),
|
|
53
|
+
}],
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
codec.parse(codec.stringify(new Point(3, 4))) // → Point { x: 3, y: 4 }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Custom types are checked **before** the built-ins, so a custom codec can override one
|
|
60
|
+
(e.g. store `Date` as epoch millis). Pass `replaceBuiltins: true` to drop the built-ins
|
|
61
|
+
entirely, or `tagKey` to change the sentinel key from `'$t'`.
|
|
62
|
+
|
|
63
|
+
`encode`/`decode` are also exposed standalone (value ⇄ JSON-safe value, no string step).
|
|
64
|
+
|
|
65
|
+
> No circular-reference support — a cycle overflows the stack, exactly like
|
|
66
|
+
> `JSON.stringify`.
|
|
67
|
+
|
|
68
|
+
## For AI coding agents
|
|
69
|
+
|
|
70
|
+
This package ships dense, machine-oriented reference docs written for **AI coding agents**
|
|
71
|
+
(Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
|
|
72
|
+
|
|
73
|
+
- [`ayepi-codec.md`](./ayepi-codec.md)
|
|
74
|
+
|
|
75
|
+
They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/codec) and are **not** shipped in the npm tarball.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT © Philip Diffenderfer
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/builtins.ts
|
|
3
|
+
/** `{@link Number}` payloads are the IEEE-754 specials JSON renders as `null`. */
|
|
4
|
+
const NEGATIVE_INFINITY = "-Infinity";
|
|
5
|
+
const POSITIVE_INFINITY = "Infinity";
|
|
6
|
+
const NOT_A_NUMBER = "NaN";
|
|
7
|
+
/**
|
|
8
|
+
* The default codecs, in match order. They are ordered most-specific first; note that
|
|
9
|
+
* `Map`/`Set`/`Date`/`Error`/`RegExp`/`URL` are mutually exclusive `instanceof`
|
|
10
|
+
* checks, so order among them is immaterial.
|
|
11
|
+
*/
|
|
12
|
+
const builtinTypes = [
|
|
13
|
+
{
|
|
14
|
+
tag: "undefined",
|
|
15
|
+
test: (value) => value === void 0,
|
|
16
|
+
encode: () => 0,
|
|
17
|
+
decode: () => void 0
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
tag: "Number",
|
|
21
|
+
test: (value) => typeof value === "number" && !Number.isFinite(value),
|
|
22
|
+
encode: (value) => {
|
|
23
|
+
if (Number.isNaN(value)) return NOT_A_NUMBER;
|
|
24
|
+
return value > 0 ? POSITIVE_INFINITY : NEGATIVE_INFINITY;
|
|
25
|
+
},
|
|
26
|
+
decode: (payload) => {
|
|
27
|
+
if (payload === NOT_A_NUMBER) return NaN;
|
|
28
|
+
return payload === POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
tag: "BigInt",
|
|
33
|
+
test: (value) => typeof value === "bigint",
|
|
34
|
+
encode: (value) => value.toString(),
|
|
35
|
+
decode: (payload) => BigInt(payload)
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
tag: "Date",
|
|
39
|
+
test: (value) => value instanceof Date,
|
|
40
|
+
encode: (value) => value.toISOString(),
|
|
41
|
+
decode: (payload) => new Date(payload)
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
tag: "Map",
|
|
45
|
+
test: (value) => value instanceof Map,
|
|
46
|
+
encode: (value, recurse) => [...value.entries()].map(([k, v]) => [recurse(k), recurse(v)]),
|
|
47
|
+
decode: (payload, recurse) => new Map(payload.map(([k, v]) => [recurse(k), recurse(v)]))
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
tag: "Set",
|
|
51
|
+
test: (value) => value instanceof Set,
|
|
52
|
+
encode: (value, recurse) => [...value.values()].map((v) => recurse(v)),
|
|
53
|
+
decode: (payload, recurse) => new Set(payload.map((v) => recurse(v)))
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
tag: "Error",
|
|
57
|
+
test: (value) => value instanceof Error,
|
|
58
|
+
encode: (value) => {
|
|
59
|
+
return {
|
|
60
|
+
name: value.name,
|
|
61
|
+
message: value.message,
|
|
62
|
+
stack: value.stack
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
decode: (payload) => {
|
|
66
|
+
const data = payload;
|
|
67
|
+
const error = new Error(data.message);
|
|
68
|
+
error.name = data.name;
|
|
69
|
+
if (data.stack === void 0) delete error.stack;
|
|
70
|
+
else error.stack = data.stack;
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
tag: "RegExp",
|
|
76
|
+
test: (value) => value instanceof RegExp,
|
|
77
|
+
encode: (value) => {
|
|
78
|
+
return {
|
|
79
|
+
source: value.source,
|
|
80
|
+
flags: value.flags
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
decode: (payload) => {
|
|
84
|
+
const data = payload;
|
|
85
|
+
return new RegExp(data.source, data.flags);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
tag: "URL",
|
|
90
|
+
test: (value) => value instanceof URL,
|
|
91
|
+
encode: (value) => value.href,
|
|
92
|
+
decode: (payload) => new URL(payload)
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/codec.ts
|
|
97
|
+
/**
|
|
98
|
+
* # The codec engine
|
|
99
|
+
*
|
|
100
|
+
* {@link createCodec} composes a list of {@link TypeCodec}s into a {@link Codec} that
|
|
101
|
+
* recursively encodes/decodes values to and from a JSON-safe shape.
|
|
102
|
+
*
|
|
103
|
+
* ## Wire format
|
|
104
|
+
*
|
|
105
|
+
* A value handled by a {@link TypeCodec} is wrapped as
|
|
106
|
+
* `{ [tagKey]: tag, value: <payload> }`. Plain objects and arrays are walked and their
|
|
107
|
+
* entries encoded recursively. Everything else (`null`, booleans, finite numbers,
|
|
108
|
+
* strings) passes through.
|
|
109
|
+
*
|
|
110
|
+
* ## Collision escape
|
|
111
|
+
*
|
|
112
|
+
* If a *plain* user object already owns a property equal to `tagKey`, it would be
|
|
113
|
+
* indistinguishable from a wrapper. Such objects are escaped as
|
|
114
|
+
* `{ [tagKey]: ESCAPE_TAG, value: <encoded plain object> }` so they round-trip
|
|
115
|
+
* losslessly. Decoding recognizes the escape tag and unwraps it.
|
|
116
|
+
*
|
|
117
|
+
* Circular references are **not** supported and will overflow the stack, exactly as
|
|
118
|
+
* with `JSON.stringify`.
|
|
119
|
+
*
|
|
120
|
+
* @module
|
|
121
|
+
*/
|
|
122
|
+
/** Default sentinel key for the tagged wrapper. */
|
|
123
|
+
const DEFAULT_TAG_KEY = "$t";
|
|
124
|
+
/** Reserved tag used to escape plain objects that collide with the {@link tagKey}. */
|
|
125
|
+
const ESCAPE_TAG = "$escape";
|
|
126
|
+
/** Whether `value` is a non-null, non-array object (a candidate for walking). */
|
|
127
|
+
const isPlainObjectLike = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
128
|
+
/**
|
|
129
|
+
* Build a {@link Codec} from the built-in type codecs plus any custom {@link TypeCodec}s.
|
|
130
|
+
*
|
|
131
|
+
* @param options - custom types, built-in replacement, and the wrapper tag key.
|
|
132
|
+
* @returns a codec exposing `stringify`/`parse`/`encode`/`decode`.
|
|
133
|
+
*/
|
|
134
|
+
function createCodec(options = {}) {
|
|
135
|
+
const { types = [], replaceBuiltins = false, tagKey = DEFAULT_TAG_KEY } = options;
|
|
136
|
+
const codecs = replaceBuiltins ? types : [...types, ...builtinTypes];
|
|
137
|
+
const byTag = new Map(codecs.map((codec) => [codec.tag, codec]));
|
|
138
|
+
/** Recursively encode `value` into its JSON-safe shape. */
|
|
139
|
+
const encode = (value) => {
|
|
140
|
+
for (const codec of codecs) if (codec.test(value)) return {
|
|
141
|
+
[tagKey]: codec.tag,
|
|
142
|
+
value: codec.encode(value, encode)
|
|
143
|
+
};
|
|
144
|
+
if (Array.isArray(value)) return value.map((item) => encode(item));
|
|
145
|
+
if (isPlainObjectLike(value)) {
|
|
146
|
+
const encoded = {};
|
|
147
|
+
for (const [key, inner] of Object.entries(value)) encoded[key] = encode(inner);
|
|
148
|
+
if (Object.prototype.hasOwnProperty.call(value, tagKey)) return {
|
|
149
|
+
[tagKey]: ESCAPE_TAG,
|
|
150
|
+
value: encoded
|
|
151
|
+
};
|
|
152
|
+
return encoded;
|
|
153
|
+
}
|
|
154
|
+
return value;
|
|
155
|
+
};
|
|
156
|
+
/** Decode the own properties of an (already-encoded) plain object, one by one. */
|
|
157
|
+
const decodeEntries = (value) => {
|
|
158
|
+
const decoded = {};
|
|
159
|
+
for (const [key, inner] of Object.entries(value)) decoded[key] = decode(inner);
|
|
160
|
+
return decoded;
|
|
161
|
+
};
|
|
162
|
+
/** Recursively decode a JSON-safe `value` back into a rich value. */
|
|
163
|
+
const decode = (value) => {
|
|
164
|
+
if (Array.isArray(value)) return value.map((item) => decode(item));
|
|
165
|
+
if (!isPlainObjectLike(value)) return value;
|
|
166
|
+
if (Object.prototype.hasOwnProperty.call(value, tagKey)) {
|
|
167
|
+
const tag = value[tagKey];
|
|
168
|
+
if (tag === ESCAPE_TAG) return decodeEntries(value.value);
|
|
169
|
+
const codec = typeof tag === "string" ? byTag.get(tag) : void 0;
|
|
170
|
+
if (codec !== void 0) return codec.decode(value.value, decode);
|
|
171
|
+
}
|
|
172
|
+
return decodeEntries(value);
|
|
173
|
+
};
|
|
174
|
+
return {
|
|
175
|
+
encode,
|
|
176
|
+
decode,
|
|
177
|
+
stringify: (value) => JSON.stringify(encode(value)),
|
|
178
|
+
parse: (text) => decode(JSON.parse(text))
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/index.ts
|
|
183
|
+
/** A ready-to-use {@link Codec} with the default built-in types and tag key (`'$t'`). */
|
|
184
|
+
const defaultCodec = createCodec();
|
|
185
|
+
/** Encode + `JSON.stringify` a value, bound to {@link defaultCodec}. */
|
|
186
|
+
const stringify = (value) => defaultCodec.stringify(value);
|
|
187
|
+
/** `JSON.parse` + decode a string, bound to {@link defaultCodec}. */
|
|
188
|
+
const parse = (text) => defaultCodec.parse(text);
|
|
189
|
+
//#endregion
|
|
190
|
+
exports.builtinTypes = builtinTypes;
|
|
191
|
+
exports.createCodec = createCodec;
|
|
192
|
+
exports.defaultCodec = defaultCodec;
|
|
193
|
+
exports.parse = parse;
|
|
194
|
+
exports.stringify = stringify;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # Public types for `@ayepi/codec`
|
|
4
|
+
*
|
|
5
|
+
* The codec is built from a list of {@link TypeCodec}s. Each one claims a slice of
|
|
6
|
+
* the value space ({@link TypeCodec.test}) and knows how to turn its values into a
|
|
7
|
+
* JSON-safe payload and back. {@link createCodec} composes a list into a working
|
|
8
|
+
* {@link Codec}.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Recurses into a nested value during encode/decode. A {@link TypeCodec} calls it on
|
|
14
|
+
* the inner values of a container (e.g. a `Map`'s entries) so that nested rich types
|
|
15
|
+
* are encoded/decoded too.
|
|
16
|
+
*/
|
|
17
|
+
type Recurse = (value: unknown) => unknown;
|
|
18
|
+
/**
|
|
19
|
+
* Handles one kind of value that plain JSON can't represent. A codec is registered by
|
|
20
|
+
* its {@link tag} and turns matching values into a JSON-safe payload (and back).
|
|
21
|
+
*
|
|
22
|
+
* @typeParam T - the value type this codec handles; defaults to `unknown`.
|
|
23
|
+
*/
|
|
24
|
+
interface TypeCodec<T = unknown> {
|
|
25
|
+
/** Unique short tag stored in the wire wrapper, e.g. `'Date'`. */
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
/** Whether this codec handles `value` (decides which codec encodes a value). */
|
|
28
|
+
test(value: unknown): boolean;
|
|
29
|
+
/** Turn `value` into a JSON-safe payload, using `recurse` for any nested values. */
|
|
30
|
+
encode(value: T, recurse: Recurse): unknown;
|
|
31
|
+
/** Rebuild the value from a payload produced by {@link encode}. */
|
|
32
|
+
decode(payload: unknown, recurse: Recurse): T;
|
|
33
|
+
}
|
|
34
|
+
/** Options for {@link createCodec}. */
|
|
35
|
+
interface CodecOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Custom type codecs. By default they are checked *before* the built-ins, so a
|
|
38
|
+
* custom codec can override a built-in; set {@link replaceBuiltins} to use them
|
|
39
|
+
* instead of the built-ins. Within the combined list, earlier codecs win when more
|
|
40
|
+
* than one matches a value.
|
|
41
|
+
*/
|
|
42
|
+
readonly types?: readonly TypeCodec[];
|
|
43
|
+
/**
|
|
44
|
+
* Replace the built-in codecs entirely with {@link types} rather than prepending.
|
|
45
|
+
* @defaultValue `false`
|
|
46
|
+
*/
|
|
47
|
+
readonly replaceBuiltins?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The sentinel key marking a tagged wrapper on the wire.
|
|
50
|
+
* @defaultValue `'$t'`
|
|
51
|
+
*/
|
|
52
|
+
readonly tagKey?: string;
|
|
53
|
+
}
|
|
54
|
+
/** A bidirectional rich-JSON codec produced by {@link createCodec}. */
|
|
55
|
+
interface Codec {
|
|
56
|
+
/** Encode then `JSON.stringify` a value to a string. */
|
|
57
|
+
stringify(value: unknown): string;
|
|
58
|
+
/** `JSON.parse` then decode a string back to a value. */
|
|
59
|
+
parse(text: string): unknown;
|
|
60
|
+
/** Encode a value to a JSON-safe value (no stringification). */
|
|
61
|
+
encode(value: unknown): unknown;
|
|
62
|
+
/** Decode a JSON-safe value back to a rich value (no parsing). */
|
|
63
|
+
decode(value: unknown): unknown;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/builtins.d.ts
|
|
67
|
+
/**
|
|
68
|
+
* The default codecs, in match order. They are ordered most-specific first; note that
|
|
69
|
+
* `Map`/`Set`/`Date`/`Error`/`RegExp`/`URL` are mutually exclusive `instanceof`
|
|
70
|
+
* checks, so order among them is immaterial.
|
|
71
|
+
*/
|
|
72
|
+
declare const builtinTypes: readonly TypeCodec[];
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/codec.d.ts
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a {@link Codec} from the built-in type codecs plus any custom {@link TypeCodec}s.
|
|
78
|
+
*
|
|
79
|
+
* @param options - custom types, built-in replacement, and the wrapper tag key.
|
|
80
|
+
* @returns a codec exposing `stringify`/`parse`/`encode`/`decode`.
|
|
81
|
+
*/
|
|
82
|
+
declare function createCodec(options?: CodecOptions): Codec;
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/index.d.ts
|
|
85
|
+
|
|
86
|
+
/** A ready-to-use {@link Codec} with the default built-in types and tag key (`'$t'`). */
|
|
87
|
+
declare const defaultCodec: Codec;
|
|
88
|
+
/** Encode + `JSON.stringify` a value, bound to {@link defaultCodec}. */
|
|
89
|
+
declare const stringify: (value: unknown) => string;
|
|
90
|
+
/** `JSON.parse` + decode a string, bound to {@link defaultCodec}. */
|
|
91
|
+
declare const parse: (text: string) => unknown;
|
|
92
|
+
//#endregion
|
|
93
|
+
export { type Codec, type CodecOptions, type Recurse, type TypeCodec, builtinTypes, createCodec, defaultCodec, parse, stringify };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* # Public types for `@ayepi/codec`
|
|
4
|
+
*
|
|
5
|
+
* The codec is built from a list of {@link TypeCodec}s. Each one claims a slice of
|
|
6
|
+
* the value space ({@link TypeCodec.test}) and knows how to turn its values into a
|
|
7
|
+
* JSON-safe payload and back. {@link createCodec} composes a list into a working
|
|
8
|
+
* {@link Codec}.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Recurses into a nested value during encode/decode. A {@link TypeCodec} calls it on
|
|
14
|
+
* the inner values of a container (e.g. a `Map`'s entries) so that nested rich types
|
|
15
|
+
* are encoded/decoded too.
|
|
16
|
+
*/
|
|
17
|
+
type Recurse = (value: unknown) => unknown;
|
|
18
|
+
/**
|
|
19
|
+
* Handles one kind of value that plain JSON can't represent. A codec is registered by
|
|
20
|
+
* its {@link tag} and turns matching values into a JSON-safe payload (and back).
|
|
21
|
+
*
|
|
22
|
+
* @typeParam T - the value type this codec handles; defaults to `unknown`.
|
|
23
|
+
*/
|
|
24
|
+
interface TypeCodec<T = unknown> {
|
|
25
|
+
/** Unique short tag stored in the wire wrapper, e.g. `'Date'`. */
|
|
26
|
+
readonly tag: string;
|
|
27
|
+
/** Whether this codec handles `value` (decides which codec encodes a value). */
|
|
28
|
+
test(value: unknown): boolean;
|
|
29
|
+
/** Turn `value` into a JSON-safe payload, using `recurse` for any nested values. */
|
|
30
|
+
encode(value: T, recurse: Recurse): unknown;
|
|
31
|
+
/** Rebuild the value from a payload produced by {@link encode}. */
|
|
32
|
+
decode(payload: unknown, recurse: Recurse): T;
|
|
33
|
+
}
|
|
34
|
+
/** Options for {@link createCodec}. */
|
|
35
|
+
interface CodecOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Custom type codecs. By default they are checked *before* the built-ins, so a
|
|
38
|
+
* custom codec can override a built-in; set {@link replaceBuiltins} to use them
|
|
39
|
+
* instead of the built-ins. Within the combined list, earlier codecs win when more
|
|
40
|
+
* than one matches a value.
|
|
41
|
+
*/
|
|
42
|
+
readonly types?: readonly TypeCodec[];
|
|
43
|
+
/**
|
|
44
|
+
* Replace the built-in codecs entirely with {@link types} rather than prepending.
|
|
45
|
+
* @defaultValue `false`
|
|
46
|
+
*/
|
|
47
|
+
readonly replaceBuiltins?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The sentinel key marking a tagged wrapper on the wire.
|
|
50
|
+
* @defaultValue `'$t'`
|
|
51
|
+
*/
|
|
52
|
+
readonly tagKey?: string;
|
|
53
|
+
}
|
|
54
|
+
/** A bidirectional rich-JSON codec produced by {@link createCodec}. */
|
|
55
|
+
interface Codec {
|
|
56
|
+
/** Encode then `JSON.stringify` a value to a string. */
|
|
57
|
+
stringify(value: unknown): string;
|
|
58
|
+
/** `JSON.parse` then decode a string back to a value. */
|
|
59
|
+
parse(text: string): unknown;
|
|
60
|
+
/** Encode a value to a JSON-safe value (no stringification). */
|
|
61
|
+
encode(value: unknown): unknown;
|
|
62
|
+
/** Decode a JSON-safe value back to a rich value (no parsing). */
|
|
63
|
+
decode(value: unknown): unknown;
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/builtins.d.ts
|
|
67
|
+
/**
|
|
68
|
+
* The default codecs, in match order. They are ordered most-specific first; note that
|
|
69
|
+
* `Map`/`Set`/`Date`/`Error`/`RegExp`/`URL` are mutually exclusive `instanceof`
|
|
70
|
+
* checks, so order among them is immaterial.
|
|
71
|
+
*/
|
|
72
|
+
declare const builtinTypes: readonly TypeCodec[];
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/codec.d.ts
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a {@link Codec} from the built-in type codecs plus any custom {@link TypeCodec}s.
|
|
78
|
+
*
|
|
79
|
+
* @param options - custom types, built-in replacement, and the wrapper tag key.
|
|
80
|
+
* @returns a codec exposing `stringify`/`parse`/`encode`/`decode`.
|
|
81
|
+
*/
|
|
82
|
+
declare function createCodec(options?: CodecOptions): Codec;
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/index.d.ts
|
|
85
|
+
|
|
86
|
+
/** A ready-to-use {@link Codec} with the default built-in types and tag key (`'$t'`). */
|
|
87
|
+
declare const defaultCodec: Codec;
|
|
88
|
+
/** Encode + `JSON.stringify` a value, bound to {@link defaultCodec}. */
|
|
89
|
+
declare const stringify: (value: unknown) => string;
|
|
90
|
+
/** `JSON.parse` + decode a string, bound to {@link defaultCodec}. */
|
|
91
|
+
declare const parse: (text: string) => unknown;
|
|
92
|
+
//#endregion
|
|
93
|
+
export { type Codec, type CodecOptions, type Recurse, type TypeCodec, builtinTypes, createCodec, defaultCodec, parse, stringify };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
//#region src/builtins.ts
|
|
2
|
+
/** `{@link Number}` payloads are the IEEE-754 specials JSON renders as `null`. */
|
|
3
|
+
const NEGATIVE_INFINITY = "-Infinity";
|
|
4
|
+
const POSITIVE_INFINITY = "Infinity";
|
|
5
|
+
const NOT_A_NUMBER = "NaN";
|
|
6
|
+
/**
|
|
7
|
+
* The default codecs, in match order. They are ordered most-specific first; note that
|
|
8
|
+
* `Map`/`Set`/`Date`/`Error`/`RegExp`/`URL` are mutually exclusive `instanceof`
|
|
9
|
+
* checks, so order among them is immaterial.
|
|
10
|
+
*/
|
|
11
|
+
const builtinTypes = [
|
|
12
|
+
{
|
|
13
|
+
tag: "undefined",
|
|
14
|
+
test: (value) => value === void 0,
|
|
15
|
+
encode: () => 0,
|
|
16
|
+
decode: () => void 0
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
tag: "Number",
|
|
20
|
+
test: (value) => typeof value === "number" && !Number.isFinite(value),
|
|
21
|
+
encode: (value) => {
|
|
22
|
+
if (Number.isNaN(value)) return NOT_A_NUMBER;
|
|
23
|
+
return value > 0 ? POSITIVE_INFINITY : NEGATIVE_INFINITY;
|
|
24
|
+
},
|
|
25
|
+
decode: (payload) => {
|
|
26
|
+
if (payload === NOT_A_NUMBER) return NaN;
|
|
27
|
+
return payload === POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
tag: "BigInt",
|
|
32
|
+
test: (value) => typeof value === "bigint",
|
|
33
|
+
encode: (value) => value.toString(),
|
|
34
|
+
decode: (payload) => BigInt(payload)
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
tag: "Date",
|
|
38
|
+
test: (value) => value instanceof Date,
|
|
39
|
+
encode: (value) => value.toISOString(),
|
|
40
|
+
decode: (payload) => new Date(payload)
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
tag: "Map",
|
|
44
|
+
test: (value) => value instanceof Map,
|
|
45
|
+
encode: (value, recurse) => [...value.entries()].map(([k, v]) => [recurse(k), recurse(v)]),
|
|
46
|
+
decode: (payload, recurse) => new Map(payload.map(([k, v]) => [recurse(k), recurse(v)]))
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
tag: "Set",
|
|
50
|
+
test: (value) => value instanceof Set,
|
|
51
|
+
encode: (value, recurse) => [...value.values()].map((v) => recurse(v)),
|
|
52
|
+
decode: (payload, recurse) => new Set(payload.map((v) => recurse(v)))
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tag: "Error",
|
|
56
|
+
test: (value) => value instanceof Error,
|
|
57
|
+
encode: (value) => {
|
|
58
|
+
return {
|
|
59
|
+
name: value.name,
|
|
60
|
+
message: value.message,
|
|
61
|
+
stack: value.stack
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
decode: (payload) => {
|
|
65
|
+
const data = payload;
|
|
66
|
+
const error = new Error(data.message);
|
|
67
|
+
error.name = data.name;
|
|
68
|
+
if (data.stack === void 0) delete error.stack;
|
|
69
|
+
else error.stack = data.stack;
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
tag: "RegExp",
|
|
75
|
+
test: (value) => value instanceof RegExp,
|
|
76
|
+
encode: (value) => {
|
|
77
|
+
return {
|
|
78
|
+
source: value.source,
|
|
79
|
+
flags: value.flags
|
|
80
|
+
};
|
|
81
|
+
},
|
|
82
|
+
decode: (payload) => {
|
|
83
|
+
const data = payload;
|
|
84
|
+
return new RegExp(data.source, data.flags);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
tag: "URL",
|
|
89
|
+
test: (value) => value instanceof URL,
|
|
90
|
+
encode: (value) => value.href,
|
|
91
|
+
decode: (payload) => new URL(payload)
|
|
92
|
+
}
|
|
93
|
+
];
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/codec.ts
|
|
96
|
+
/**
|
|
97
|
+
* # The codec engine
|
|
98
|
+
*
|
|
99
|
+
* {@link createCodec} composes a list of {@link TypeCodec}s into a {@link Codec} that
|
|
100
|
+
* recursively encodes/decodes values to and from a JSON-safe shape.
|
|
101
|
+
*
|
|
102
|
+
* ## Wire format
|
|
103
|
+
*
|
|
104
|
+
* A value handled by a {@link TypeCodec} is wrapped as
|
|
105
|
+
* `{ [tagKey]: tag, value: <payload> }`. Plain objects and arrays are walked and their
|
|
106
|
+
* entries encoded recursively. Everything else (`null`, booleans, finite numbers,
|
|
107
|
+
* strings) passes through.
|
|
108
|
+
*
|
|
109
|
+
* ## Collision escape
|
|
110
|
+
*
|
|
111
|
+
* If a *plain* user object already owns a property equal to `tagKey`, it would be
|
|
112
|
+
* indistinguishable from a wrapper. Such objects are escaped as
|
|
113
|
+
* `{ [tagKey]: ESCAPE_TAG, value: <encoded plain object> }` so they round-trip
|
|
114
|
+
* losslessly. Decoding recognizes the escape tag and unwraps it.
|
|
115
|
+
*
|
|
116
|
+
* Circular references are **not** supported and will overflow the stack, exactly as
|
|
117
|
+
* with `JSON.stringify`.
|
|
118
|
+
*
|
|
119
|
+
* @module
|
|
120
|
+
*/
|
|
121
|
+
/** Default sentinel key for the tagged wrapper. */
|
|
122
|
+
const DEFAULT_TAG_KEY = "$t";
|
|
123
|
+
/** Reserved tag used to escape plain objects that collide with the {@link tagKey}. */
|
|
124
|
+
const ESCAPE_TAG = "$escape";
|
|
125
|
+
/** Whether `value` is a non-null, non-array object (a candidate for walking). */
|
|
126
|
+
const isPlainObjectLike = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
127
|
+
/**
|
|
128
|
+
* Build a {@link Codec} from the built-in type codecs plus any custom {@link TypeCodec}s.
|
|
129
|
+
*
|
|
130
|
+
* @param options - custom types, built-in replacement, and the wrapper tag key.
|
|
131
|
+
* @returns a codec exposing `stringify`/`parse`/`encode`/`decode`.
|
|
132
|
+
*/
|
|
133
|
+
function createCodec(options = {}) {
|
|
134
|
+
const { types = [], replaceBuiltins = false, tagKey = DEFAULT_TAG_KEY } = options;
|
|
135
|
+
const codecs = replaceBuiltins ? types : [...types, ...builtinTypes];
|
|
136
|
+
const byTag = new Map(codecs.map((codec) => [codec.tag, codec]));
|
|
137
|
+
/** Recursively encode `value` into its JSON-safe shape. */
|
|
138
|
+
const encode = (value) => {
|
|
139
|
+
for (const codec of codecs) if (codec.test(value)) return {
|
|
140
|
+
[tagKey]: codec.tag,
|
|
141
|
+
value: codec.encode(value, encode)
|
|
142
|
+
};
|
|
143
|
+
if (Array.isArray(value)) return value.map((item) => encode(item));
|
|
144
|
+
if (isPlainObjectLike(value)) {
|
|
145
|
+
const encoded = {};
|
|
146
|
+
for (const [key, inner] of Object.entries(value)) encoded[key] = encode(inner);
|
|
147
|
+
if (Object.prototype.hasOwnProperty.call(value, tagKey)) return {
|
|
148
|
+
[tagKey]: ESCAPE_TAG,
|
|
149
|
+
value: encoded
|
|
150
|
+
};
|
|
151
|
+
return encoded;
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
};
|
|
155
|
+
/** Decode the own properties of an (already-encoded) plain object, one by one. */
|
|
156
|
+
const decodeEntries = (value) => {
|
|
157
|
+
const decoded = {};
|
|
158
|
+
for (const [key, inner] of Object.entries(value)) decoded[key] = decode(inner);
|
|
159
|
+
return decoded;
|
|
160
|
+
};
|
|
161
|
+
/** Recursively decode a JSON-safe `value` back into a rich value. */
|
|
162
|
+
const decode = (value) => {
|
|
163
|
+
if (Array.isArray(value)) return value.map((item) => decode(item));
|
|
164
|
+
if (!isPlainObjectLike(value)) return value;
|
|
165
|
+
if (Object.prototype.hasOwnProperty.call(value, tagKey)) {
|
|
166
|
+
const tag = value[tagKey];
|
|
167
|
+
if (tag === ESCAPE_TAG) return decodeEntries(value.value);
|
|
168
|
+
const codec = typeof tag === "string" ? byTag.get(tag) : void 0;
|
|
169
|
+
if (codec !== void 0) return codec.decode(value.value, decode);
|
|
170
|
+
}
|
|
171
|
+
return decodeEntries(value);
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
encode,
|
|
175
|
+
decode,
|
|
176
|
+
stringify: (value) => JSON.stringify(encode(value)),
|
|
177
|
+
parse: (text) => decode(JSON.parse(text))
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/index.ts
|
|
182
|
+
/** A ready-to-use {@link Codec} with the default built-in types and tag key (`'$t'`). */
|
|
183
|
+
const defaultCodec = createCodec();
|
|
184
|
+
/** Encode + `JSON.stringify` a value, bound to {@link defaultCodec}. */
|
|
185
|
+
const stringify = (value) => defaultCodec.stringify(value);
|
|
186
|
+
/** `JSON.parse` + decode a string, bound to {@link defaultCodec}. */
|
|
187
|
+
const parse = (text) => defaultCodec.parse(text);
|
|
188
|
+
//#endregion
|
|
189
|
+
export { builtinTypes, createCodec, defaultCodec, parse, stringify };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ayepi/codec",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rich JSON codec — tagged, reversible serialization of Date, BigInt, Map, Set, undefined, Error, and custom types through JSON.stringify/parse",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/ClickerMonkey/ayepi.git",
|
|
12
|
+
"directory": "packages/codec"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/ClickerMonkey/ayepi/tree/main/packages/codec#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/ClickerMonkey/ayepi/issues"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"import": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"require": {
|
|
30
|
+
"types": "./dist/index.d.cts",
|
|
31
|
+
"default": "./dist/index.cjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@vitest/coverage-v8": "^2.1.8",
|
|
41
|
+
"publint": "^0.3.0",
|
|
42
|
+
"tsdown": "^0.12.0",
|
|
43
|
+
"vitest": "^2.1.8"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"ayepi",
|
|
47
|
+
"json",
|
|
48
|
+
"codec",
|
|
49
|
+
"serialization",
|
|
50
|
+
"date",
|
|
51
|
+
"bigint",
|
|
52
|
+
"map",
|
|
53
|
+
"set"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsdown",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run --coverage",
|
|
59
|
+
"publint": "publint"
|
|
60
|
+
}
|
|
61
|
+
}
|