NAME

File::Raw::JSON - Fast JSON / JSONL plugin for File::Raw

VERSION

Version 0.03

SYNOPSIS

Loading the module registers two plugins (json, jsonl) with File::Raw at BOOT time. Use them via File::Raw's standard plugin tail:

use File::Raw qw(slurp spew each_line);
use File::Raw::JSON;     # registers json + jsonl plugins

# Single JSON document
my $config = file_slurp("config.json", plugin => 'json');

# Pretty-printed write
file_spew("out.json", $payload, plugin => 'json',
          pretty => 1, sort_keys => 1);

# JSON Lines / NDJSON streaming (line-by-line, RSS bounded)
file_each_line("events.jsonl",
    sub { my $event = $_[0]; ... },
    plugin => 'jsonl');

# Whole-file JSONL into AoV
my $events = file_slurp("events.jsonl", plugin => 'jsonl');

For in-memory bytes <-> structure work (no path, no syscalls), import the direct codec:

use File::Raw::JSON qw(file_json_decode file_json_encode);

my $val   = file_json_decode($json_bytes);
my $bytes = file_json_encode($val, pretty => 1, sort_keys => 1);

# JSONL via the same entry points
my $rows  = file_json_decode($jsonl_bytes, mode => 'lines');
my $out   = file_json_encode(\@rows,       mode => 'lines');

DESCRIPTION

Fast JSON I/O integrated with the File::Raw plugin pipeline. One syscall path through File::Raw, then a direct call into yyjson (vendored, MIT).

Two plugins are registered:

json

One JSON value per file. slurp returns the parsed structure (hashref / arrayref / scalar / undef); spew serialises any Perl value back to JSON bytes. each_line with this plugin croaks with a "use 'jsonl'" message - single documents don't decompose into records.

jsonl

JSONL / NDJSON / concatenated JSON values. slurp returns an AoV; spew takes an AoV and writes one record per line; each_line streams records via callback (memory-bounded).

JSONL parsing uses brace-balancing rather than newline-splitting, mirroring JSON::Lines's $LINES regex. This means:

  • Pretty-printed JSONL works (records can span multiple lines).

  • Multiple records on one line work ({"a":1}{"b":2} = 2 records).

  • Braces inside string fields don't break parsing.

  • Chunked input is buffered and re-assembled across reads.

DIRECT CODEC

For callers who already have JSON bytes in scalar form (or who want JSON bytes back without round-tripping through a temp file), two importable XSUBs:

file_json_decode($bytes, ?key => value, ...)

Parses $bytes and returns the decoded Perl value. Default mode is document (one value per buffer); pass mode => 'lines' to parse a JSONL/NDJSON buffer and get back an arrayref of values.

my $val   = file_json_decode($json_bytes);
my $rows  = file_json_decode($jsonl_bytes, mode => 'lines');
my $cfg   = file_json_decode($cfg_bytes,   ordered => 1, relaxed => 1);

Passing undef returns undef. Malformed input croaks with the yyjson error message and byte offset.

file_json_encode($value, ?key => value, ...)

Serialises $value and returns JSON bytes. Default mode is document; pass mode => 'lines' with an arrayref payload to get back a JSONL buffer (one record per line, trailing \n).

my $bytes = file_json_encode($val);
my $jsonl = file_json_encode(\@rows, mode => 'lines');
my $diff  = file_json_encode($val,   pretty => 1, sort_keys => 1);

The trailing key/value list accepts the same options as the plugin path (see "OPTIONS" below). Odd-count tails croak; unknown keys croak.

These are XSUBs in the File::Raw::JSON package; importable on request via use File::Raw::JSON qw(file_json_decode file_json_encode) or use File::Raw::JSON qw(:codec). They share the same yyjson codec body as the plugin path, so output is byte-identical to a File::Raw::spew(... plugin => 'json', ...) for the same input.

OPTIONS

The standard plugin tail accepts these keys.

mode

document (default for the json plugin) or lines (default for jsonl). Override the plugin's default per call.

pretty

Encode: pretty-print with newlines and indent. Default false.

indent

Encode: spaces per indent level when pretty is on. Must be 2 or 4 (yyjson constraint). Arbitrary indent strings planned for v0.02.

sort_keys

Encode: emit object keys in sorted order. Off by default for speed; on for diff-friendly output.

canonical

Encode: shorthand for sort_keys => 1 + minimal whitespace.

utf8

Treat bytes as UTF-8. Default true.

relaxed

Decode: tolerate // and /* */ comments and trailing commas.

allow_nonref

Decode: accept top-level scalars (42, "hi", true). Default true.

allow_nan_inf

Round-trip NaN, Infinity, -Infinity. Non-standard JSON; default false.

ordered

Decode JSON objects as Tie::OrderedHash-tied hashes so insertion order is preserved on the Perl side. yyjson already preserves source order on parse; the regular Perl HV randomises iteration since 5.18, which is what this option works around.

my $config = file_slurp("config.json", plugin => 'json', ordered => 1);
# keys(%$config) returns in document order

my $events = file_slurp("trace.jsonl", plugin => 'jsonl', ordered => 1);
# each $events->[$i] preserves its original key order

Decode-only flag. The encoder detects the tied storage automatically and emits keys in insertion order, so a parsed-then-re-encoded ordered structure round-trips byte-for-byte without any extra flag.

Cost: ordered mode is slower than the default HV path because maintaining insertion order requires bookkeeping the encoder/decoder otherwise wouldn't do. Indicative numbers on a 10k-record JSONL fixture (10 fields each):

decode default  vs  decode ordered:   ~4x slower
encode default  vs  encode ordered:   ~1.25x slower (C ABI iterator)
round-trip      vs  round-trip:        ~3x slower

The encode side is essentially free; the decode side pays the AV/HV bookkeeping that maintains the insertion-ordered key list. Use the option only when order actually matters; the default is the fast path.

boolean_class

Class to bless decoded JSON true/false into. Default File::Raw::JSON::Boolean. The encoder also recognises JSON::PP::Boolean, Types::Serialiser::Boolean, Cpanel::JSON::XS::Boolean, JSON::XS::Boolean, and the boolean module by name string.

max_depth

Decode: croak on nesting deeper than this. Default 512.

eol

JSONL only: line terminator on encode. 1-3 bytes, default \n.

VALUE MAPPING

JSON                Perl                    Encoded back as
----                ----                    ---------------
null                undef                   null
true / false        blessed sentinel        matching literal
integer (IV)        IV                      integer
integer (UV)        UV                      integer
float               NV                      float (shortest round-trip)
string              utf8-decoded SV         JSON string
array               AV ref                  [ ... ]
object              HV ref                  { ... }

STREAMING

each_line($path, $cb, plugin => 'jsonl') opens the file via File::Raw's STREAM dispatch, reads in 64 KiB chunks, accumulates bytes in a per-call buffer, and uses a brace-balancing state machine to slice off complete top-level values. Each value is parsed and passed to your callback. RSS is bounded by the read buffer + the largest single JSON value in the stream.

To abort mid-stream, die from the callback. The exception propagates and the buffer is freed.

C ABI

File::Raw::JSON exposes a small C ABI so that other XS modules can decode and encode JSON entirely in C, with no per-call Perl codec dispatch. The motivating consumer is an event-loop server (Hyperman) that decodes a JSON request body on the worker hot path.

The header is distributed through ExtUtils::Depends - a consumer does not copy it. This dist is an ExtUtils::Depends provider (it also consumes File::Raw and Tie::OrderedHash the same way): building installs frj_abi.h and writes File::Raw::JSON::Install::Files, so a dependent's Makefile.PL that says

my $pkg = ExtUtils::Depends->new('My::Consumer', 'File::Raw::JSON');
WriteMakefile( ..., $pkg->get_makefile_vars );

picks up frj_abi.h on its include path automatically.

This is an integration surface for XS authors, not part of the Perl API. Perl callers should use "DIRECT CODEC" (file_json_decode / file_json_encode).

The table

The contract lives in include/frj_abi.h (installed via ExtUtils::Depends):

#define FRJ_ABI_VERSION 1

typedef struct frj_opts {
    int utf8, ordered, relaxed, allow_nonref, allow_nan_inf;
    int max_depth;                 /* 0 => default (512)     */
    int pretty, indent;            /* indent 0 => default (2) */
    int sort_keys, canonical;
} frj_opts;

typedef struct frj_abi {
    int abi_version;                          /* == FRJ_ABI_VERSION */
    void (*opts_init)(frj_opts *o);
    SV  *(*decode)(pTHX_ const char *bytes, STRLEN len, const frj_opts *o);
    AV  *(*decode_lines)(pTHX_ const char *bytes, STRLEN len, const frj_opts *o);
    SV  *(*encode)(pTHX_ SV *value, const frj_opts *o);
    SV  *(*encode_lines)(pTHX_ SV *payload, const frj_opts *o);
} frj_abi;

File::Raw::JSON::_abi_ptr

my $iv = File::Raw::JSON::_abi_ptr;

Returns the address of the process-wide frj_abi table as an integer (an IV). A consumer calls this once at BOOT, INT2PTRs it to a const frj_abi *, and checks ->abi_version == FRJ_ABI_VERSION before using it. Not intended to be called from Perl for any other purpose.

Functions

opts_init fills *o with the File::Raw::JSON defaults; call it, then set the fields you want (or pass NULL as the opts argument to any call for the defaults). decode / decode_lines return an SV / AV with a reference count of one owned by the caller, and croak on malformed input with a byte-offset message. encode / encode_lines return JSON bytes as an SV (+1 owned) and croak on an unencodable shape. decode_lines / encode_lines handle JSONL (one record per line).

Example: a Hyperman handler decoding a JSON body

Vendor nothing - add File::Raw::JSON via ExtUtils::Depends (above), resolve the table at boot, then decode per request:

#include "frj_abi.h"   /* found via ExtUtils::Depends, not copied */

static const frj_abi *FRJ = NULL;

MODULE = My::Consumer   PACKAGE = My::Consumer

BOOT:
{
    IV p = 0; dSP;
    eval_pv("require File::Raw::JSON;", FALSE);
    PUSHMARK(SP); PUTBACK;
    if (call_pv("File::Raw::JSON::_abi_ptr", G_SCALAR|G_EVAL) > 0) {
        SPAGAIN; p = POPi; PUTBACK;
    }
    if (p) {
        const frj_abi *a = INT2PTR(const frj_abi *, p);
        if (a && a->abi_version == FRJ_ABI_VERSION) FRJ = a;
    }
    /* FRJ == NULL => the installed File::Raw::JSON is too old or absent;
     * fall back to the Perl codec. */
}

# per request, on the JSON body bytes:
#   SV *val = FRJ->decode(aTHX_ body, body_len, NULL);
#   ... use val ...  (sv_2mortal it or SvREFCNT_dec when done)

SEE ALSO

File::Raw, JSON::Lines, https://jsonlines.org/, https://github.com/ibireme/yyjson.

AUTHOR

LNATION, <thisusedtobeanemail at gmail.com>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION.

This is free software, licensed under the Artistic License 2.0 (GPL Compatible).

The bundled yyjson library is Copyright (c) 2020 YaoYuan, MIT licensed - see LICENSE.yyjson.