@webjsdev/core 0.7.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.
@@ -0,0 +1,452 @@
1
+ /**
2
+ * webjs serializer: JSON-friendly encoding for rich JS types.
3
+ *
4
+ * Replaces superjson with a pure-ESM, dependency-free implementation
5
+ * tuned to the wire surface that React Server Actions support, plus a
6
+ * couple of webjs-specific niceties.
7
+ *
8
+ * Wire format: tagged-inline. Non-JSON values are wrapped in a small
9
+ * object whose tag key is `_$wj`:
10
+ *
11
+ * { _$wj: "Date", v: "2026-04-28T12:00:00.000Z" }
12
+ * { _$wj: "BigInt", v: "12345678901234567890" }
13
+ * { _$wj: "Map", v: [[k, v], ...] } // recursively encoded
14
+ * { _$wj: "Set", v: [...] }
15
+ * { _$wj: "Error", v: { name, message, stack } }
16
+ * { _$wj: "Sym", v: <Symbol.for key> }
17
+ * { _$wj: "u8", v: "<base64>" } // and i8/u16/i16/u32/i32/u8c/f32/f64/buf/dv
18
+ * { _$wj: "Blob", v: "<base64>", t: "<mime>" }
19
+ * { _$wj: "File", v: "<base64>", n: "<name>", t: "<mime>", m: <lastModified> }
20
+ * { _$wj: "FD", v: [[name, encoded], ...] } // FormData
21
+ * { _$wj: "undef" } | "NaN" | "Inf" | "-Inf" | "-0"
22
+ * { _$wj: "Ref", v: <id> } // back-reference for cycles / shared refs
23
+ *
24
+ * A container value (object/array/Map/Set) that participates in a
25
+ * cycle or appears more than once gets an `_id` field so refs can
26
+ * back-point to it.
27
+ *
28
+ * Plain-object keys that look like reserved markers (`_$wj`, `__$wj`,
29
+ * `_id`, `__id`, …) are escaped on encode by adding a leading
30
+ * underscore; decode strips one. This makes the format collision-safe
31
+ * for arbitrary user data.
32
+ *
33
+ * ## Public API
34
+ *
35
+ * stringify(v) async: JSON.stringify(serialize(v))
36
+ * serialize(v) async: produces JSON-safe value
37
+ *
38
+ * parse(s) sync: deserialize(JSON.parse(s))
39
+ * deserialize(v) sync: inverse of serialize
40
+ *
41
+ * The serializer is async because Blob / File / FormData require
42
+ * `await blob.arrayBuffer()` to read their bytes. For payloads that
43
+ * don't include any binary types the cost is just a single Promise
44
+ * tick (microseconds). Single async function keeps the API obvious -
45
+ * AI agents can't pick the wrong variant.
46
+ *
47
+ * (Built-in `JSON.stringify` / `JSON.parse` are unaffected and remain
48
+ * available for plain-JSON use cases.)
49
+ */
50
+
51
+ const TAG = '_$wj';
52
+ const ID_KEY = '_id';
53
+
54
+ /* ----------------------------------------------------------------- *
55
+ * Public API *
56
+ * ----------------------------------------------------------------- */
57
+
58
+ /**
59
+ * Serialize `value` to a JSON string. Async to support Blob / File /
60
+ * FormData (which require `await arrayBuffer()` to read their bytes).
61
+ *
62
+ * @param {unknown} value
63
+ * @returns {Promise<string>}
64
+ */
65
+ export async function stringify(value) {
66
+ return JSON.stringify(await serialize(value));
67
+ }
68
+
69
+ /**
70
+ * Inverse of `stringify`. Synchronous: no async types need awaiting
71
+ * during decode (binary is already inlined as base64).
72
+ *
73
+ * @param {string} text
74
+ * @returns {unknown}
75
+ */
76
+ export function parse(text) {
77
+ return deserialize(JSON.parse(text));
78
+ }
79
+
80
+ /**
81
+ * Serialize `value` to a JSON-safe shape (plain JSON values + tagged
82
+ * marker objects for rich types). Use `stringify` if you also need
83
+ * the JSON string.
84
+ *
85
+ * @param {unknown} value
86
+ * @returns {Promise<unknown>}
87
+ */
88
+ export async function serialize(value) {
89
+ const ctx = newEncodeCtx();
90
+ await countRefs(value, ctx);
91
+ return encodeOne(value, ctx);
92
+ }
93
+
94
+ /**
95
+ * Inverse of `serialize`. Synchronous.
96
+ *
97
+ * @param {unknown} value
98
+ * @returns {unknown}
99
+ */
100
+ export function deserialize(value) {
101
+ return decode(value, newDecodeCtx());
102
+ }
103
+
104
+ /* ----------------------------------------------------------------- *
105
+ * Encode *
106
+ * ----------------------------------------------------------------- */
107
+
108
+ function newEncodeCtx() {
109
+ return {
110
+ refCount: new Map(), // obj → number of references
111
+ idOf: new Map(), // obj → assigned id (only for multi-ref)
112
+ emitted: new Set(), // ids already emitted as the canonical encoding
113
+ nextId: 0,
114
+ blobBytes: new Map(), // Blob → Uint8Array (precomputed during countRefs)
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Pass 1: walk the value, count references, pre-read Blob/File bytes.
120
+ * Async because `Blob.arrayBuffer()` is async. Iterative (uses an
121
+ * explicit stack) to avoid recursion limits on deeply nested values.
122
+ */
123
+ async function countRefs(value, ctx) {
124
+ const stack = [value];
125
+ while (stack.length) {
126
+ const v = stack.pop();
127
+ if (!isCountableObject(v)) continue;
128
+ const cur = ctx.refCount.get(v) || 0;
129
+ ctx.refCount.set(v, cur + 1);
130
+ if (cur > 0) continue; // already traversed children
131
+ if (typeof Blob !== 'undefined' && v instanceof Blob) {
132
+ const buf = new Uint8Array(await v.arrayBuffer());
133
+ ctx.blobBytes.set(v, buf);
134
+ continue;
135
+ }
136
+ if (typeof FormData !== 'undefined' && v instanceof FormData) {
137
+ for (const [, val] of v.entries()) {
138
+ if (typeof val !== 'string') stack.push(val);
139
+ }
140
+ continue;
141
+ }
142
+ if (Array.isArray(v)) {
143
+ for (let i = 0; i < v.length; i++) stack.push(v[i]);
144
+ } else if (v instanceof Map) {
145
+ for (const [k, val] of v) { stack.push(k); stack.push(val); }
146
+ } else if (v instanceof Set) {
147
+ for (const item of v) stack.push(item);
148
+ } else if (isPlainObject(v)) {
149
+ for (const k in v) {
150
+ if (Object.prototype.hasOwnProperty.call(v, k)) stack.push(v[k]);
151
+ }
152
+ }
153
+ }
154
+ // Assign ids only to objects with multiple refs (cycles or shared refs).
155
+ for (const [obj, count] of ctx.refCount) {
156
+ if (count > 1) ctx.idOf.set(obj, ctx.nextId++);
157
+ }
158
+ }
159
+
160
+ function encodeOne(v, ctx) {
161
+ // Primitives + sentinel-tagged values
162
+ if (v === null) return null;
163
+ if (v === undefined) return { [TAG]: 'undef' };
164
+ const t = typeof v;
165
+ if (t === 'string' || t === 'boolean') return v;
166
+ if (t === 'number') {
167
+ if (Number.isNaN(v)) return { [TAG]: 'NaN' };
168
+ if (v === Infinity) return { [TAG]: 'Inf' };
169
+ if (v === -Infinity) return { [TAG]: '-Inf' };
170
+ if (Object.is(v, -0)) return { [TAG]: '-0' };
171
+ return v;
172
+ }
173
+ if (t === 'bigint') return { [TAG]: 'BigInt', v: v.toString() };
174
+ if (t === 'symbol') {
175
+ const k = Symbol.keyFor(v);
176
+ if (k === undefined) {
177
+ throw new TypeError('Cannot serialize a local Symbol; only Symbol.for() registered symbols are supported.');
178
+ }
179
+ return { [TAG]: 'Sym', v: k };
180
+ }
181
+ if (t === 'function') {
182
+ throw new TypeError('Cannot serialize a function.');
183
+ }
184
+
185
+ // Object types: first check for repeat reference
186
+ const id = ctx.idOf.get(v);
187
+ if (id !== undefined && ctx.emitted.has(id)) {
188
+ return { [TAG]: 'Ref', v: id };
189
+ }
190
+ if (id !== undefined) ctx.emitted.add(id);
191
+
192
+ // Containers + tagged leaf values
193
+ let out;
194
+ if (v instanceof Date) {
195
+ out = { [TAG]: 'Date', v: Number.isNaN(v.getTime()) ? null : v.toISOString() };
196
+ } else if (v instanceof Error) {
197
+ out = { [TAG]: 'Error', v: { name: v.name, message: v.message, stack: v.stack || null } };
198
+ } else if (typeof Blob !== 'undefined' && v instanceof Blob) {
199
+ const bytes = ctx.blobBytes.get(v);
200
+ if (typeof File !== 'undefined' && v instanceof File) {
201
+ out = { [TAG]: 'File', v: bytesToB64(bytes), n: v.name, t: v.type || '', m: v.lastModified };
202
+ } else {
203
+ out = { [TAG]: 'Blob', v: bytesToB64(bytes), t: v.type || '' };
204
+ }
205
+ } else if (typeof FormData !== 'undefined' && v instanceof FormData) {
206
+ const entries = [];
207
+ for (const [k, val] of v.entries()) entries.push([k, encodeOne(val, ctx)]);
208
+ out = { [TAG]: 'FD', v: entries };
209
+ } else if (ArrayBuffer.isView(v) || v instanceof ArrayBuffer) {
210
+ out = encodeBinary(v);
211
+ } else if (v instanceof Map) {
212
+ const entries = [];
213
+ for (const [k, val] of v) entries.push([encodeOne(k, ctx), encodeOne(val, ctx)]);
214
+ out = { [TAG]: 'Map', v: entries };
215
+ } else if (v instanceof Set) {
216
+ const items = [];
217
+ for (const item of v) items.push(encodeOne(item, ctx));
218
+ out = { [TAG]: 'Set', v: items };
219
+ } else if (Array.isArray(v)) {
220
+ out = v.map((item) => encodeOne(item, ctx));
221
+ } else {
222
+ // Plain object (or class instance: degrades to plain object,
223
+ // matching React's behavior).
224
+ out = {};
225
+ for (const k in v) {
226
+ if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
227
+ const escaped = isReservedKey(k) ? '_' + k : k;
228
+ out[escaped] = encodeOne(v[k], ctx);
229
+ }
230
+ }
231
+
232
+ if (id !== undefined) {
233
+ if (Array.isArray(out)) {
234
+ // Arrays can't carry ids inline; wrap in a tagged container.
235
+ return { [TAG]: 'Arr', [ID_KEY]: id, v: out };
236
+ }
237
+ out[ID_KEY] = id;
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /** Encode a typed array / ArrayBuffer / DataView as a tagged base64 value. */
243
+ function encodeBinary(v) {
244
+ if (v instanceof ArrayBuffer) {
245
+ return { [TAG]: 'buf', v: bytesToB64(new Uint8Array(v)) };
246
+ }
247
+ if (v instanceof DataView) {
248
+ return { [TAG]: 'dv', v: bytesToB64(new Uint8Array(v.buffer, v.byteOffset, v.byteLength)) };
249
+ }
250
+ // Typed array
251
+ const ctor = v.constructor.name;
252
+ const tag = TYPED_ARRAY_TAG[ctor];
253
+ if (!tag) {
254
+ throw new TypeError(`Unsupported typed array: ${ctor}`);
255
+ }
256
+ const u8 = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
257
+ return { [TAG]: tag, v: bytesToB64(u8) };
258
+ }
259
+
260
+ const TYPED_ARRAY_TAG = {
261
+ Int8Array: 'i8',
262
+ Uint8Array: 'u8',
263
+ Uint8ClampedArray: 'u8c',
264
+ Int16Array: 'i16',
265
+ Uint16Array: 'u16',
266
+ Int32Array: 'i32',
267
+ Uint32Array: 'u32',
268
+ Float32Array: 'f32',
269
+ Float64Array: 'f64',
270
+ BigInt64Array: 'bi64',
271
+ BigUint64Array: 'bu64',
272
+ };
273
+
274
+ const TAG_TO_TYPED_ARRAY = {
275
+ i8: Int8Array,
276
+ u8: Uint8Array,
277
+ u8c: Uint8ClampedArray,
278
+ i16: Int16Array,
279
+ u16: Uint16Array,
280
+ i32: Int32Array,
281
+ u32: Uint32Array,
282
+ f32: Float32Array,
283
+ f64: Float64Array,
284
+ bi64: typeof BigInt64Array !== 'undefined' ? BigInt64Array : null,
285
+ bu64: typeof BigUint64Array !== 'undefined' ? BigUint64Array : null,
286
+ };
287
+
288
+ /* ----------------------------------------------------------------- *
289
+ * Decode *
290
+ * ----------------------------------------------------------------- */
291
+
292
+ function newDecodeCtx() {
293
+ return {
294
+ refs: new Map(), // id → object (mutable; populated as we decode)
295
+ };
296
+ }
297
+
298
+ function decode(v, ctx) {
299
+ if (v === null || typeof v !== 'object') return v;
300
+ if (Array.isArray(v)) {
301
+ const out = [];
302
+ for (let i = 0; i < v.length; i++) out.push(decode(v[i], ctx));
303
+ return out;
304
+ }
305
+ const tag = v[TAG];
306
+ if (typeof tag === 'string') {
307
+ return decodeTagged(v, tag, ctx);
308
+ }
309
+ // Plain object
310
+ const out = {};
311
+ const id = v[ID_KEY];
312
+ if (typeof id === 'number') ctx.refs.set(id, out);
313
+ for (const k in v) {
314
+ if (!Object.prototype.hasOwnProperty.call(v, k)) continue;
315
+ if (k === ID_KEY) continue;
316
+ const realKey = isEscapedReservedKey(k) ? k.slice(1) : k;
317
+ out[realKey] = decode(v[k], ctx);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ function decodeTagged(v, tag, ctx) {
323
+ const id = v[ID_KEY];
324
+ switch (tag) {
325
+ case 'undef': return undefined;
326
+ case 'NaN': return NaN;
327
+ case 'Inf': return Infinity;
328
+ case '-Inf': return -Infinity;
329
+ case '-0': return -0;
330
+ case 'BigInt': return BigInt(v.v);
331
+ case 'Sym': return Symbol.for(v.v);
332
+ case 'Date': return v.v == null ? new Date(NaN) : new Date(v.v);
333
+ case 'Error': {
334
+ const e = new Error(v.v.message);
335
+ e.name = v.v.name;
336
+ if (v.v.stack) e.stack = v.v.stack;
337
+ if (typeof id === 'number') ctx.refs.set(id, e);
338
+ return e;
339
+ }
340
+ case 'Map': {
341
+ const m = new Map();
342
+ if (typeof id === 'number') ctx.refs.set(id, m);
343
+ for (const [k, val] of v.v) m.set(decode(k, ctx), decode(val, ctx));
344
+ return m;
345
+ }
346
+ case 'Set': {
347
+ const s = new Set();
348
+ if (typeof id === 'number') ctx.refs.set(id, s);
349
+ for (const item of v.v) s.add(decode(item, ctx));
350
+ return s;
351
+ }
352
+ case 'Arr': {
353
+ const arr = [];
354
+ if (typeof id === 'number') ctx.refs.set(id, arr);
355
+ for (const item of v.v) arr.push(decode(item, ctx));
356
+ return arr;
357
+ }
358
+ case 'Ref': {
359
+ const ref = ctx.refs.get(v.v);
360
+ if (ref === undefined) {
361
+ throw new TypeError(`Dangling reference: id=${v.v}`);
362
+ }
363
+ return ref;
364
+ }
365
+ case 'Blob': {
366
+ if (typeof Blob === 'undefined') throw new TypeError('Blob is not available in this environment.');
367
+ const bytes = b64ToBytes(v.v);
368
+ return new Blob([bytes], { type: v.t || '' });
369
+ }
370
+ case 'File': {
371
+ if (typeof File === 'undefined') throw new TypeError('File is not available in this environment.');
372
+ const bytes = b64ToBytes(v.v);
373
+ return new File([bytes], v.n, { type: v.t || '', lastModified: v.m });
374
+ }
375
+ case 'FD': {
376
+ if (typeof FormData === 'undefined') throw new TypeError('FormData is not available in this environment.');
377
+ const fd = new FormData();
378
+ for (const [k, val] of v.v) {
379
+ const decoded = decode(val, ctx);
380
+ fd.append(k, decoded);
381
+ }
382
+ return fd;
383
+ }
384
+ default: {
385
+ // Typed arrays + binary buffers
386
+ if (tag === 'buf') {
387
+ const bytes = b64ToBytes(v.v);
388
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
389
+ }
390
+ if (tag === 'dv') {
391
+ const bytes = b64ToBytes(v.v);
392
+ return new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
393
+ }
394
+ const Ctor = TAG_TO_TYPED_ARRAY[tag];
395
+ if (Ctor) {
396
+ const bytes = b64ToBytes(v.v);
397
+ const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
398
+ return new Ctor(sliced);
399
+ }
400
+ throw new TypeError(`Unknown serialization tag: ${tag}`);
401
+ }
402
+ }
403
+ }
404
+
405
+ /* ----------------------------------------------------------------- *
406
+ * Helpers *
407
+ * ----------------------------------------------------------------- */
408
+
409
+ function isCountableObject(v) {
410
+ // Returns true for any object that countRefs should visit. Includes
411
+ // Blob/FormData (so countRefs can pre-read their bytes via async
412
+ // arrayBuffer) and the leaf typed values (Date/Error/typed arrays /
413
+ // ArrayBuffer / DataView), which countRefs records but doesn't
414
+ // traverse into.
415
+ return v !== null && typeof v === 'object';
416
+ }
417
+
418
+ function isPlainObject(v) {
419
+ if (v === null || typeof v !== 'object') return false;
420
+ const p = Object.getPrototypeOf(v);
421
+ return p === Object.prototype || p === null;
422
+ }
423
+
424
+ /** A user-supplied object key that would collide with our marker syntax. */
425
+ function isReservedKey(k) {
426
+ return /^_+\$wj$/.test(k) || /^_+id$/.test(k);
427
+ }
428
+
429
+ /** A key on the wire that was escaped because it would have been reserved. */
430
+ function isEscapedReservedKey(k) {
431
+ return /^__+\$wj$/.test(k) || /^__+id$/.test(k);
432
+ }
433
+
434
+ const _hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function';
435
+
436
+ function bytesToB64(u8) {
437
+ if (_hasBuffer) return Buffer.from(u8.buffer, u8.byteOffset, u8.byteLength).toString('base64');
438
+ let s = '';
439
+ for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]);
440
+ return btoa(s);
441
+ }
442
+
443
+ function b64ToBytes(b64) {
444
+ if (_hasBuffer) {
445
+ const buf = Buffer.from(b64, 'base64');
446
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
447
+ }
448
+ const bin = atob(b64);
449
+ const out = new Uint8Array(bin.length);
450
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
451
+ return out;
452
+ }