Initial Commit
This commit is contained in:
123
packages/foundation/data/json/README.md
Normal file
123
packages/foundation/data/json/README.md
Normal 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
|
||||
|
||||
76
packages/foundation/data/json/package.json
Normal file
76
packages/foundation/data/json/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
1
packages/foundation/data/json/src/errors/index.ts
Normal file
1
packages/foundation/data/json/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { JSON_CODES, type JsonCode, JsonError } from "./json-error.js";
|
||||
103
packages/foundation/data/json/src/errors/json-error.md
Normal file
103
packages/foundation/data/json/src/errors/json-error.md
Normal 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.
|
||||
29
packages/foundation/data/json/src/errors/json-error.ts
Normal file
29
packages/foundation/data/json/src/errors/json-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
packages/foundation/data/json/src/index.ts
Normal file
9
packages/foundation/data/json/src/index.ts
Normal 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";
|
||||
8
packages/foundation/data/json/src/safe-json/index.ts
Normal file
8
packages/foundation/data/json/src/safe-json/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { JsonError } from "../errors/index.js";
|
||||
export {
|
||||
getStringifySize,
|
||||
isValidJSON,
|
||||
safeParse,
|
||||
safeStringify,
|
||||
tryParse,
|
||||
} from "./safe-json.js";
|
||||
209
packages/foundation/data/json/src/safe-json/safe-json.md
Normal file
209
packages/foundation/data/json/src/safe-json/safe-json.md
Normal 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.
|
||||
@@ -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(),
|
||||
});
|
||||
325
packages/foundation/data/json/src/safe-json/safe-json.test.ts
Normal file
325
packages/foundation/data/json/src/safe-json/safe-json.test.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
193
packages/foundation/data/json/src/safe-json/safe-json.ts
Normal file
193
packages/foundation/data/json/src/safe-json/safe-json.ts
Normal 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;
|
||||
}
|
||||
4
packages/foundation/data/json/src/types/index.ts
Normal file
4
packages/foundation/data/json/src/types/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
ParseOptions,
|
||||
StringifyOptions,
|
||||
} from "./types.js";
|
||||
78
packages/foundation/data/json/src/types/types.md
Normal file
78
packages/foundation/data/json/src/types/types.md
Normal 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.
|
||||
45
packages/foundation/data/json/src/types/types.ts
Normal file
45
packages/foundation/data/json/src/types/types.ts
Normal 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;
|
||||
}
|
||||
12
packages/foundation/data/json/tsconfig.lib.json
Normal file
12
packages/foundation/data/json/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
37
packages/foundation/data/json/vite.config.ts
Normal file
37
packages/foundation/data/json/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/foundation/data/json/vitest.config.ts
Normal file
30
packages/foundation/data/json/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user