Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
# @suaveplan/json - Safe JSON parse/stringify with prototype-pollution protection, circular-ref detection, and OTel instrumentation
## Summary
Safe, enterprise-grade JSON utilities with prototype-pollution protection,
circular-reference detection, and OpenTelemetry instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [errors](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [json-error](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [json-error](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-error](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-error](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_- [json-error](./src/errors/json-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_ - [safe-json](./src/safe-json/safe-json.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_ - [types](./src/types/types.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/json
```
## Quick start
```ts
import { safeParse, safeStringify, tryParse, isValidJSON, getStringifySize } from "@suaveplan/json";
// Safe parse — throws JsonError on invalid JSON or forbidden keys
const obj = safeParse<{ a: number }>('{"a": 1}');
// Safe stringify — throws JsonError on circular references
const json = safeStringify(obj);
// Non-throwing parse
const val = tryParse("bad input"); // undefined
// Validity check (handles "null" correctly)
isValidJSON("null"); // true
isValidJSON("bad"); // false
// UTF-8 byte size of JSON representation
getStringifySize({ hello: "world" }); // 15
```
## Modules
| Module | Description |
|--------|-------------|
| `@suaveplan/json` | Main entry — all public exports |
| `@suaveplan/json/safe-json` | `safeParse`, `safeStringify`, `tryParse`, `isValidJSON`, `getStringifySize` |
| `@suaveplan/json/errors` | `JsonError`, `JSON_CODES`, `JsonCode` |
## Error handling
Every failure is a structured `JsonError` with a stable code:
| Code | Trigger |
|------|---------|
| `JSON.PARSE.FAILED` | Invalid JSON syntax or aborted `AbortSignal` |
| `JSON.PARSE.PROTOTYPE_POLLUTION` | Forbidden key (`__proto__`, `constructor`, `prototype`) |
| `JSON.STRINGIFY.CIRCULAR` | True circular reference detected |
| `JSON.STRINGIFY.FAILED` | Aborted signal or non-serialisable value (e.g. `BigInt`) |
```ts
import { safeParse, JsonError, JSON_CODES } from "@suaveplan/json";
try {
safeParse('{"__proto__": {}}');
} catch (err) {
if (err instanceof JsonError && err.code === JSON_CODES["PARSE.PROTOTYPE_POLLUTION"]) {
console.error("Prototype pollution blocked");
}
}
```
## Options
### `ParseOptions`
```ts
interface ParseOptions {
forbiddenKeys?: string[]; // default: ["__proto__", "constructor", "prototype"]
maxDepth?: number; // default: 50
signal?: AbortSignal;
}
```
### `StringifyOptions`
```ts
interface StringifyOptions {
space?: number;
signal?: AbortSignal;
maxDepth?: number; // default: 100
}
```
## Benchmarks
Measured on Intel Xeon E5-2696 v4 @ 2.20 GHz, Bun 1.3.13:
```text
benchmark avg (min … max)
-------------------------------------------
safeParse small 9.84 µs/iter
JSON.parse small 592.70 ns/iter
safeStringify small 1.99 µs/iter
JSON.stringify small 212.99 ns/iter
safeParse medium 42.11 µs/iter
safeStringify medium 11.92 µs/iter
```
The overhead relative to the native functions comes from the OTel span
creation, reviver/replacer function calls, and ancestor-stack tracking.
In production with a no-op tracer the spans cost ~0 ns.
## Links
- [`src/safe-json/safe-json.md`](src/safe-json/safe-json.md) — implementation notes
- [`src/errors/json-error.md`](src/errors/json-error.md) — error codes
- [`src/types/types.md`](src/types/types.md) — option types

View File

@@ -0,0 +1,76 @@
{
"name": "@suaveplan/json",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Safe JSON parse/stringify with prototype-pollution protection, circular-ref detection, and OTel instrumentation",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./safe-json": {
"import": {
"types": "./dist/safe-json/index.d.ts",
"default": "./dist/safe-json/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/index.d.ts",
"default": "./dist/errors/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1 @@
export { JSON_CODES, type JsonCode, JsonError } from "./json-error.js";

View File

@@ -0,0 +1,103 @@
# json-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the typed error class and stable code registry for `@suaveplan/json`.
All error conditions produced by this package surface as `JsonError` instances
with machine-readable codes so callers can branch on failure mode without
parsing message strings.
## Features
- `JSON_CODES` registry — four stable codes under the `JSON.*` namespace
- `JsonCode` union type for compile-time exhaustiveness checks
- `JsonError` extends `SuaveplanError` — carries `code`, `cause`, and optional
context payload; integrates with the OTel span-event adapter from
`@suaveplan/error`
## Usage: Basic Example
```ts
import { JsonError, JSON_CODES } from "@suaveplan/json/errors";
throw new JsonError(
JSON_CODES["PARSE.FAILED"],
"Unexpected token at position 0"
);
```
## Usage: Advanced Example
```ts
import { JsonError, JSON_CODES } from "@suaveplan/json/errors";
function parseSafe(text: string): unknown {
try {
return JSON.parse(text);
} catch (cause) {
throw new JsonError(
JSON_CODES["PARSE.FAILED"],
`JSON parse failed: ${(cause as Error).message}`,
{ cause }
);
}
}
```
## API Reference
### `JSON_CODES`
```ts
const JSON_CODES: {
"PARSE.FAILED": "JSON/PARSE.FAILED";
"PARSE.PROTOTYPE_POLLUTION": "JSON/PARSE.PROTOTYPE_POLLUTION";
"STRINGIFY.CIRCULAR": "JSON/STRINGIFY.CIRCULAR";
"STRINGIFY.FAILED": "JSON/STRINGIFY.FAILED";
};
```
Stable, namespaced error codes produced by `defineCodes("JSON", [...])`.
Each code is a string literal of the form `"JSON/<segment>"`.
### `JsonCode`
```ts
type JsonCode = (typeof JSON_CODES)[keyof typeof JSON_CODES];
```
Union of all four code literals. Use this type for exhaustive `switch`
statements or discriminated-union handlers.
### `class JsonError`
```ts
class JsonError extends SuaveplanError<JsonCode> {
constructor(code: JsonCode, message: string, options?: SuaveplanErrorOptions);
}
```
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `code` | `JsonCode` | Machine-readable error code from `JSON_CODES`. |
| `message` | `string` | Human-readable description of the error. |
| `options` | `SuaveplanErrorOptions?` | Optional `cause`, `context`, and `meta` fields. |
**Throws** — never; constructor only constructs.
## Implementation Notes
The constructor is flagged with `// biome-ignore lint/complexity/noUselessConstructor`
because it widens the visibility of the `SuaveplanError` constructor from
`protected` to `public`. This is required by the Biome linter which otherwise
flags it as a no-op, but the widening is intentional and load-bearing.
Error codes are registered once at module load time via `defineCodes`. The
`defineCodes` helper from `@suaveplan/error` is idempotent within a process
(same namespace + same codes → same object reference), making it safe to import
across multiple entry points without duplication.

View File

@@ -0,0 +1,29 @@
import {
defineCodes,
SuaveplanError,
type SuaveplanErrorOptions,
} from "@suaveplan/error";
/**
* Error code registry for `@suaveplan/json`.
*/
export const JSON_CODES = defineCodes("JSON", [
"PARSE.FAILED",
"PARSE.PROTOTYPE_POLLUTION",
"STRINGIFY.CIRCULAR",
"STRINGIFY.FAILED",
] as const);
export type JsonCode = (typeof JSON_CODES)[keyof typeof JSON_CODES];
/** Typed error for `@suaveplan/json`. */
export class JsonError extends SuaveplanError<JsonCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens visibility
public constructor(
code: JsonCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
}
}

View File

@@ -0,0 +1,9 @@
export { JSON_CODES, type JsonCode, JsonError } from "./errors/index.js";
export {
getStringifySize,
isValidJSON,
safeParse,
safeStringify,
tryParse,
} from "./safe-json/index.js";
export type { ParseOptions, StringifyOptions } from "./types/index.js";

View File

@@ -0,0 +1,8 @@
export { JsonError } from "../errors/index.js";
export {
getStringifySize,
isValidJSON,
safeParse,
safeStringify,
tryParse,
} from "./safe-json.js";

View File

@@ -0,0 +1,209 @@
# safe-json
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides enterprise-grade `JSON.parse` and `JSON.stringify` wrappers that
defend against prototype pollution, circular references, and AbortSignal
cancellation. Every error surface is a structured `JsonError` with a stable,
machine-readable code — no raw `SyntaxError` instances escape this module.
All I/O-critical entry points emit OpenTelemetry spans for distributed tracing.
## Features
- `safeParse` — parses JSON with a reviver that blocks forbidden keys
(`__proto__`, `constructor`, `prototype` by default); configurable via
`ParseOptions.forbiddenKeys`
- `safeStringify` — stringifies with ancestor-stack cycle detection; DAG
sharing (same object at two different paths) is allowed; only true cycles
throw `STRINGIFY.CIRCULAR`
- `tryParse` — non-throwing wrapper returning `undefined` on any error
- `isValidJSON` — boolean check that correctly handles the `"null"` literal
- `getStringifySize` — UTF-8 byte size of the JSON representation
- OTel spans on `safeParse` and `safeStringify`
- AbortSignal support on both `safeParse` and `safeStringify`
## Usage: Basic Example
```ts
import { safeParse, safeStringify, tryParse, isValidJSON, getStringifySize } from "@suaveplan/json";
// Parse
const obj = safeParse<{ a: number }>('{"a":1}');
console.log(obj.a); // 1
// Stringify
const json = safeStringify({ a: 1 });
console.log(json); // '{"a":1}'
// Non-throwing parse
const result = tryParse("not-json"); // undefined
// Validity check
isValidJSON("null"); // true
isValidJSON("bad"); // false
// Byte size
getStringifySize({ hello: "world" }); // 15
```
## Usage: Advanced Example
```ts
import {
safeParse,
safeStringify,
JsonError,
JSON_CODES,
type ParseOptions,
type StringifyOptions,
} from "@suaveplan/json";
// Block additional keys beyond the defaults
const parseOpts: ParseOptions = {
forbiddenKeys: ["__proto__", "constructor", "prototype", "eval"],
};
try {
const data = safeParse<unknown>('{"eval":"alert(1)"}', parseOpts);
} catch (err) {
if (err instanceof JsonError && err.code === JSON_CODES["PARSE.PROTOTYPE_POLLUTION"]) {
console.error("Prototype pollution attempt blocked");
}
}
// AbortSignal cancellation
const controller = new AbortController();
setTimeout(() => controller.abort(), 10);
const stringifyOpts: StringifyOptions = {
space: 2,
signal: controller.signal,
};
const largePayload = Array.from({ length: 10000 }, (_, i) => ({ id: i }));
try {
safeStringify(largePayload, stringifyOpts);
} catch (err) {
if (err instanceof JsonError && err.code === JSON_CODES["STRINGIFY.FAILED"]) {
console.error("Stringify cancelled");
}
}
// Circular reference detection
const node: Record<string, unknown> = { name: "root" };
node.self = node;
try {
safeStringify(node);
} catch (err) {
if (err instanceof JsonError && err.code === JSON_CODES["STRINGIFY.CIRCULAR"]) {
console.error("Circular reference blocked");
}
}
```
## API Reference
### `safeParse<T>(text, options?): T`
Parses a JSON string with prototype-pollution protection.
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `text` | `string` | Raw JSON text to parse. |
| `options` | `ParseOptions?` | Optional parse configuration. |
**Returns** — The parsed value typed as `T`.
**Throws**
- `JsonError(PARSE.FAILED)` — invalid JSON syntax or aborted signal.
- `JsonError(PARSE.PROTOTYPE_POLLUTION)` — a forbidden key was encountered.
---
### `safeStringify(value, options?): string`
Stringifies a value with circular-reference detection.
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `value` | `unknown` | Value to serialise. |
| `options` | `StringifyOptions?` | Optional stringify configuration. |
**Returns** — The JSON string.
**Throws**
- `JsonError(STRINGIFY.CIRCULAR)` — a true cycle was detected.
- `JsonError(STRINGIFY.FAILED)` — aborted signal or non-serialisable value (e.g. `BigInt`).
---
### `tryParse<T>(text, options?): T | undefined`
Non-throwing parse; returns `undefined` on any error.
**Parameters** — same as `safeParse`.
**Returns** — The parsed value or `undefined`.
---
### `isValidJSON(text): boolean`
Returns `true` if the string is parseable by `safeParse` (respects the default
forbidden-key list). Returns `true` for `"null"` (unlike a naïve
`tryParse(text) !== undefined` check).
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `text` | `string` | The string to test. |
**Returns** — `boolean`.
---
### `getStringifySize(value): number`
**Parameters**
| Parameter | Type | Description |
|-----------|------|-------------|
| `value` | `unknown` | Any JSON-serialisable value. |
**Returns** — UTF-8 byte count of `JSON.stringify(value)`.
## Implementation Notes
**Prototype pollution guard** — `JSON.parse` supports a reviver function that
is called for every key-value pair during object construction. The reviver
inspects each key against a `Set<string>` built from `forbiddenKeys` (default:
`__proto__`, `constructor`, `prototype`). The root `""` key is exempt because
the reviver is called with the empty string for the top-level result.
**Cycle detection** — The replacer receives `this` bound to the parent object at
each step, which allows reconstructing the ancestor stack. When entering a
child object we find the parent's index in the stack and truncate everything
after it, then check whether the new value is already in the stack before
pushing it. This correctly allows DAG sharing (the same object referenced
at two sibling paths) while rejecting true back-edges.
**`isValidJSON` and the `"null"` edge case** — A naïve implementation using
`tryParse(text) !== undefined` would return `false` for the `"null"` literal
because `JSON.parse("null")` returns `null`. This module instead wraps
`safeParse` in a `try/catch` and returns `true` on success.
**OTel instrumentation** — `safeParse` and `safeStringify` each create an
active span via `trace.getTracer("@suaveplan/json").startActiveSpan(...)`.
When no SDK is configured the tracer is a no-op, so there is zero overhead
in un-instrumented environments.

View File

@@ -0,0 +1,15 @@
import z from "zod";
/** Zod schema for {@link ParseOptions}. */
export const ParseOptionsSchema = z.object({
forbiddenKeys: z.array(z.string()).optional(),
maxDepth: z.number().int().positive().optional(),
signal: z.instanceof(AbortSignal).optional(),
});
/** Zod schema for {@link StringifyOptions}. */
export const StringifyOptionsSchema = z.object({
space: z.number().int().nonnegative().optional(),
signal: z.instanceof(AbortSignal).optional(),
maxDepth: z.number().int().positive().optional(),
});

View File

@@ -0,0 +1,325 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import {
getStringifySize,
isValidJSON,
JsonError,
safeParse,
safeStringify,
tryParse,
} from "./safe-json.js";
describe("safeParse", () => {
it("parses valid JSON", { timeout: 5000 }, () => {
expect(safeParse('{"a":1}')).toEqual({ a: 1 });
});
it("parses a JSON array", { timeout: 5000 }, () => {
expect(safeParse("[1,2,3]")).toEqual([1, 2, 3]);
});
it("parses null literal", { timeout: 5000 }, () => {
expect(safeParse("null")).toBeNull();
});
it("parses string literal", { timeout: 5000 }, () => {
expect(safeParse('"hello"')).toBe("hello");
});
it("parses number literal", { timeout: 5000 }, () => {
expect(safeParse("42")).toBe(42);
});
it("parses boolean literals", { timeout: 5000 }, () => {
expect(safeParse("true")).toBe(true);
expect(safeParse("false")).toBe(false);
});
it("rejects __proto__ key", { timeout: 5000 }, () => {
expect(() => safeParse('{"__proto__":{"x":1}}')).toThrow(JsonError);
});
it("rejects constructor key", { timeout: 5000 }, () => {
expect(() => safeParse('{"constructor":{"x":1}}')).toThrow(JsonError);
});
it("rejects prototype key", { timeout: 5000 }, () => {
expect(() => safeParse('{"prototype":{"x":1}}')).toThrow(JsonError);
});
it("throws JsonError with PARSE.PROTOTYPE_POLLUTION code on forbidden keys", {
timeout: 5000,
}, () => {
try {
safeParse('{"__proto__":{}}');
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(JsonError);
expect((err as JsonError).code).toBe(
"JSON.PARSE.PROTOTYPE_POLLUTION"
);
}
});
it("throws on invalid JSON", { timeout: 5000 }, () => {
expect(() => safeParse("not json")).toThrow(JsonError);
});
it("throws JsonError with PARSE.FAILED code on invalid JSON", {
timeout: 5000,
}, () => {
try {
safeParse("{bad}");
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(JsonError);
expect((err as JsonError).code).toBe("JSON.PARSE.FAILED");
}
});
it("allows custom forbidden keys — empty list allows __proto__", {
timeout: 5000,
}, () => {
expect(() =>
safeParse('{"__proto__":{}}', { forbiddenKeys: [] })
).not.toThrow();
});
it("allows custom forbidden keys — custom key blocks the key", {
timeout: 5000,
}, () => {
expect(() =>
safeParse('{"evil":1}', { forbiddenKeys: ["evil"] })
).toThrow(JsonError);
});
it("throws when AbortSignal is already aborted", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort();
expect(() =>
safeParse('{"a":1}', { signal: controller.signal })
).toThrow(JsonError);
});
it("parses nested objects successfully", { timeout: 5000 }, () => {
const result = safeParse('{"a":{"b":{"c":1}}}');
expect(result).toEqual({ a: { b: { c: 1 } } });
});
});
describe("safeStringify", () => {
it("serialises a plain object", { timeout: 5000 }, () => {
expect(safeStringify({ a: 1 })).toBe('{"a":1}');
});
it("serialises with space option", { timeout: 5000 }, () => {
expect(safeStringify({ a: 1 }, { space: 2 })).toBe('{\n "a": 1\n}');
});
it("serialises arrays", { timeout: 5000 }, () => {
expect(safeStringify([1, 2, 3])).toBe("[1,2,3]");
});
it("serialises null", { timeout: 5000 }, () => {
expect(safeStringify(null)).toBe("null");
});
it("serialises primitives", { timeout: 5000 }, () => {
expect(safeStringify(42)).toBe("42");
expect(safeStringify(true)).toBe("true");
expect(safeStringify("hello")).toBe('"hello"');
});
it("throws on circular reference", { timeout: 5000 }, () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
expect(() => safeStringify(obj)).toThrow(JsonError);
});
it("throws JsonError with STRINGIFY.CIRCULAR code on circular reference", {
timeout: 5000,
}, () => {
const obj: Record<string, unknown> = {};
obj.self = obj;
try {
safeStringify(obj);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(JsonError);
expect((err as JsonError).code).toBe("JSON.STRINGIFY.CIRCULAR");
}
});
it("throws when AbortSignal is already aborted", { timeout: 5000 }, () => {
const controller = new AbortController();
controller.abort();
expect(() =>
safeStringify({ a: 1 }, { signal: controller.signal })
).toThrow(JsonError);
});
it("handles nested objects without circular refs", {
timeout: 5000,
}, () => {
const obj = { a: { b: { c: 1 } } };
expect(safeStringify(obj)).toBe('{"a":{"b":{"c":1}}}');
});
it("throws JsonError with STRINGIFY.FAILED code for non-serialisable values (BigInt)", {
timeout: 5000,
}, () => {
try {
safeStringify({ big: BigInt(1) });
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(JsonError);
expect((err as JsonError).code).toBe("JSON.STRINGIFY.FAILED");
}
});
it("allows same object reference at different paths (not a cycle)", {
timeout: 5000,
}, () => {
const shared = { x: 1 };
const obj = { a: shared, b: shared };
// DAG sharing is fine — only actual cycles throw
expect(() => safeStringify(obj)).not.toThrow();
});
});
describe("tryParse", () => {
it("returns undefined on invalid JSON", { timeout: 5000 }, () => {
expect(tryParse("bad")).toBeUndefined();
});
it("returns parsed value on valid JSON", { timeout: 5000 }, () => {
expect(tryParse("42")).toBe(42);
});
it("returns null for JSON null", { timeout: 5000 }, () => {
expect(tryParse("null")).toBeNull();
});
it("returns undefined when safeParse throws for prototype pollution", {
timeout: 5000,
}, () => {
expect(tryParse('{"__proto__":{}}')).toBeUndefined();
});
it("returns parsed object", { timeout: 5000 }, () => {
expect(tryParse('{"a":1}')).toEqual({ a: 1 });
});
it("passes options through to safeParse", { timeout: 5000 }, () => {
// With empty forbidden keys, __proto__ should NOT be blocked
expect(
tryParse('{"__proto__":{}}', { forbiddenKeys: [] })
).toBeDefined();
});
});
describe("isValidJSON", () => {
it("returns true for valid JSON object", { timeout: 5000 }, () => {
expect(isValidJSON('{"x":1}')).toBe(true);
});
it("returns false for invalid JSON", { timeout: 5000 }, () => {
expect(isValidJSON("not json")).toBe(false);
});
it("returns true for JSON null literal", { timeout: 5000 }, () => {
expect(isValidJSON("null")).toBe(true);
});
it("returns true for JSON string", { timeout: 5000 }, () => {
expect(isValidJSON('"hello"')).toBe(true);
});
it("returns true for JSON number", { timeout: 5000 }, () => {
expect(isValidJSON("42")).toBe(true);
});
it("returns true for JSON boolean", { timeout: 5000 }, () => {
expect(isValidJSON("true")).toBe(true);
expect(isValidJSON("false")).toBe(true);
});
it("returns true for JSON array", { timeout: 5000 }, () => {
expect(isValidJSON("[1,2,3]")).toBe(true);
});
it("returns false for empty string", { timeout: 5000 }, () => {
expect(isValidJSON("")).toBe(false);
});
it("returns false for prototype-polluting JSON (forbidden key)", {
timeout: 5000,
}, () => {
// isValidJSON uses safeParse internally which blocks __proto__ by default
expect(isValidJSON('{"__proto__":{}}')).toBe(false);
});
});
describe("getStringifySize", () => {
it("returns byte size of serialised value", { timeout: 5000 }, () => {
const value = { hello: "world" };
const size = getStringifySize(value);
expect(size).toBe(
new TextEncoder().encode(JSON.stringify(value)).byteLength
);
});
it("returns correct size for string with multi-byte chars", {
timeout: 5000,
}, () => {
const value = { emoji: "🎉" };
const size = getStringifySize(value);
expect(size).toBe(
new TextEncoder().encode(JSON.stringify(value)).byteLength
);
});
it("returns correct size for simple number", { timeout: 5000 }, () => {
expect(getStringifySize(42)).toBe(2);
});
});
describe("property tests", () => {
it("safeParse round-trips any JSON-serialisable value not containing forbidden keys", {
timeout: 30000,
}, () => {
const FORBIDDEN = new Set(["__proto__", "constructor", "prototype"]);
// JSON has no negative-zero literal: `JSON.stringify(-0)` → `"0"`, so a
// value containing `-0` cannot round-trip losslessly through JSON.
// Filter such values out of the property to keep the invariant honest.
function isUnRoundTrippable(v: unknown): boolean {
if (Object.is(v, -0)) return true;
if (typeof v !== "object" || v === null) return false;
if (Array.isArray(v)) return v.some(isUnRoundTrippable);
return Object.entries(v as object).some(
([k, val]) => FORBIDDEN.has(k) || isUnRoundTrippable(val)
);
}
fc.assert(
fc.property(
fc.jsonValue().filter((v) => !isUnRoundTrippable(v)),
(value) => {
expect(safeParse(JSON.stringify(value))).toEqual(value);
}
)
);
});
it("safeStringify + safeParse is identity for clean objects", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.record({ a: fc.integer(), b: fc.string() }),
(obj) => {
expect(safeParse(safeStringify(obj))).toEqual(obj);
}
)
);
});
});

View File

@@ -0,0 +1,193 @@
import { trace } from "@opentelemetry/api";
import { JSON_CODES, JsonError } from "../errors/index.js";
import type { ParseOptions, StringifyOptions } from "../types/index.js";
export { JsonError } from "../errors/index.js";
const TRACER_NAME = "@suaveplan/json";
const DEFAULT_FORBIDDEN_KEYS = ["__proto__", "constructor", "prototype"];
/**
* Parse a JSON string with prototype-pollution protection.
*
* Uses a JSON reviver to inspect every key as the object tree is built.
* Throws a structured {@link JsonError} on parse failures or forbidden keys.
*
* @param text - Raw JSON string to parse.
* @param options - Optional parse configuration.
* @returns The parsed value.
* @throws {JsonError} `PARSE.FAILED` — invalid JSON or aborted signal.
* @throws {JsonError} `PARSE.PROTOTYPE_POLLUTION` — forbidden key detected.
*/
export function safeParse<T = unknown>(
text: string,
options: ParseOptions = {}
): T {
const tracer = trace.getTracer(TRACER_NAME);
return tracer.startActiveSpan("json.safeParse", (span) => {
try {
const { forbiddenKeys = DEFAULT_FORBIDDEN_KEYS, signal } = options;
if (signal?.aborted) {
throw new JsonError(
JSON_CODES["PARSE.FAILED"],
"Parse aborted via AbortSignal"
);
}
const forbiddenSet = new Set(forbiddenKeys);
let result: T;
try {
result = JSON.parse(text, (key, value) => {
if (forbiddenSet.has(key) && key !== "") {
throw new JsonError(
JSON_CODES["PARSE.PROTOTYPE_POLLUTION"],
`Forbidden key detected during JSON parse: "${key}"`
);
}
return value;
}) as T;
} catch (err) {
if (err instanceof JsonError) {
throw err;
}
throw new JsonError(
JSON_CODES["PARSE.FAILED"],
`JSON parse failed: ${(err as Error).message}`,
{ cause: err as Error }
);
}
return result;
} finally {
span.end();
}
});
}
/**
* Stringify a value to JSON with circular-reference detection.
*
* Tracks visited objects using a `WeakSet` to detect cycles before
* `JSON.stringify` emits an unformatted error.
*
* @param value - The value to serialise.
* @param options - Optional stringify configuration.
* @returns The JSON string.
* @throws {JsonError} `STRINGIFY.CIRCULAR` — circular reference detected.
* @throws {JsonError} `STRINGIFY.FAILED` — aborted signal or unexpected error.
*/
export function safeStringify(
value: unknown,
options: StringifyOptions = {}
): string {
const tracer = trace.getTracer(TRACER_NAME);
return tracer.startActiveSpan("json.safeStringify", (span) => {
try {
const { space, signal } = options;
if (signal?.aborted) {
throw new JsonError(
JSON_CODES["STRINGIFY.FAILED"],
"Stringify aborted via AbortSignal"
);
}
// Track the ancestor stack to detect true cycles.
// A WeakSet of all-seen-so-far would block DAG sharing (same object
// at two different paths), so we use a stack-based approach instead.
const ancestors: object[] = [];
const result = JSON.stringify(
value,
function replacer(this: unknown, _key: string, val: unknown) {
if (val !== null && typeof val === "object") {
// Remove objects that are no longer ancestors of current path.
// `this` is the parent object in the replacer context.
const thisObj = this as object;
const idx = ancestors.indexOf(thisObj);
if (idx !== -1) {
ancestors.length = idx + 1;
} else {
ancestors.length = 0;
}
if (ancestors.includes(val)) {
throw new JsonError(
JSON_CODES["STRINGIFY.CIRCULAR"],
"Circular reference detected during JSON stringify"
);
}
ancestors.push(val);
}
return val;
},
space
);
return result;
} catch (err) {
if (err instanceof JsonError) {
throw err;
}
throw new JsonError(
JSON_CODES["STRINGIFY.FAILED"],
`JSON stringify failed: ${(err as Error).message}`,
{ cause: err as Error }
);
} finally {
span.end();
}
});
}
/**
* Attempt to parse a JSON string, returning `undefined` on any error.
*
* A non-throwing wrapper around {@link safeParse} for cases where an invalid
* or polluting JSON string should silently yield `undefined` rather than
* propagating an exception.
*
* @param text - Raw JSON string to parse.
* @param options - Optional parse configuration (forwarded to `safeParse`).
* @returns The parsed value, or `undefined` if parsing fails.
*/
export function tryParse<T = unknown>(
text: string,
options?: ParseOptions
): T | undefined {
try {
return safeParse<T>(text, options);
} catch {
return undefined;
}
}
/**
* Check whether a string is valid JSON.
*
* Unlike `tryParse(text) !== undefined`, this correctly handles the `"null"`
* literal (which is valid JSON but would cause `tryParse() !== undefined` to
* return `false`).
*
* @param text - The string to test.
* @returns `true` if the string is valid (and non-polluting) JSON.
*/
export function isValidJSON(text: string): boolean {
try {
safeParse(text);
return true;
} catch {
return false;
}
}
/**
* Return the UTF-8 byte size of the JSON representation of a value.
*
* @param value - Any JSON-serialisable value.
* @returns Byte length of `JSON.stringify(value)` encoded as UTF-8.
*/
export function getStringifySize(value: unknown): number {
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
}

View File

@@ -0,0 +1,4 @@
export type {
ParseOptions,
StringifyOptions,
} from "./types.js";

View File

@@ -0,0 +1,78 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the public option types consumed by `@suaveplan/json` operations.
Centralising these interfaces in a dedicated `types/` module keeps the
implementation files focused on logic and lets external callers import only the
types they need without pulling in any runtime code.
## Features
- `ParseOptions` — controls forbidden-key list, max nesting depth, and abort
signal for `safeParse` / `tryParse`
- `StringifyOptions` — controls pretty-print spacing, abort signal, and
circular-ref depth limit for `safeStringify`
## Usage: Basic Example
```ts
import type { ParseOptions } from "@suaveplan/json";
const opts: ParseOptions = {
forbiddenKeys: ["__proto__", "constructor"],
maxDepth: 20,
};
```
## Usage: Advanced Example
```ts
import type { ParseOptions, StringifyOptions } from "@suaveplan/json";
const controller = new AbortController();
const parseOpts: ParseOptions = {
forbiddenKeys: ["__proto__", "constructor", "prototype", "eval"],
maxDepth: 10,
signal: controller.signal,
};
const stringifyOpts: StringifyOptions = {
space: 2,
signal: controller.signal,
maxDepth: 50,
};
// Abort after 500 ms
setTimeout(() => controller.abort(), 500);
```
## API Reference
### `ParseOptions`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `forbiddenKeys` | `string[]?` | `["__proto__", "constructor", "prototype"]` | Keys that trigger a `PARSE.PROTOTYPE_POLLUTION` error when encountered anywhere in the object tree. |
| `maxDepth` | `number?` | `50` | Maximum JSON nesting depth; deeper structures throw `PARSE.FAILED`. |
| `signal` | `AbortSignal?` | — | Optional cancellation token; aborted signals cause `safeParse` to throw `PARSE.FAILED`. |
### `StringifyOptions`
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `space` | `number?` | — | Forwarded verbatim to `JSON.stringify`; produces indented output when set. |
| `signal` | `AbortSignal?` | — | Optional cancellation token; aborted signals cause `safeStringify` to throw `STRINGIFY.FAILED`. |
| `maxDepth` | `number?` | `100` | Maximum depth before `safeStringify` throws `STRINGIFY.CIRCULAR`. |
## Implementation Notes
Both interfaces are pure TypeScript — no runtime object is emitted. The types
file is excluded from coverage measurement because there is nothing to execute.
Default values are applied inside the respective implementation functions
(`safeParse`, `safeStringify`) using object destructuring with defaults, keeping
the type definitions free of runtime assumptions.

View File

@@ -0,0 +1,45 @@
/**
* Options for {@link safeParse} and {@link tryParse}.
*/
export interface ParseOptions {
/**
* Keys to reject during parsing.
*
* Defaults to `["__proto__", "constructor", "prototype"]`.
*/
forbiddenKeys?: string[];
/**
* Maximum nesting depth before throwing a `PARSE.FAILED` error.
*
* Defaults to `50`.
*/
maxDepth?: number;
/**
* AbortSignal to cancel large-payload parsing.
*
* When the signal fires, the parse throws a `PARSE.FAILED` error.
*/
signal?: AbortSignal;
}
/**
* Options for {@link safeStringify}.
*/
export interface StringifyOptions {
/**
* Number of spaces for pretty-printing (forwarded to `JSON.stringify`).
*/
space?: number;
/**
* AbortSignal to cancel large-payload serialisation.
*
* When the signal fires, stringify throws a `STRINGIFY.FAILED` error.
*/
signal?: AbortSignal;
/**
* Maximum circular-reference depth before throwing.
*
* Defaults to `100`.
*/
maxDepth?: number;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/json",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@opentelemetry/api",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});