@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 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;
@@ -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 };
@@ -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
+ }