Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project are documented in this file.

## Unreleased

- Replace native and callback serialization error messages with a value-free
`TypeError` so a cyclic key or callback exception cannot copy private input
into diagnostics. Strict unsupported-value errors retain positional detail.
- Reject unknown option names and report unsupported nested object properties
by source position, including legal empty-name properties.
- Separate `undefined` from unsupported types when a top-level value cannot be
represented as JSON, so the error names the category instead of using one
shared message.
- Add the optional `onUnsupported` option. `'omit'` is the default and keeps
existing successful-serialization behavior; `'throw'` reports an
unrepresentable nested value and its source position rather than silently
dropping it.
- Document the supported-value contract in the README.
- Add an HTML raw-text parse fixture that extracts the embedded value back out
of a rendered page, covering Unicode round-trips and confirming a hostile
payload cannot terminate its containing script element.

## 0.1.1 - 2026-08-24

- Expand the threat model, compatibility guidance, context matrix, examples,
Expand Down
60 changes: 56 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,56 @@ serializeInlineJson(value, {
})
```

Options follow native `JSON.stringify` behavior with one deliberate
restriction: `space` must be an integer from 0 through 10. A top-level value
that cannot be represented as JSON throws instead of returning `undefined`.
Options are a plain object with only `replacer`, `space`, and
`onUnsupported` as own enumerable data properties; unknown or inherited
options are rejected rather than silently changing the omission policy.
The supported replacer and spacing behavior follows native `JSON.stringify`,
except `space` must be an integer from 0 through 10. A top-level value
that cannot be represented as JSON throws instead of returning `undefined`, and
the error distinguishes an `undefined` value from an unsupported type.

### Supported values

| Input | Result |
| --- | --- |
| Object, array, string, finite number, boolean, `null` | Serialized |
| `Date`, or any value with `toJSON` | Serialized via `toJSON` |
| Non-finite number (`NaN`, `±Infinity`) | `null` |
| `undefined`, function, symbol — top level | Throws, naming the category |
| `undefined`, function, symbol — nested | See `onUnsupported` below |
| `BigInt` | Throws a value-free `TypeError` |
| Cyclic structure | Throws a value-free `TypeError` |

### `onUnsupported`

JSON cannot represent `undefined`, functions or symbols. Native
`JSON.stringify` drops such a property from an object and turns such an element
into `null` in an array — silently, so embedded page data can end up missing
fields the author expected to be there.

- `'omit'` (default) keeps that native omission behavior for successful
serialization.
- `'throw'` reports the value and its source position instead:

```js
serializeInlineJson({ user: { id: 1, email: undefined } }, {
onUnsupported: 'throw',
})
// TypeError: Cannot represent the undefined value at property[0].property[1] as JSON;
// remove it or serialize with onUnsupported: 'omit'.
```

Object positions are zero-based in `JSON.stringify` visitation order; array
positions keep their numeric indices (for example, `property[0][2]`). Raw
property names are never copied into diagnostics, because they may contain
control characters or private data. Distinct object and array locations remain
distinguishable.
Values are inspected after `toJSON` and after a function replacer, so a value
that *becomes* unrepresentable is reported too.

`onUnsupported: 'throw'` requires a function replacer or no replacer. An array
replacer is a property allowlist that omits properties by design, so combining
the two is rejected rather than given a confusing meaning.

## What it escapes

Expand Down Expand Up @@ -117,11 +164,16 @@ The detailed security assumptions and abuse cases are in
## Native JSON behavior retained

- Cyclic values and `BigInt` values throw.
- Unsupported object properties are omitted.
- Unsupported object properties are omitted by default; set
`onUnsupported: 'throw'` to be told about them instead.
- Non-finite numbers become `null`.
- Getters, `toJSON`, and replacer callbacks execute normally.
- A replacer or `toJSON` implementation can have side effects; this package
does not isolate user code.
- Errors thrown during serialization are replaced with a value-free `TypeError`
unless they are this package's own positional strict-mode diagnostic. Native
cycle errors can otherwise echo private property names, and callback errors
can carry arbitrary input. The original error and cause are not attached.

## Architecture

Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ and the compatibility contract without improving the supported use case.

- Invalid options fail before serialization.
- Unsupported top-level values throw instead of returning `undefined`.
- Native failures for cycles and `BigInt` are preserved.
- Replacer, getter, or `toJSON` exceptions propagate unchanged.
- Cycles and `BigInt` still fail with `TypeError`, but their native messages are
replaced with a value-free diagnostic because native cycle errors can name
private properties.
- Replacer, getter, or `toJSON` exceptions are also replaced with a value-free
`TypeError`; callbacks still execute and can have side effects.

## Release boundary

Expand Down
7 changes: 7 additions & 0 deletions docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ with a JSON Unicode escape. Because the serialized output contains no literal
The escapes remain valid JSON and reconstruct the original characters when
parsed.

Serialization failures are also a report surface. Native cycle errors may
include raw property names, and getters, `toJSON`, or replacer callbacks may
throw messages containing arbitrary data. Except for this package's own
source-position-only strict diagnostic, such errors are replaced with a
value-free `TypeError` without the original message or cause. This does not
stop callbacks from executing or undo their side effects.

## Trust assumptions

- The surrounding HTML element and its attributes are created safely.
Expand Down
12 changes: 12 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,21 @@ export type InlineJsonReplacer =
| ((this: unknown, key: string, value: unknown) => unknown)
| readonly (string | number)[]

/**
* How to treat values JSON cannot represent (`undefined`, functions, symbols)
* below the top level.
*
* - `'omit'` (default) keeps native `JSON.stringify` behaviour: the property is
* omitted from an object and becomes `null` in an array.
* - `'throw'` reports the value and its path instead of dropping it. Requires a
* function replacer or no replacer.
*/
export type InlineJsonUnsupportedHandling = 'omit' | 'throw'

export interface SerializeInlineJsonOptions {
replacer?: InlineJsonReplacer
space?: number
onUnsupported?: InlineJsonUnsupportedHandling
}

export declare function serializeInlineJson(
Expand Down
165 changes: 159 additions & 6 deletions index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,38 @@ const JSON_ESCAPES = Object.freeze({
'\u2029': '\\u2029',
})

const UNSUPPORTED_HANDLING = Object.freeze(['omit', 'throw'])
const OPTION_KEYS = Object.freeze(['replacer', 'space', 'onUnsupported'])

function assertOptions(options) {
if (options === undefined) return {}

if (options === null || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('Options must be an object when provided.')
}

return options
let keys
let descriptors
try {
if (![Object.prototype, null].includes(Object.getPrototypeOf(options))) {
throw new TypeError('Options must be a plain object.')
}
keys = Reflect.ownKeys(options)
descriptors = Object.getOwnPropertyDescriptors(options)
} catch {
throw new TypeError('Options must be a plain object with data properties.')
}
const selected = {}
for (const key of keys) {
if (typeof key !== 'string' || !OPTION_KEYS.includes(key)) {
throw new TypeError('Unknown option.')
}
const descriptor = descriptors[key]
if (!Object.hasOwn(descriptor, 'value') || !descriptor.enumerable) {
throw new TypeError('Options must use enumerable data properties.')
}
selected[key] = descriptor.value
}
return selected
}

function assertSpace(space) {
Expand All @@ -25,21 +49,150 @@ function assertSpace(space) {
}
}

function assertUnsupported(onUnsupported) {
if (
onUnsupported !== undefined &&
!UNSUPPORTED_HANDLING.includes(onUnsupported)
) {
throw new TypeError("Option onUnsupported must be 'omit' or 'throw' when provided.")
}
}

/**
* Classify a value that JSON cannot represent.
*
* `undefined` is a representable absence; a function or symbol is an
* unsupported type. JSON.stringify treats both the same way — omitted in an
* object, `null` in an array — so the distinction is made here.
*
* @returns {'undefined' | 'function' | 'symbol' | null} null when representable
*/
function categorize(value) {
if (value === undefined) return 'undefined'

const type = typeof value
if (type === 'function' || type === 'symbol') return type

return null
}

function childPath(holderPath, holder, key, ordinal) {
if (Array.isArray(holder)) return `${holderPath}[${Number(key)}]`
const position = `property[${ordinal}]`
return holderPath === '' ? position : `${holderPath}.${position}`
}

/**
* Wrap a replacer so that values JSON cannot represent are reported with their
* location instead of being dropped. Only used when `onUnsupported` is
* `'throw'`; otherwise the caller's replacer is passed through untouched so
* native behaviour is preserved exactly.
*/
function strictReplacer(replacer, noteOwnError) {
const paths = new WeakMap()
const nextOrdinals = new WeakMap()
let rootSeen = false

return function trackingReplacer(key, entry) {
const next = replacer === undefined ? entry : replacer.call(this, key, entry)

// JSON.stringify also permits an ordinary property named ''. Only its
// first callback is the wrapper root, regardless of later property keys.
if (!rootSeen) {
rootSeen = true
if (next !== null && typeof next === 'object') {
paths.set(next, '')
nextOrdinals.set(next, 0)
}
return next
}

// Every holder reached here was registered before JSON.stringify recursed
// into it: the root is registered on the key === '' call above, and each
// nested object is registered below before it becomes a holder in turn.
const ordinal = nextOrdinals.get(this)
nextOrdinals.set(this, ordinal + 1)
const path = childPath(paths.get(this), this, key, ordinal)
const category = categorize(next)

if (category !== null) {
const error = new TypeError(
`Cannot represent the ${category} value at ${path} as JSON; ` +
"remove it or serialize with onUnsupported: 'omit'.",
)
noteOwnError(error)
throw error
}

if (next !== null && typeof next === 'object') {
paths.set(next, path)
nextOrdinals.set(next, 0)
}

return next
}
}

function topLevelMessage(value, hasFunctionReplacer) {
if (hasFunctionReplacer) {
return 'The top-level value cannot be represented as JSON.'
}

const category = value === undefined ? 'undefined' : typeof value

return category === 'undefined'
? 'The top-level value is undefined and cannot be represented as JSON.'
: `The top-level value is an unsupported ${category} and cannot be represented as JSON.`
}

/**
* Serialize a value as JSON that can be placed in the raw-text content of an
* HTML script element.
*
* @param {unknown} value
* @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number }} [options]
* @param {{ replacer?: ((this: unknown, key: string, value: unknown) => unknown) | readonly (string | number)[], space?: number, onUnsupported?: 'omit' | 'throw' }} [options]
* @returns {string}
*/
export function serializeInlineJson(value, options) {
const { replacer, space } = assertOptions(options)
const { replacer, space, onUnsupported } = assertOptions(options)
assertSpace(space)
const serialized = JSON.stringify(value, replacer, space)
assertUnsupported(onUnsupported)

const strict = onUnsupported === 'throw'
const arrayReplacer = Array.isArray(replacer)

if (strict && arrayReplacer) {
throw new TypeError(
"Option onUnsupported: 'throw' requires a function replacer or no replacer, " +
'because a property allowlist omits properties by design.',
)
}

let ownError = null
let hasOwnError = false
const effectiveReplacer = strict
? strictReplacer(replacer, error => {
ownError = error
hasOwnError = true
})
: replacer
let serialized
try {
serialized = JSON.stringify(value, effectiveReplacer, space)
} catch (error) {
// Native cycle diagnostics can contain raw object keys; callbacks may
// throw messages containing arbitrary input too. Preserve only our own
// positional strict diagnostic, never the original message or cause.
// Match the exact error created by this call: callbacks can throw a
// fabricated TypeError or even reuse a mutated error from an earlier call.
if (hasOwnError && error === ownError) throw error
throw new TypeError('JSON serialization failed; inspect the input and callbacks locally.')
}

if (serialized === undefined) {
throw new TypeError('The top-level value cannot be represented as JSON.')
throw new TypeError(
topLevelMessage(value, !arrayReplacer && typeof replacer === 'function'),
)
}

return serialized.replace(/[<>&\u2028\u2029]/g, (character) => JSON_ESCAPES[character])
Expand Down
Loading
Loading