@zudojs/serialization 1.0.0 → 1.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/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  JSON serialization with optional type preservation, a transformer registry, and an envelope for cross-service payloads.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-serialization](https://zudojs.oyinlola.site/docs/packages-serialization) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-serialization.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -22,8 +28,10 @@ const value = serializer.deserialize<{ id: string; createdAt: Date }>(json);
22
28
  value.createdAt instanceof Date; // true
23
29
  ```
24
30
 
25
- The methods are `serialize` / `deserialize`, and every option can be overridden
26
- per call:
31
+ Every serialize/deserialize option — `pretty`, `preserveTypes`, `maxSize`,
32
+ `maxDepth`, `strict`, `allowUnsafeKeys`, `includeStack` — can be passed to
33
+ `createSerializer` as an instance default. The methods are `serialize` /
34
+ `deserialize`, and every option can be overridden per call:
27
35
 
28
36
  ```typescript
29
37
  serializer.serialize(value, { pretty: true, maxSize: 1_000_000 });
@@ -81,13 +89,35 @@ RPC peer or a request body.
81
89
  is plain `JSON.parse`, where `__proto__` survives as an inert own property and
82
90
  never reaches the prototype — but do not spread or deep-merge such an object
83
91
  into another without screening its keys.
84
- - **Input is size-bounded** by `maxSize`, and depth-bounded by `maxDepth`.
85
- - **An unrecognised `$type` tag is treated as ordinary data**, so a peer cannot
86
- stop the consumer with `{"$type":"anything"}` and your own records may carry a
87
- `$type` field. Pass `strict: true` to make an unknown tag an error instead.
92
+ - **Input is size-bounded** by `maxSize`, and depth-bounded by `maxDepth`. On
93
+ the `preserveTypes` path the depth limit defaults to 128; on the fast path it
94
+ is enforced whenever you set it, per call or as an instance default.
95
+ - **Your own data may carry a `$type` key.** With `preserveTypes`, a plain
96
+ object that has its own string `$type` is written escaped as
97
+ `{"$type":"Object","$value":{...}}` (`ESCAPED_OBJECT_TAG`) and read back as
98
+ the plain object it was, so user data can never be revived as a `Map`,
99
+ `Error`, `BigInt` or `Buffer`. `"Object"` is reserved and cannot be
100
+ registered as a transformer. Payloads written before this escaping existed
101
+ are still read as before: an unescaped tag is revived, since it cannot be
102
+ told apart from one the serializer wrote.
103
+ - **An unrecognised or malformed tag is treated as ordinary data**, so a peer
104
+ cannot stop the consumer with `{"$type":"anything"}` or
105
+ `{"$type":"Date","$value":"nope"}`, and a bad record stays readable. Pass
106
+ `strict: true` to make either an error instead.
107
+ - **BigInt tags are capped at 4096 decimal digits**
108
+ (`SerializationLimits.MAX_BIGINT_DIGITS` from `@zudojs/constants`, shared
109
+ with `@zudojs/schema`'s `coerce.bigint()`), checked before `BigInt()` runs;
110
+ serializing a larger BigInt throws.
88
111
 
89
112
  ## Errors
90
113
 
114
+ Failures throw `@zudojs/errors` serialization classes: `SerializationPayloadTooLargeError`,
115
+ `SerializationDepthError`, `InvalidSerializedDataError` (malformed JSON, unknown
116
+ or malformed tags under `strict`), `TransformerError` and `SerializeError` (for
117
+ example an invalid `Date`). All extend `SerializationError`.
118
+
119
+ ### Serialized errors
120
+
91
121
  Stack traces are **not** serialized unless you ask, because a serialized error
92
122
  routinely ends up in a queue message or a log sink:
93
123
 
package/dist/index.d.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * ```
26
26
  */
27
27
  export type { SerializationFormat, SerializedValue, SerializeOptions, DeserializeOptions, SerializationMetadata, SerializedEnvelope, Serializer, TypeTransformer, } from "./serializerTypes/index.js";
28
- export { JSONSerializer } from "./serializerJson/index.js";
28
+ export { JSONSerializer, ESCAPED_OBJECT_TAG } from "./serializerJson/index.js";
29
29
  export { TransformerRegistry } from "./serializerTransforms/index.js";
30
30
  export { DateTransformer } from "./serializerTransforms/index.js";
31
31
  export { BigIntTransformer } from "./serializerTransforms/index.js";
package/dist/index.js CHANGED
@@ -25,7 +25,7 @@
25
25
  * ```
26
26
  */
27
27
  // ─── JSON Serializer ──────────────────────────────────────────
28
- export { JSONSerializer } from "./serializerJson/index.js";
28
+ export { JSONSerializer, ESCAPED_OBJECT_TAG } from "./serializerJson/index.js";
29
29
  // ─── Transformer Registry ─────────────────────────────────────
30
30
  export { TransformerRegistry } from "./serializerTransforms/index.js";
31
31
  export { DateTransformer } from "./serializerTransforms/index.js";
@@ -5,4 +5,5 @@
5
5
  * with type preservation via transformer registry.
6
6
  */
7
7
  export { JSONSerializer } from "./jsonSerializer.core.js";
8
+ export { ESCAPED_OBJECT_TAG } from "./jsonSerializer.escape.js";
8
9
  //# sourceMappingURL=index.d.ts.map
@@ -5,4 +5,5 @@
5
5
  * with type preservation via transformer registry.
6
6
  */
7
7
  export { JSONSerializer } from "./jsonSerializer.core.js";
8
+ export { ESCAPED_OBJECT_TAG } from "./jsonSerializer.escape.js";
8
9
  //# sourceMappingURL=index.js.map
@@ -7,6 +7,12 @@
7
7
  */
8
8
  import type { Serializer, SerializeOptions, DeserializeOptions } from "../serializerTypes/index.js";
9
9
  import { TransformerRegistry } from "../serializerTransforms/index.js";
10
+ /**
11
+ * JSON serializer. With `preserveTypes`, plain objects that carry their own
12
+ * string `$type` key are escaped as `{"$type":"Object","$value":{...}}` on
13
+ * write and unwrapped on read, so user data can never be revived as a
14
+ * different runtime type.
15
+ */
10
16
  export declare class JSONSerializer implements Serializer<unknown, string> {
11
17
  readonly name = "json";
12
18
  readonly contentType = "application/json";
@@ -17,14 +23,15 @@ export declare class JSONSerializer implements Serializer<unknown, string> {
17
23
  /** Default options merged into every call. */
18
24
  readonly defaults?: SerializeOptions & DeserializeOptions;
19
25
  });
20
- /** Register a custom type transformer. */
26
+ /**
27
+ * Register a custom type transformer.
28
+ *
29
+ * @throws {TransformerError} for the reserved escape tag `"Object"`.
30
+ */
21
31
  registerTransformer(transformer: import("../serializerTypes/index.js").TypeTransformer): void;
22
32
  serialize(value: unknown, options?: SerializeOptions): string;
23
33
  deserialize<T = unknown>(value: string, options?: DeserializeOptions): T;
24
- private transformValue;
25
- private restoreValue;
26
- private assertValidJson;
27
- private assertOutputSize;
28
- private assertInputSize;
34
+ /** Parses JSON, reporting malformed input as a typed error. */
35
+ private parse;
29
36
  }
30
37
  //# sourceMappingURL=jsonSerializer.core.d.ts.map
@@ -5,12 +5,15 @@
5
5
  * Advanced path (preserveTypes: true): recursive traversal with
6
6
  * transformer-based type preservation.
7
7
  */
8
- import { TransformerRegistry } from "../serializerTransforms/index.js";
9
- import { DateTransformer, BigIntTransformer, MapTransformer, SetTransformer, } from "../serializerTransforms/index.js";
8
+ import { TransformerRegistry, DateTransformer, BigIntTransformer, MapTransformer, SetTransformer, } from "../serializerTransforms/index.js";
10
9
  import { BufferTransformer, ErrorTransformer, } from "../serializerTransformsExt/index.js";
11
- import { SerializationLimits, SerializationTags, SCHEMA_FORBIDDEN_KEYS, } from "@zudojs/constants";
12
- import { isPlainObject } from "@zudojs/types";
10
+ import { SerializationLimits } from "@zudojs/constants";
11
+ import { InvalidSerializedDataError, TransformerError } from "@zudojs/errors";
13
12
  import { assertNoCircularReference, assertDepthWithinLimit, } from "@zudojs/validation";
13
+ import { ESCAPED_OBJECT_TAG } from "./jsonSerializer.escape.js";
14
+ import { assertByteSize } from "./jsonSerializer.keys.js";
15
+ import { transformValue } from "./jsonSerializer.transform.js";
16
+ import { restoreValue } from "./jsonSerializer.restore.js";
14
17
  /** Default transformer registry with all built-in transformers. */
15
18
  function createDefaultTransformers() {
16
19
  const registry = new TransformerRegistry();
@@ -22,6 +25,12 @@ function createDefaultTransformers() {
22
25
  registry.register(ErrorTransformer);
23
26
  return registry;
24
27
  }
28
+ /**
29
+ * JSON serializer. With `preserveTypes`, plain objects that carry their own
30
+ * string `$type` key are escaped as `{"$type":"Object","$value":{...}}` on
31
+ * write and unwrapped on read, so user data can never be revived as a
32
+ * different runtime type.
33
+ */
25
34
  export class JSONSerializer {
26
35
  name = "json";
27
36
  contentType = "application/json";
@@ -31,8 +40,15 @@ export class JSONSerializer {
31
40
  this.transformers = options?.transformers ?? createDefaultTransformers();
32
41
  this.defaults = options?.defaults ?? {};
33
42
  }
34
- /** Register a custom type transformer. */
43
+ /**
44
+ * Register a custom type transformer.
45
+ *
46
+ * @throws {TransformerError} for the reserved escape tag `"Object"`.
47
+ */
35
48
  registerTransformer(transformer) {
49
+ if (transformer.type === ESCAPED_OBJECT_TAG) {
50
+ throw new TransformerError(transformer.type, `"${ESCAPED_OBJECT_TAG}" is reserved for escaped plain objects.`);
51
+ }
36
52
  this.transformers.register(transformer);
37
53
  }
38
54
  serialize(value, options) {
@@ -42,17 +58,24 @@ export class JSONSerializer {
42
58
  if (opts.preserveTypes === true) {
43
59
  assertNoCircularReference(value);
44
60
  assertDepthWithinLimit(value, maxDepth);
45
- const transformed = this.transformValue(value, 0, maxDepth, opts);
61
+ const transformed = transformValue({ transformers: this.transformers, maxDepth, options: opts }, value, 0);
46
62
  const json = opts.pretty
47
63
  ? JSON.stringify(transformed, null, opts.indent ?? 2)
48
64
  : JSON.stringify(transformed);
49
- this.assertOutputSize(json, maxSize);
65
+ assertByteSize(json, maxSize);
50
66
  return json;
51
67
  }
68
+ // The fast path stays a bare `JSON.stringify` by default, but a depth
69
+ // limit the caller asked for must still be enforced: `maxDepth` used to
70
+ // be read only when `preserveTypes` was on, so a per-call or per-instance
71
+ // limit was silently ignored on the path most callers use.
72
+ if (opts.maxDepth !== undefined) {
73
+ assertDepthWithinLimit(value, maxDepth);
74
+ }
52
75
  const json = opts.pretty
53
76
  ? JSON.stringify(value, null, opts.indent ?? 2)
54
77
  : JSON.stringify(value);
55
- this.assertOutputSize(json, maxSize);
78
+ assertByteSize(json, maxSize);
56
79
  return json;
57
80
  }
58
81
  deserialize(value, options) {
@@ -62,138 +85,28 @@ export class JSONSerializer {
62
85
  // the string handed to `deserialize` arrives from a queue, an RPC peer, or
63
86
  // a request body.
64
87
  const maxSize = opts.maxSize ?? SerializationLimits.MAX_SIZE;
65
- this.assertInputSize(value, maxSize);
66
- if (opts.strict === true)
67
- this.assertValidJson(value);
68
- const parsed = JSON.parse(value);
88
+ assertByteSize(value, maxSize);
89
+ const parsed = this.parse(value);
69
90
  if (opts.preserveTypes === true) {
70
91
  const maxDepth = opts.maxDepth ?? SerializationLimits.MAX_DEPTH;
71
92
  assertDepthWithinLimit(parsed, maxDepth);
72
- return this.restoreValue(parsed, 0, maxDepth, opts);
73
- }
74
- return parsed;
75
- }
76
- transformValue(value, depth, maxDepth, options) {
77
- if (value === null || value === undefined)
78
- return value;
79
- if (typeof value === "bigint") {
80
- const transformer = this.transformers.findForValue(value);
81
- return transformer
82
- ? transformer.serialize(value, options)
83
- : value.toString();
84
- }
85
- if (typeof value !== "object")
86
- return value;
87
- if (depth >= maxDepth) {
88
- // Depth is pre-checked by assertDepthWithinLimit, so this is a belt-and
89
- // braces guard. Returning the raw value would silently emit an untagged
90
- // Map or Date, which cannot round-trip — fail loudly instead.
91
- throw new Error(`Serialization exceeded maximum depth of ${maxDepth}`);
92
- }
93
- if (Array.isArray(value)) {
94
- return value.map((item) => this.transformValue(item, depth + 1, maxDepth, options));
95
- }
96
- const transformer = this.transformers.findForValue(value);
97
- if (transformer) {
98
- const raw = transformer.serialize(value, options);
99
- return this.transformValue(raw, depth + 1, maxDepth, options);
100
- }
101
- if (isPlainObject(value)) {
102
- const result = {};
103
- for (const key of Object.keys(value)) {
104
- defineKey(result, key, this.transformValue(value[key], depth + 1, maxDepth, options), options.allowUnsafeKeys === true);
105
- }
106
- return result;
107
- }
108
- return value;
109
- }
110
- restoreValue(value, depth, maxDepth, options) {
111
- if (value === null || value === undefined)
112
- return value;
113
- if (typeof value !== "object")
114
- return value;
115
- if (depth >= maxDepth) {
116
- throw new Error(`Deserialization exceeded maximum depth of ${maxDepth}`);
93
+ return restoreValue({ transformers: this.transformers, maxDepth, options: opts }, parsed, 0);
117
94
  }
118
- if (Array.isArray(value)) {
119
- return value.map((item) => this.restoreValue(item, depth + 1, maxDepth, options));
95
+ // Same contract on the fast path: an explicit `maxDepth` bounds input
96
+ // that arrives from the wire, whether or not types are being restored.
97
+ if (opts.maxDepth !== undefined) {
98
+ assertDepthWithinLimit(parsed, opts.maxDepth);
120
99
  }
121
- const obj = value;
122
- const typeTag = obj[SerializationTags.TYPE];
123
- if (typeof typeTag === "string" && this.transformers.has(typeTag)) {
124
- // Restore the children first. A transformer receives a plain structure
125
- // and has no way to recurse back into this serializer, so handing it the
126
- // still-tagged payload is what used to leave a Map full of raw
127
- // `{$type, $value}` objects.
128
- const restoredShell = {};
129
- for (const key of Object.keys(obj)) {
130
- defineKey(restoredShell, key, key === SerializationTags.TYPE
131
- ? obj[key]
132
- : this.restoreValue(obj[key], depth + 1, maxDepth, options), options.allowUnsafeKeys === true);
133
- }
134
- return this.transformers.get(typeTag).deserialize(restoredShell, options);
135
- }
136
- // An unknown tag is ordinary data. Throwing here let any peer crash the
137
- // consumer with `{"$type":"anything"}`, and made legitimate payloads that
138
- // happen to carry a `$type` field unparseable.
139
- if (typeof typeTag === "string" && options.strict === true) {
140
- throw new Error(`Unknown serialization type tag: "${typeTag}". ` +
141
- "Register a transformer for it, or deserialize without strict mode.");
142
- }
143
- if (isPlainObject(value)) {
144
- const result = {};
145
- for (const key of Object.keys(value)) {
146
- defineKey(result, key, this.restoreValue(value[key], depth + 1, maxDepth, options), options.allowUnsafeKeys === true);
147
- }
148
- return result;
149
- }
150
- return value;
100
+ return parsed;
151
101
  }
152
- assertValidJson(value) {
102
+ /** Parses JSON, reporting malformed input as a typed error. */
103
+ parse(value) {
153
104
  try {
154
- JSON.parse(value);
105
+ return JSON.parse(value);
155
106
  }
156
107
  catch (err) {
157
- throw new Error(`Invalid JSON: ${err.message}`);
158
- }
159
- }
160
- assertOutputSize(json, maxSize) {
161
- const size = byteLength(json);
162
- if (size > maxSize) {
163
- throw new Error(`Serialized payload too large: ${size} bytes (max: ${maxSize})`);
164
- }
165
- }
166
- assertInputSize(json, maxSize) {
167
- const size = byteLength(json);
168
- if (size > maxSize) {
169
- throw new Error(`Serialized payload too large: ${size} bytes (max: ${maxSize})`);
108
+ throw new InvalidSerializedDataError(`Invalid JSON: ${err.message}`, { format: "json", cause: err });
170
109
  }
171
110
  }
172
111
  }
173
- /** Byte length of a string, in whichever runtime we are on. */
174
- function byteLength(value) {
175
- return typeof Buffer !== "undefined"
176
- ? Buffer.byteLength(value, "utf-8")
177
- : new TextEncoder().encode(value).byteLength;
178
- }
179
- /**
180
- * Assigns a key onto a freshly built object without invoking a setter.
181
- *
182
- * Plain assignment of `__proto__` does not create an own property — it calls
183
- * the inherited setter and replaces the object's prototype, so an attacker's
184
- * keys resolve on the result while `Object.keys` shows nothing. `defineProperty`
185
- * always creates a real own property, and forbidden keys are dropped outright
186
- * unless the caller has explicitly opted in with `allowUnsafeKeys`.
187
- */
188
- function defineKey(target, key, value, allowUnsafeKeys) {
189
- if (!allowUnsafeKeys && SCHEMA_FORBIDDEN_KEYS.has(key)) {
190
- return;
191
- }
192
- Object.defineProperty(target, key, {
193
- value,
194
- writable: true,
195
- enumerable: true,
196
- configurable: true,
197
- });
198
- }
199
112
  //# sourceMappingURL=jsonSerializer.core.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @zudojs/serialization — Escaping of user objects that look like type tags.
3
+ *
4
+ * With `preserveTypes`, a tagged object `{ "$type": "Map", "$value": [...] }`
5
+ * is revived into a real `Map` on read. A *user* object carrying its own
6
+ * string `$type` key used to be written out verbatim, so any caller who
7
+ * controlled part of a payload could choose the runtime type the reader got
8
+ * back (an `Error`, a `Buffer`, a `BigInt`), or plant a malformed tag that
9
+ * made the record unreadable.
10
+ *
11
+ * Such objects are now written wrapped in a reserved tag:
12
+ *
13
+ * ```json
14
+ * { "$type": "Object", "$value": { "$type": "Map", "note": "user data" } }
15
+ * ```
16
+ *
17
+ * and unwrapped on read into the plain object they were, with their own
18
+ * `$type` key left uninterpreted.
19
+ */
20
+ /**
21
+ * Reserved type tag that marks an escaped plain object. No transformer may
22
+ * register under this name.
23
+ */
24
+ export declare const ESCAPED_OBJECT_TAG = "Object";
25
+ /** True when a plain object would be mistaken for a type tag on read. */
26
+ export declare function needsEscape(value: Record<string, unknown>): boolean;
27
+ /** Wraps an already-transformed object body in the reserved escape tag. */
28
+ export declare function escapeObject(body: Record<string, unknown>): Record<string, unknown>;
29
+ /**
30
+ * Returns the escaped body when `value` is a well-formed escape wrapper,
31
+ * `undefined` when it is not an escape wrapper at all, and `null` when it
32
+ * claims to be one but its `$value` is not a plain object.
33
+ */
34
+ export declare function escapedBody(value: Record<string, unknown>): Record<string, unknown> | null | undefined;
35
+ //# sourceMappingURL=jsonSerializer.escape.d.ts.map
@@ -0,0 +1,50 @@
1
+ /**
2
+ * @zudojs/serialization — Escaping of user objects that look like type tags.
3
+ *
4
+ * With `preserveTypes`, a tagged object `{ "$type": "Map", "$value": [...] }`
5
+ * is revived into a real `Map` on read. A *user* object carrying its own
6
+ * string `$type` key used to be written out verbatim, so any caller who
7
+ * controlled part of a payload could choose the runtime type the reader got
8
+ * back (an `Error`, a `Buffer`, a `BigInt`), or plant a malformed tag that
9
+ * made the record unreadable.
10
+ *
11
+ * Such objects are now written wrapped in a reserved tag:
12
+ *
13
+ * ```json
14
+ * { "$type": "Object", "$value": { "$type": "Map", "note": "user data" } }
15
+ * ```
16
+ *
17
+ * and unwrapped on read into the plain object they were, with their own
18
+ * `$type` key left uninterpreted.
19
+ */
20
+ import { SerializationTags } from "@zudojs/constants";
21
+ import { isPlainObject } from "@zudojs/types";
22
+ /**
23
+ * Reserved type tag that marks an escaped plain object. No transformer may
24
+ * register under this name.
25
+ */
26
+ export const ESCAPED_OBJECT_TAG = "Object";
27
+ /** True when a plain object would be mistaken for a type tag on read. */
28
+ export function needsEscape(value) {
29
+ return (Object.hasOwn(value, SerializationTags.TYPE) &&
30
+ typeof value[SerializationTags.TYPE] === "string");
31
+ }
32
+ /** Wraps an already-transformed object body in the reserved escape tag. */
33
+ export function escapeObject(body) {
34
+ return {
35
+ [SerializationTags.TYPE]: ESCAPED_OBJECT_TAG,
36
+ [SerializationTags.VALUE]: body,
37
+ };
38
+ }
39
+ /**
40
+ * Returns the escaped body when `value` is a well-formed escape wrapper,
41
+ * `undefined` when it is not an escape wrapper at all, and `null` when it
42
+ * claims to be one but its `$value` is not a plain object.
43
+ */
44
+ export function escapedBody(value) {
45
+ if (value[SerializationTags.TYPE] !== ESCAPED_OBJECT_TAG)
46
+ return undefined;
47
+ const body = value[SerializationTags.VALUE];
48
+ return isPlainObject(body) ? body : null;
49
+ }
50
+ //# sourceMappingURL=jsonSerializer.escape.js.map
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @zudojs/serialization — Low-level helpers shared by the JSON walkers.
3
+ */
4
+ /** Byte length of a string, in whichever runtime we are on. */
5
+ export declare function byteLength(value: string): number;
6
+ /**
7
+ * Throws `SerializationPayloadTooLargeError` when a JSON string is larger
8
+ * than `maxSize` bytes.
9
+ */
10
+ export declare function assertByteSize(json: string, maxSize: number): void;
11
+ /**
12
+ * Assigns a key onto a freshly built object without invoking a setter.
13
+ *
14
+ * Plain assignment of `__proto__` does not create an own property — it calls
15
+ * the inherited setter and replaces the object's prototype, so an attacker's
16
+ * keys resolve on the result while `Object.keys` shows nothing. `defineProperty`
17
+ * always creates a real own property, and forbidden keys are dropped outright
18
+ * unless the caller has explicitly opted in with `allowUnsafeKeys`.
19
+ */
20
+ export declare function defineKey(target: Record<string, unknown>, key: string, value: unknown, allowUnsafeKeys: boolean): void;
21
+ //# sourceMappingURL=jsonSerializer.keys.d.ts.map
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @zudojs/serialization — Low-level helpers shared by the JSON walkers.
3
+ */
4
+ import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
5
+ import { SerializationPayloadTooLargeError } from "@zudojs/errors";
6
+ /** Byte length of a string, in whichever runtime we are on. */
7
+ export function byteLength(value) {
8
+ return typeof Buffer !== "undefined"
9
+ ? Buffer.byteLength(value, "utf-8")
10
+ : new TextEncoder().encode(value).byteLength;
11
+ }
12
+ /**
13
+ * Throws `SerializationPayloadTooLargeError` when a JSON string is larger
14
+ * than `maxSize` bytes.
15
+ */
16
+ export function assertByteSize(json, maxSize) {
17
+ const size = byteLength(json);
18
+ if (size > maxSize)
19
+ throw new SerializationPayloadTooLargeError(size, maxSize);
20
+ }
21
+ /**
22
+ * Assigns a key onto a freshly built object without invoking a setter.
23
+ *
24
+ * Plain assignment of `__proto__` does not create an own property — it calls
25
+ * the inherited setter and replaces the object's prototype, so an attacker's
26
+ * keys resolve on the result while `Object.keys` shows nothing. `defineProperty`
27
+ * always creates a real own property, and forbidden keys are dropped outright
28
+ * unless the caller has explicitly opted in with `allowUnsafeKeys`.
29
+ */
30
+ export function defineKey(target, key, value, allowUnsafeKeys) {
31
+ if (!allowUnsafeKeys && SCHEMA_FORBIDDEN_KEYS.has(key)) {
32
+ return;
33
+ }
34
+ Object.defineProperty(target, key, {
35
+ value,
36
+ writable: true,
37
+ enumerable: true,
38
+ configurable: true,
39
+ });
40
+ }
41
+ //# sourceMappingURL=jsonSerializer.keys.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @zudojs/serialization — Deserialize-side walker (`preserveTypes: true`).
3
+ *
4
+ * Revives tagged values through their transformers and unwraps escaped
5
+ * plain objects (see `jsonSerializer.escape.ts`).
6
+ */
7
+ import type { DeserializeOptions } from "../serializerTypes/index.js";
8
+ import type { TransformerRegistry } from "../serializerTransforms/index.js";
9
+ /** What the walker needs besides the value. */
10
+ export interface RestoreWalk {
11
+ readonly transformers: TransformerRegistry;
12
+ readonly maxDepth: number;
13
+ readonly options: DeserializeOptions;
14
+ }
15
+ /** Rebuilds runtime values from their tagged JSON representation. */
16
+ export declare function restoreValue(walk: RestoreWalk, value: unknown, depth: number): unknown;
17
+ //# sourceMappingURL=jsonSerializer.restore.d.ts.map
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @zudojs/serialization — Deserialize-side walker (`preserveTypes: true`).
3
+ *
4
+ * Revives tagged values through their transformers and unwraps escaped
5
+ * plain objects (see `jsonSerializer.escape.ts`).
6
+ */
7
+ import { InvalidSerializedDataError, SerializationDepthError, TransformerError, isSerializationError, } from "@zudojs/errors";
8
+ import { SerializationTags } from "@zudojs/constants";
9
+ import { isPlainObject } from "@zudojs/types";
10
+ import { escapedBody } from "./jsonSerializer.escape.js";
11
+ import { defineKey } from "./jsonSerializer.keys.js";
12
+ /** Rebuilds runtime values from their tagged JSON representation. */
13
+ export function restoreValue(walk, value, depth) {
14
+ if (value === null || typeof value !== "object")
15
+ return value;
16
+ if (depth >= walk.maxDepth) {
17
+ throw new SerializationDepthError(depth + 1, walk.maxDepth);
18
+ }
19
+ if (Array.isArray(value)) {
20
+ return value.map((item) => restoreValue(walk, item, depth + 1));
21
+ }
22
+ const obj = value;
23
+ const strict = walk.options.strict === true;
24
+ const body = escapedBody(obj);
25
+ if (body)
26
+ return restoreEntries(walk, body, depth);
27
+ if (body === null && strict) {
28
+ throw new InvalidSerializedDataError('Escaped object tag "Object" must carry a plain-object $value.', { format: "json" });
29
+ }
30
+ const tag = obj[SerializationTags.TYPE];
31
+ if (body === undefined && typeof tag === "string") {
32
+ if (walk.transformers.has(tag))
33
+ return revive(walk, tag, obj, depth);
34
+ // An unknown tag is ordinary data unless the caller asked for strictness.
35
+ if (strict) {
36
+ throw new InvalidSerializedDataError(`Unknown serialization type tag: "${tag}". ` +
37
+ "Register a transformer for it, or deserialize without strict mode.", { format: "json" });
38
+ }
39
+ }
40
+ return isPlainObject(obj) ? restoreEntries(walk, obj, depth) : obj;
41
+ }
42
+ /**
43
+ * Hands a tag to its transformer after restoring the tag's children.
44
+ *
45
+ * A tag the transformer rejects (say an unparseable Date) used to make the
46
+ * whole record permanently unreadable. Outside strict mode it now reads back
47
+ * as the plain object it is; strict mode throws a typed `TransformerError`.
48
+ */
49
+ function revive(walk, tag, obj, depth) {
50
+ const shell = restoreEntries(walk, obj, depth, SerializationTags.TYPE);
51
+ try {
52
+ return walk.transformers.get(tag).deserialize(shell, walk.options);
53
+ }
54
+ catch (error) {
55
+ if (walk.options.strict !== true)
56
+ return shell;
57
+ if (isSerializationError(error))
58
+ throw error;
59
+ throw new TransformerError(tag, error.message, { cause: error });
60
+ }
61
+ }
62
+ /** Restores each own key of a plain object into a fresh object. */
63
+ function restoreEntries(walk, obj, depth, verbatimKey) {
64
+ const result = {};
65
+ for (const key of Object.keys(obj)) {
66
+ defineKey(result, key, key === verbatimKey ? obj[key] : restoreValue(walk, obj[key], depth + 1), walk.options.allowUnsafeKeys === true);
67
+ }
68
+ return result;
69
+ }
70
+ //# sourceMappingURL=jsonSerializer.restore.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @zudojs/serialization — Serialize-side walker (`preserveTypes: true`).
3
+ *
4
+ * Replaces values that have a registered transformer with their tagged form,
5
+ * and escapes plain objects that would otherwise read back as a tag.
6
+ */
7
+ import type { SerializeOptions } from "../serializerTypes/index.js";
8
+ import type { TransformerRegistry } from "../serializerTransforms/index.js";
9
+ /** What the walker needs besides the value. */
10
+ export interface TransformWalk {
11
+ readonly transformers: TransformerRegistry;
12
+ readonly maxDepth: number;
13
+ readonly options: SerializeOptions;
14
+ }
15
+ /** Converts a value into its JSON-safe, tagged representation. */
16
+ export declare function transformValue(walk: TransformWalk, value: unknown, depth: number): unknown;
17
+ //# sourceMappingURL=jsonSerializer.transform.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @zudojs/serialization — Serialize-side walker (`preserveTypes: true`).
3
+ *
4
+ * Replaces values that have a registered transformer with their tagged form,
5
+ * and escapes plain objects that would otherwise read back as a tag.
6
+ */
7
+ import { SerializationDepthError } from "@zudojs/errors";
8
+ import { isPlainObject } from "@zudojs/types";
9
+ import { escapeObject, needsEscape } from "./jsonSerializer.escape.js";
10
+ import { defineKey } from "./jsonSerializer.keys.js";
11
+ /** Converts a value into its JSON-safe, tagged representation. */
12
+ export function transformValue(walk, value, depth) {
13
+ if (value === null || value === undefined)
14
+ return value;
15
+ if (typeof value === "bigint") {
16
+ const transformer = walk.transformers.findForValue(value);
17
+ return transformer
18
+ ? transformer.serialize(value, walk.options)
19
+ : value.toString();
20
+ }
21
+ if (typeof value !== "object")
22
+ return value;
23
+ if (depth >= walk.maxDepth) {
24
+ // Depth is pre-checked by assertDepthWithinLimit, so this is a belt-and
25
+ // braces guard. Returning the raw value would silently emit an untagged
26
+ // Map or Date, which cannot round-trip — fail loudly instead.
27
+ throw new SerializationDepthError(depth + 1, walk.maxDepth);
28
+ }
29
+ if (Array.isArray(value)) {
30
+ const out = [];
31
+ for (let i = 0; i < value.length; i++) {
32
+ out.push(transformValue(walk, value[i], depth + 1));
33
+ }
34
+ return out;
35
+ }
36
+ const transformer = walk.transformers.findForValue(value);
37
+ if (transformer) {
38
+ const raw = transformer.serialize(value, walk.options);
39
+ // The transformer's own tag object is not user data: its children are
40
+ // walked, but it is never escaped.
41
+ return isPlainObject(raw)
42
+ ? transformEntries(walk, raw, depth + 1)
43
+ : transformValue(walk, raw, depth + 1);
44
+ }
45
+ if (isPlainObject(value)) {
46
+ const body = transformEntries(walk, value, depth);
47
+ return needsEscape(value) ? escapeObject(body) : body;
48
+ }
49
+ return value;
50
+ }
51
+ /** Transforms each own enumerable key of a plain object. */
52
+ function transformEntries(walk, value, depth) {
53
+ const result = {};
54
+ for (const key of Object.keys(value)) {
55
+ defineKey(result, key, transformValue(walk, value[key], depth + 1), walk.options.allowUnsafeKeys === true);
56
+ }
57
+ return result;
58
+ }
59
+ //# sourceMappingURL=jsonSerializer.transform.js.map
@@ -5,12 +5,18 @@
5
5
  * Provides a clean public API for creating serializers without
6
6
  * exposing concrete class constructors.
7
7
  */
8
- import type { Serializer, SerializationFormat } from "../serializerTypes/index.js";
8
+ import type { Serializer, SerializationFormat, SerializeOptions, DeserializeOptions } from "../serializerTypes/index.js";
9
9
  import { SerializationFormat as Format } from "@zudojs/constants";
10
10
  import { TransformerRegistry } from "../serializerTransforms/index.js";
11
11
  import { SerializerRegistry } from "./serializerRegistry.core.js";
12
- /** Options accepted when creating a serializer. */
13
- export interface CreateSerializerOptions {
12
+ /**
13
+ * Options accepted when creating a serializer.
14
+ *
15
+ * Every serialize/deserialize option (`pretty`, `preserveTypes`, `maxDepth`,
16
+ * `maxSize`, `strict`, `allowUnsafeKeys`, ...) is accepted as a per-instance
17
+ * default that each call can still override.
18
+ */
19
+ export interface CreateSerializerOptions extends SerializeOptions, DeserializeOptions {
14
20
  /** Transformer registry to use instead of the built-in one. */
15
21
  readonly transformers?: TransformerRegistry;
16
22
  /** Pretty-print by default. Overridable per call. */
@@ -12,19 +12,13 @@ import { TransformerRegistry } from "../serializerTransforms/index.js";
12
12
  import { SerializerRegistry } from "./serializerRegistry.core.js";
13
13
  export function createSerializer(format, options) {
14
14
  switch (format) {
15
- case Format.JSON:
16
- // `pretty` and `preserveTypes` used to be accepted and thrown away.
17
- // They are now carried as per-instance defaults that each call can
18
- // still override.
19
- return new JSONSerializer({
20
- transformers: options?.transformers,
21
- defaults: {
22
- ...(options?.pretty !== undefined ? { pretty: options.pretty } : {}),
23
- ...(options?.preserveTypes !== undefined
24
- ? { preserveTypes: options.preserveTypes }
25
- : {}),
26
- },
27
- });
15
+ case Format.JSON: {
16
+ // Only `pretty` and `preserveTypes` used to be forwarded, so instance
17
+ // limits such as `maxDepth`/`maxSize`/`strict` were silently dropped.
18
+ const { transformers, ...rest } = options ?? {};
19
+ const defaults = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined));
20
+ return new JSONSerializer({ transformers, defaults });
21
+ }
28
22
  default:
29
23
  throw new UnsupportedSerializationFormatError(format);
30
24
  }
@@ -4,8 +4,18 @@
4
4
  * Preserves BigInt values across serialization boundaries
5
5
  * using string representation.
6
6
  */
7
- import { SerializationTags } from "@zudojs/constants";
7
+ import { SerializationLimits, SerializationTags } from "@zudojs/constants";
8
+ import { InvalidSerializedDataError, SerializeError } from "@zudojs/errors";
8
9
  const BIGINT_TYPE = "BigInt";
10
+ /**
11
+ * Most decimal digits a BigInt may carry across the wire. `BigInt(raw)` is
12
+ * super-linear in the digit count, so one 10 MB `{"$type":"BigInt"}` payload
13
+ * used to stall a consumer for seconds. Shared with `@zudojs/schema`'s
14
+ * coercion cap through `SerializationLimits.MAX_BIGINT_DIGITS`.
15
+ */
16
+ const MAX_BIGINT_DIGITS = SerializationLimits.MAX_BIGINT_DIGITS;
17
+ /** Plain decimal integer, bounded, as `bigint.toString()` writes it. */
18
+ const BIGINT_PATTERN = new RegExp(`^-?\\d{1,${MAX_BIGINT_DIGITS}}$`);
9
19
  /** Transformer that handles BigInt round-trips. */
10
20
  export const BigIntTransformer = {
11
21
  type: BIGINT_TYPE,
@@ -13,23 +23,27 @@ export const BigIntTransformer = {
13
23
  return typeof value === "bigint";
14
24
  },
15
25
  serialize(value) {
26
+ const text = value.toString();
27
+ if (!BIGINT_PATTERN.test(text)) {
28
+ throw new SerializeError(`BigInt exceeds ${MAX_BIGINT_DIGITS} digits and could not be read back.`);
29
+ }
16
30
  return {
17
31
  [SerializationTags.TYPE]: BIGINT_TYPE,
18
- [SerializationTags.VALUE]: value.toString(),
32
+ [SerializationTags.VALUE]: text,
19
33
  };
20
34
  },
21
35
  deserialize(value) {
22
36
  const data = value;
23
37
  const raw = data[SerializationTags.VALUE];
24
38
  if (typeof raw !== "string") {
25
- throw new Error(`Invalid BigInt serialized value: expected string, got ${typeof raw}`);
26
- }
27
- try {
28
- return BigInt(raw);
39
+ throw new InvalidSerializedDataError(`Invalid BigInt serialized value: expected string, got ${typeof raw}`);
29
40
  }
30
- catch {
31
- throw new Error(`Cannot parse BigInt from: "${raw}"`);
41
+ // Checked before `BigInt()` runs, so an oversized value costs a regex
42
+ // scan bounded by the digit limit rather than a multi-second parse.
43
+ if (!BIGINT_PATTERN.test(raw)) {
44
+ throw new InvalidSerializedDataError(`Invalid BigInt serialized value: expected at most ${MAX_BIGINT_DIGITS} decimal digits`);
32
45
  }
46
+ return BigInt(raw);
33
47
  },
34
48
  };
35
49
  //# sourceMappingURL=bigint.transformer.js.map
@@ -5,6 +5,7 @@
5
5
  * using ISO-8601 string representation.
6
6
  */
7
7
  import { SerializationTags } from "@zudojs/constants";
8
+ import { InvalidSerializedDataError, SerializeError } from "@zudojs/errors";
8
9
  const DATE_TYPE = "Date";
9
10
  /** Transformer that handles Date round-trips. */
10
11
  export const DateTransformer = {
@@ -13,6 +14,9 @@ export const DateTransformer = {
13
14
  return value instanceof Date;
14
15
  },
15
16
  serialize(value) {
17
+ if (Number.isNaN(value.getTime())) {
18
+ throw new SerializeError("Cannot serialize an invalid Date.");
19
+ }
16
20
  return {
17
21
  [SerializationTags.TYPE]: DATE_TYPE,
18
22
  [SerializationTags.VALUE]: value.toISOString(),
@@ -22,11 +26,11 @@ export const DateTransformer = {
22
26
  const data = value;
23
27
  const raw = data[SerializationTags.VALUE];
24
28
  if (typeof raw !== "string") {
25
- throw new Error(`Invalid Date serialized value: expected string, got ${typeof raw}`);
29
+ throw new InvalidSerializedDataError(`Invalid Date serialized value: expected string, got ${typeof raw}`);
26
30
  }
27
31
  const date = new Date(raw);
28
32
  if (Number.isNaN(date.getTime())) {
29
- throw new Error(`Invalid Date value: "${raw}"`);
33
+ throw new InvalidSerializedDataError(`Invalid Date value: "${raw}"`);
30
34
  }
31
35
  return date;
32
36
  },
@@ -5,6 +5,7 @@
5
5
  * using an array-of-entries representation.
6
6
  */
7
7
  import { SerializationTags } from "@zudojs/constants";
8
+ import { InvalidSerializedDataError } from "@zudojs/errors";
8
9
  const MAP_TYPE = "Map";
9
10
  /** Transformer that handles Map round-trips. */
10
11
  export const MapTransformer = {
@@ -26,7 +27,7 @@ export const MapTransformer = {
26
27
  const data = value;
27
28
  const raw = data[SerializationTags.VALUE];
28
29
  if (!Array.isArray(raw)) {
29
- throw new Error(`Invalid Map serialized value: expected array, got ${typeof raw}`);
30
+ throw new InvalidSerializedDataError(`Invalid Map serialized value: expected array, got ${typeof raw}`);
30
31
  }
31
32
  return new Map(raw);
32
33
  },
@@ -5,6 +5,7 @@
5
5
  * using an array representation.
6
6
  */
7
7
  import { SerializationTags } from "@zudojs/constants";
8
+ import { InvalidSerializedDataError } from "@zudojs/errors";
8
9
  const SET_TYPE = "Set";
9
10
  /** Transformer that handles Set round-trips. */
10
11
  export const SetTransformer = {
@@ -22,7 +23,7 @@ export const SetTransformer = {
22
23
  const data = value;
23
24
  const raw = data[SerializationTags.VALUE];
24
25
  if (!Array.isArray(raw)) {
25
- throw new Error(`Invalid Set serialized value: expected array, got ${typeof raw}`);
26
+ throw new InvalidSerializedDataError(`Invalid Set serialized value: expected array, got ${typeof raw}`);
26
27
  }
27
28
  return new Set(raw);
28
29
  },
@@ -5,6 +5,7 @@
5
5
  * using base64 encoding.
6
6
  */
7
7
  import { SerializationTags } from "@zudojs/constants";
8
+ import { InvalidSerializedDataError } from "@zudojs/errors";
8
9
  import { toBase64, fromBase64 } from "./encoding.utils.js";
9
10
  const BUFFER_TYPE = "Buffer";
10
11
  /** Transformer that handles Uint8Array round-trips. */
@@ -24,7 +25,7 @@ export const BufferTransformer = {
24
25
  const data = value;
25
26
  const raw = data[SerializationTags.VALUE];
26
27
  if (typeof raw !== "string") {
27
- throw new Error(`Invalid Buffer serialized value: expected string, got ${typeof raw}`);
28
+ throw new InvalidSerializedDataError(`Invalid Buffer serialized value: expected string, got ${typeof raw}`);
28
29
  }
29
30
  return fromBase64(raw);
30
31
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/serialization",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Data translation layer with JSON serializer, type transformers, envelopes, and registry for Zudojs applications.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,16 +19,20 @@
19
19
  "LICENSE"
20
20
  ],
21
21
  "dependencies": {
22
- "@zudojs/constants": "1.0.0",
23
- "@zudojs/errors": "1.0.0",
24
- "@zudojs/types": "1.0.0",
25
- "@zudojs/validation": "1.0.0"
22
+ "@zudojs/constants": "1.1.0",
23
+ "@zudojs/errors": "1.1.0",
24
+ "@zudojs/types": "1.1.0",
25
+ "@zudojs/validation": "1.0.2"
26
26
  },
27
27
  "devDependencies": {
28
28
  "typescript": "7.0.2",
29
29
  "vitest": "^4.1.11"
30
30
  },
31
31
  "license": "MIT",
32
+ "author": {
33
+ "name": "Oluwayemi Oyinlola",
34
+ "url": "https://github.com/oyinlola-tech"
35
+ },
32
36
  "keywords": [
33
37
  "zudojs",
34
38
  "serialization",