joelwurtz/json-stream-polyfill

Pure-PHP polyfill for the json_stream extension

Maintainers

Package info

github.com/joelwurtz/php-json-stream-polyfill

pkg:composer/joelwurtz/json-stream-polyfill

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

dev-main 2026-07-24 12:54 UTC

This package is auto-updated.

Last update: 2026-07-24 12:58:24 UTC


README

A userland implementation of the json_stream extension's observable API: json_stream_decode(), JsonStream\Document and the JSON_STREAM_TRANSIENT constant. It only activates when the extension is not loaded.

Requires PHP >= 8.1 and ext-json (always compiled in).

Loading

Manual:

require '/path/to/polyfill/bootstrap.php';

Composer:

composer require joelwurtz/json-stream-polyfill

The package's Composer autoloader includes bootstrap.php automatically. It defines everything only when !extension_loaded('json_stream') (and the class is not already defined), so it is safe to load unconditionally: with the extension present the polyfill is inert.

What it implements

  • json_stream_decode(string|resource|array|Traversable $source, int $options = 0): JsonStream\Document with the extension's exact TypeError wording for invalid sources. Nothing is read from the source until the first access on the Document.
  • final class JsonStream\Document implements IteratorAggregate, ArrayAccess, Countable with offsetExists / offsetGet / offsetSet / offsetUnset / count / toValue / getIterator, plus __debugInfo() mirroring the extension's get_debug_info (kind / scannedEntries / fullyScanned, or kind / value for scalar roots), and throwing __clone / __serialize.
  • Three-state lazy slots (unread → buffered byte span → parsed value): only the accessed path is ever parsed; scans early-exit as soon as the requested key is found; sibling values passed over are recorded as cheap spans.
  • Structural scanning is done in PHP (strcspn / strpos / strspn over a pulled buffer, with simdjson-style global escaped-quote semantics matching the extension's stage-1 indexer). Every scalar token (string, number, literal) and toValue() materialization is delegated to json_decode(), so unescaping, UTF-8 validation (JSON_INVALID_UTF8_IGNORE / JSON_INVALID_UTF8_SUBSTITUTE), number semantics (JSON_BIGINT_AS_STRING) and JsonException codes/messages match ext/json byte-for-byte. One exception: JSON_ERROR_STATE_MISMATCH from json_decode is remapped to Syntax error (code 4), because the extension diagnoses mismatched bracket kinds as a plain syntax error.
  • Error semantics: syntax errors are only discovered when an access forces scanning past them; duplicate keys resolve like the extension (early-exit scans stop at the first occurrence; full scans overwrite in place); trailing-garbage-after-root detection; depth limit 512 on materialization; the extension's exact Error messages for read-only writes, scalar-root access/count/iteration, transient forward-only violations and released content.
  • JSON_STREAM_TRANSIENT (1 << 30): foreach on a virgin container is a forward-only cursor that records no slots and actually releases consumed buffer prefixes (with the same 64 KiB hysteresis and one-entry lag as the extension), so memory stays bounded over arbitrarily large streams. Anything still referencing a region about to be released (buffered earlier fields, kept child Documents) is force-parsed first, exactly like js_state_force_resident_before. Once cursor iteration starts, dimension access / count() / toValue() / re-iteration throw the extension's Errors.

Parity is enforced by scripts/polyfill_tests.php, which runs the extension's phpt suite against the polyfill and the differential fuzzer (scripts/differential.php) through it.

Known divergences (irreducible in userland)

  • isset() vs offsetExists() split. The C extension distinguishes the engine has_dimension handler (used by isset() / empty() / ??: a null value is not set) from the offsetExists() method (array_key_exists semantics: true for null, answered structurally without parsing the value). Userland ArrayAccess routes both through the offsetExists() method, so the split cannot be reproduced. The polyfill implements the isset semantics (the value is parsed; null reports false), which is what isset(), ??, empty() and the differential fuzzer observe. Consequences: $doc->offsetExists('k') returns false for a null value where the extension returns true, and it can throw a JsonException for an invalid value where the extension (purely structural) returns true. This is why tests/017-offset-exists.phpt is skip-listed by the runner.
  • Warning location for missing keys. $doc['missing'] raises Warning: Undefined array key "missing" like the extension, but trigger_error() reports the polyfill's file/line instead of the caller's.
  • Foreach by reference. foreach ($doc as &$v) fails (as in the extension) but with the engine's generator message instead of Cannot iterate JsonStream\Document by reference. Similarly, re-using one $doc->getIterator() object for two loops fails with the generator rewind exception; each foreach ($doc) gets a fresh iterator and behaves normally.
  • Class surface. var_dump($doc) shows the same fields via __debugInfo(), but the object hash/id and the getIterator() return class (Generator instead of InternalIterator) differ. Dynamic properties raise the engine's deprecation instead of the extension's hard Error. Internal helper classes exist under JsonStream\Polyfill\.

Performance

Far below the extension (the extension's SIMD stage-1 indexes ~1 GiB/s; this is interpreted PHP). But the memory model is preserved: laziness (only the accessed prefix of a stream is pulled and only the accessed path is parsed) and transient constant-memory iteration both hold, and toValue() on a fully buffered document is a single json_decode call, i.e. near-native.