@ayepi/codec 0.1.0 → 0.2.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/README.md CHANGED
@@ -72,7 +72,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
72
72
 
73
73
  - [`ayepi-codec.md`](./ayepi-codec.md)
74
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.
75
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/codec).
76
76
 
77
77
  ## License
78
78
 
package/ayepi-codec.md ADDED
@@ -0,0 +1,282 @@
1
+ <!--
2
+ ayepi-codec.md — reference for `@ayepi/codec`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/codec` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/codec`
11
+
12
+ Rich, reversible JSON. `JSON.stringify`/`parse` is lossy: it **drops** `undefined`,
13
+ **throws** on `BigInt`, **flattens** `Date`/`Map`/`Set`/`Error`/`RegExp`/`URL` into
14
+ useless shapes, and turns `NaN`/`±Infinity` into `null`. `@ayepi/codec` wraps each such
15
+ value in a small **tagged envelope** so it survives a `stringify` → `parse` round-trip,
16
+ and lets you register **custom types** the same way. It is a **zero-dependency,
17
+ standalone** package — no `@ayepi/core`, no `zod`.
18
+
19
+ ```sh
20
+ pnpm add @ayepi/codec
21
+ ```
22
+
23
+ ```ts
24
+ import { stringify, parse } from '@ayepi/codec';
25
+
26
+ const wire = stringify({ when: new Date(), ids: new Set([1n, 2n]), missing: undefined });
27
+ const value = parse(wire);
28
+ // value.when instanceof Date
29
+ // value.ids instanceof Set (of bigint)
30
+ // 'missing' in value === true && value.missing === undefined
31
+ ```
32
+
33
+ Importing the package has **no side effects**.
34
+
35
+ ---
36
+
37
+ ## Public API surface
38
+
39
+ Everything below is exported. Type-only symbols are marked.
40
+
41
+ | Export | Kind | Summary |
42
+ | --- | --- | --- |
43
+ | `createCodec(options?)` | function | Build a `Codec` from the built-ins plus your custom types. |
44
+ | `defaultCodec` | `Codec` | A ready-made codec: all built-ins, tag key `'$t'`. |
45
+ | `stringify(value)` | function | `defaultCodec.stringify`. |
46
+ | `parse(text)` | function | `defaultCodec.parse`. |
47
+ | `builtinTypes` | `readonly TypeCodec[]` | The default type codecs, in match order. |
48
+ | `Codec` | interface | `stringify` / `parse` / `encode` / `decode`. |
49
+ | `CodecOptions` | interface | Options for `createCodec`. |
50
+ | `TypeCodec<T>` | interface | One handler for one kind of value. |
51
+ | `Recurse` | type | `(value: unknown) => unknown`, passed to `encode`/`decode`. |
52
+
53
+ ### `Codec`
54
+
55
+ ```ts
56
+ interface Codec {
57
+ stringify(value: unknown): string; // encode + JSON.stringify
58
+ parse(text: string): unknown; // JSON.parse + decode
59
+ encode(value: unknown): unknown; // value → JSON-safe value (no string step)
60
+ decode(value: unknown): unknown; // JSON-safe value → rich value (no parse step)
61
+ }
62
+ ```
63
+
64
+ `encode`/`decode` are useful when the value is already going through a JSON channel that
65
+ stringifies for you (an HTTP body, `structuredClone` of a plain shape, `localStorage` via
66
+ your own serializer, a DB JSON column), or when you want to inspect the wire shape.
67
+
68
+ ```ts
69
+ import { defaultCodec } from '@ayepi/codec';
70
+
71
+ const safe = defaultCodec.encode({ at: new Date(0) });
72
+ // { at: { $t: 'Date', value: '1970-01-01T00:00:00.000Z' } }
73
+ JSON.stringify(safe); // hand off to any JSON sink
74
+ defaultCodec.decode(safe); // { at: Date }
75
+ ```
76
+
77
+ ### `createCodec(options?)`
78
+
79
+ ```ts
80
+ interface CodecOptions {
81
+ readonly types?: readonly TypeCodec[]; // your custom codecs (default: none)
82
+ readonly replaceBuiltins?: boolean; // use `types` INSTEAD of the built-ins (default: false)
83
+ readonly tagKey?: string; // sentinel key in the wrapper (default: '$t')
84
+ }
85
+
86
+ function createCodec(options?: CodecOptions): Codec;
87
+ ```
88
+
89
+ - By default your `types` are **prepended before** the built-ins, so a custom codec can
90
+ override a built-in (see *Precedence* below).
91
+ - `replaceBuiltins: true` drops the built-ins entirely — only your `types` are active.
92
+ - `tagKey` changes the wrapper key (e.g. to avoid colliding with a domain field, though
93
+ collisions are handled automatically — see *Collision escape*).
94
+
95
+ ---
96
+
97
+ ## Built-in types
98
+
99
+ `builtinTypes`, in order. Each is a `TypeCodec`. `null`, booleans, finite numbers,
100
+ strings, plain arrays, and plain objects match **no** codec and pass through untouched.
101
+
102
+ | Value | `tag` | Wire `value` (payload) |
103
+ | --- | --- | --- |
104
+ | `undefined` | `undefined` | `0` (ignored on decode) |
105
+ | `NaN` / `Infinity` / `-Infinity` | `Number` | `'NaN'` / `'Infinity'` / `'-Infinity'` |
106
+ | `bigint` | `BigInt` | decimal string, e.g. `'123'` |
107
+ | `Date` | `Date` | ISO string |
108
+ | `Map` | `Map` | `[[key, value], …]` (keys & values recursed) |
109
+ | `Set` | `Set` | `[value, …]` (values recursed) |
110
+ | `Error` | `Error` | `{ name, message, stack? }` |
111
+ | `RegExp` | `RegExp` | `{ source, flags }` |
112
+ | `URL` | `URL` | `href` string |
113
+
114
+ Notes:
115
+
116
+ - **`undefined`** is preserved everywhere — as an object property *and* as an array hole
117
+ (plain JSON would turn an array `undefined` into `null` and drop object keys).
118
+ - **Number specials**: only non-finite numbers are tagged; finite numbers stay bare.
119
+ - **`Error`**: `name`, `message`, and `stack` are preserved. If the source error had **no
120
+ `stack`**, the decoded error has **no `stack`** either (the freshly synthesized one is
121
+ removed) — round-trip is exact. A decoded error is always a base `Error` instance with
122
+ the original `name` (subclass identity like `TypeError` is not reconstructed).
123
+
124
+ ---
125
+
126
+ ## Wire format
127
+
128
+ A value handled by a codec serializes as a two-key object:
129
+
130
+ ```json
131
+ { "$t": "<tag>", "value": <payload> }
132
+ ```
133
+
134
+ Encoding is **recursive**: arrays are mapped, plain objects are walked key-by-key, and a
135
+ codec's payload may itself contain encoded values (e.g. a `Map` of `Date`s). So arbitrarily
136
+ nested mixes round-trip:
137
+
138
+ ```ts
139
+ import { stringify, parse } from '@ayepi/codec';
140
+
141
+ const value = {
142
+ ids: new Set([1n, 2n]),
143
+ byDay: new Map([['2026-06-14', [new Date(), undefined]]]),
144
+ rx: /ab+c/gi,
145
+ where: new URL('https://example.com/x?q=1'),
146
+ bad: Number.NaN,
147
+ };
148
+ const back = parse(stringify(value)); // every field reconstructed to its original type
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Collision escape
154
+
155
+ If a **plain object** legitimately owns a property named exactly `tagKey` (default `'$t'`),
156
+ it is indistinguishable on the wire from a real wrapper. The codec detects this and wraps
157
+ such objects in a reserved **escape** envelope (`{ "$t": "$escape", "value": <encoded
158
+ object> }`), then unwraps them on decode. The escape is transparent:
159
+
160
+ ```ts
161
+ import { stringify, parse } from '@ayepi/codec';
162
+
163
+ parse(stringify({ $t: 'hello' }));
164
+ // → { $t: 'hello' } ✅ (NOT interpreted as a tag)
165
+
166
+ parse(stringify({ $t: 'Date', value: new Date(0), other: 1 }));
167
+ // → { $t: 'Date', value: Date(0), other: 1 } ✅ nested rich values still work
168
+ ```
169
+
170
+ You do **not** need to choose an exotic `tagKey` to be safe; the escape makes any key safe.
171
+ (`'$escape'` itself is reserved as a tag — don't register a custom codec with that tag.)
172
+
173
+ ---
174
+
175
+ ## Custom types — `TypeCodec`
176
+
177
+ ```ts
178
+ interface TypeCodec<T = unknown> {
179
+ readonly tag: string; // unique, short, stable
180
+ test(value: unknown): boolean; // does this codec claim `value`?
181
+ encode(value: T, recurse: (x: unknown) => unknown): unknown; // → JSON-safe payload
182
+ decode(payload: unknown, recurse: (x: unknown) => unknown): T; // payload → value
183
+ }
184
+ ```
185
+
186
+ - **`tag`** is written to the wire and looked up on decode — keep it unique and **stable**
187
+ across versions (renaming it breaks already-serialized data).
188
+ - **`test`** decides which codec handles a value. Be specific (prefer `instanceof` or a
189
+ precise `typeof`); a too-broad `test` will swallow values meant for another codec.
190
+ - **`encode`/`decode`** receive a **`recurse`** callback — call it on any inner values your
191
+ payload contains (container elements, wrapped sub-values) so nested rich types are handled
192
+ too. For leaf types (a class wrapping only primitives) you can ignore it.
193
+
194
+ ```ts
195
+ import { createCodec, type TypeCodec } from '@ayepi/codec';
196
+
197
+ class Money { constructor(public cents: bigint, public currency: string) {} }
198
+
199
+ const moneyCodec: TypeCodec<Money> = {
200
+ tag: 'Money',
201
+ test: (v) => v instanceof Money,
202
+ // `cents` is a bigint — recurse so the built-in BigInt codec encodes it.
203
+ encode: (m, recurse) => ({ cents: recurse(m.cents), currency: m.currency }),
204
+ decode: (p, recurse) => {
205
+ const o = p as { cents: unknown; currency: string };
206
+ return new Money(recurse(o.cents) as bigint, o.currency);
207
+ },
208
+ };
209
+
210
+ const codec = createCodec({ types: [moneyCodec] });
211
+ codec.parse(codec.stringify(new Money(1099n, 'USD'))); // → Money { cents: 1099n, currency: 'USD' }
212
+ ```
213
+
214
+ ### Precedence
215
+
216
+ Custom `types` are checked **before** the built-ins. The **first** codec whose `test`
217
+ returns `true` wins. This lets a custom codec **override** a built-in:
218
+
219
+ ```ts
220
+ import { createCodec } from '@ayepi/codec';
221
+
222
+ // Store Date as epoch millis instead of the built-in ISO string.
223
+ const codec = createCodec({
224
+ types: [{
225
+ tag: 'EpochDate',
226
+ test: (v) => v instanceof Date,
227
+ encode: (d: Date) => d.getTime(),
228
+ decode: (ms) => new Date(ms as number),
229
+ }],
230
+ });
231
+ codec.encode(new Date(0)); // → { $t: 'EpochDate', value: 0 }
232
+ ```
233
+
234
+ Use `replaceBuiltins: true` for a codec that handles **only** your types (built-in values
235
+ then fall through to plain-object/array walking — e.g. a `Date` becomes `{}`).
236
+
237
+ ---
238
+
239
+ ## Gotchas
240
+
241
+ - **No circular references.** A cycle overflows the stack, exactly like `JSON.stringify`.
242
+ Break cycles before encoding if your graph may contain them.
243
+ - **`stack` is environment-specific.** Serialized `Error.stack` strings carry file paths
244
+ and frames from the producing process; treat them as diagnostic text, not stable data.
245
+ - **Subclass identity isn't reconstructed.** Decoded errors are base `Error` instances with
246
+ the original `name`. For richer error types, register a custom `TypeCodec`.
247
+ - **Tag stability.** Changing a `tag` (built-in or custom) makes previously serialized data
248
+ with the old tag decode as a plain object. Keep tags fixed for any persisted data.
249
+ - **Reserved tag.** `'$escape'` is used internally for the collision escape; don't reuse it.
250
+ - **`tagKey` must be a string** and should be consistent between the encoder and decoder of
251
+ the same data (the default `'$t'` is fine for most uses).
252
+
253
+ ---
254
+
255
+ ## Recipes
256
+
257
+ **Persist to `localStorage` with full fidelity:**
258
+
259
+ ```ts
260
+ import { stringify, parse } from '@ayepi/codec';
261
+ localStorage.setItem('state', stringify(state));
262
+ const state = parse(localStorage.getItem('state') ?? 'null');
263
+ ```
264
+
265
+ **A project-wide codec with your domain types:**
266
+
267
+ ```ts
268
+ // codec.ts
269
+ import { createCodec } from '@ayepi/codec';
270
+ import { moneyCodec } from './money.js';
271
+ import { pointCodec } from './point.js';
272
+
273
+ export const codec = createCodec({ types: [moneyCodec, pointCodec] });
274
+ export const { stringify, parse } = codec;
275
+ ```
276
+
277
+ **Inspect the wire shape without producing a string:**
278
+
279
+ ```ts
280
+ import { defaultCodec } from '@ayepi/codec';
281
+ console.dir(defaultCodec.encode(myValue), { depth: null });
282
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/codec",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Rich JSON codec — tagged, reversible serialization of Date, BigInt, Map, Set, undefined, Error, and custom types through JSON.stringify/parse",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "ayepi-*.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {