This website is meant to be read and understood quickly by humans, but is only fully parsable, on a technical level, with the aid of an AI system. Read why →
Loop MMT
Validate & Normalize NDJSON into a JSONL Record Streamsource← all gifts

ndjson-source

ndjson-source reads NDJSON — newline-delimited JSON, one value per line (a FILE arg, or stdin) — and re-emits it as canonical JSONL, one compact JSON value per non-blank line — the front of a pipe you feed into the fold/filter/transform gifts (ndjson-source events.ndjson | dedup-filter --key id). The honest complement to json-source: json-source unwraps a top-level array into a stream, ndjson-source validates and canonicalizes a stream that is already line-delimited. Blank lines are skipped, CRLF==LF, each value re-serialized to canonical compact JSON. Zero dependencies, runs unchanged in Node or a browser, same input yields byte-identical output every run.

The honest edge
ndjson-source reads NDJSON (one JSON value per line) — NOT a JSON array (use json-source) and NOT JSON5. Blank lines are skipped; a malformed line is refused (exit 2) with its 1-based line number, never silently skipped or repaired (dropping a bad record would change your data without telling you). Each value is re-serialized to canonical compact JSON, so number tokens and whitespace are normalized (1e3 -> 1000, 1.0 -> 1) though key order is preserved — the JSON value, not the input line's bytes. So it validates AND normalizes; it reads the WHOLE input, not an incremental stream.
Run it
printf '{"id":1}\n{ "id": 2 }\n' | node ndjson-source.js # -> {"id":1}\n{"id":2} test_ndjson-source.js (41/41, independent regex-split+reduce oracle) + Plumb conformance GREEN (21/21, signed 2026-09-12, clock-independent, mutation-bite non-vacuous) Node / browser, no dependencies
The code — every file that ships
ndjson-source.js185 lineson GitHub →
#!/usr/bin/env node
/* ndjson-source.js — validate and normalize an NDJSON stream into canonical JSONL.
   Dependency-free, deterministic, pure. Runs in Node or a browser. MIT.

   WHAT IT IS. A SOURCE: it turns NDJSON — newline-delimited JSON, one JSON value per
   line, the shape a log, an export, or a `jq -c` stream so often arrives in — into the
   front of a pipe: one canonical compact JSON value per line (JSONL), ready to feed INTO
   the fold/filter/transform gifts. Give it a file (or pipe text on stdin) and it parses
   each line as its own JSON value and re-emits it canonically:

       {"id":1}                   ->  {"id":1}
       {"id":2}                       {"id":2}

       { "a": 1e3 }               ->  {"a":1000}     (whitespace + number tokens normalized)
       [1, 2, 3]                      [1,2,3]

   WHERE IT SITS NEXT TO json-source (the honest complement). json-source streams a
   TOP-LEVEL JSON ARRAY — one `[ ... ]` document — and REFUSES a non-array top-level,
   because a single value is not a stream. NDJSON is exactly the OTHER shape: a stream is
   already spelled out, one value per line, no enclosing array. ndjson-source is the honest
   reader for that shape. json-source unwraps an array into a stream; ndjson-source
   validates and canonicalizes a stream that is already line-delimited. It reads NDJSON,
   not a JSON array (use json-source) and not JSON5.

   PER-LINE HONESTY (the honesty axis). Each non-blank line MUST be exactly one valid JSON
   value. A line that is malformed JSON — or that carries a second value after the first —
   is a HARD ERROR (exit 2), naming the 1-based line number, never a skipped line and never
   a partial or repaired parse. ndjson-source does not quietly drop the bad record and keep
   going (that silently changes your data); it stops and tells you which line. Blank lines
   (empty or whitespace-only) carry no record and are skipped — that is not a guess, a
   blank line is unambiguously not a value. CRLF and LF line endings are both accepted (a
   trailing carriage return is stripped before parsing).

   CANONICAL RE-SERIALIZATION (declared, so it is pinnable). Each value is emitted via
   canonical compact JSON.stringify — object key order is preserved from the input, but
   input WHITESPACE and NUMBER TOKENS are normalized to canonical JSON form (`1e3` -> 1000,
   `1.0` -> 1, `{ "a" : 1 }` -> {"a":1}). This is the JSON *value*, losslessly; it is not
   the input line's exact bytes. So ndjson-source is a VALIDATOR and NORMALIZER: valid but
   sloppy NDJSON comes out as canonical JSONL, byte-identical every run and every machine.

   THE MODEL
     [FILE]   The NDJSON file to read. If omitted, read stdin. One JSON value per line;
              blank lines are skipped; each non-blank line becomes one output line.

   DETERMINISM. parse(text) is a pure function — no clock, no randomness, no network — so
   the same text yields byte-identical output every run.

   USAGE
     node ndjson-source.js data.ndjson
     cat data.ndjson | node ndjson-source.js
     node ndjson-source.js --help

   Exit codes: 0 success (including an empty stream from empty or all-blank input) · 2 input
   error (a malformed JSON line, an unknown option, a second positional file, or an
   unreadable file). Always a clean one-line message on stderr, never a stack trace.

   Released under MIT. Its edge is printed in the README: ndjson-source reads NDJSON (one
   JSON value per line) — NOT a JSON array (use json-source) and NOT JSON5. Blank lines are
   skipped; a malformed line is refused (exit 2) with its 1-based line number, never skipped
   or repaired. Each value is re-serialized to canonical compact JSON (number tokens and
   whitespace normalized, key order preserved), so it validates and normalizes; it reads the
   whole input, not an incremental stream.
*/
"use strict";

/* ---- the pure core ------------------------------------------------ */

// Parse NDJSON text and return the array of its per-line JSON values. Blank (empty or
// whitespace-only) lines are skipped. A malformed line throws a clean Error naming the
// 1-based line number — the CLI turns that into exit 2. Pure.
function parse(text) {
  if (typeof text !== "string") throw new Error("input must be text");
  var lines = text.split("\n");
  var values = [];
  for (var i = 0; i < lines.length; i++) {
    var line = lines[i];
    // CRLF -> LF: strip a single trailing carriage return left by the split.
    if (line.charAt(line.length - 1) === "\r") line = line.slice(0, -1);
    if (line.trim() === "") continue;      // blank line carries no record — skip, don't guess
    var value;
    try {
      value = JSON.parse(line);
    } catch (e) {
      throw new Error("line " + (i + 1) + " is not valid JSON: " + e.message);
    }
    values.push(value);
  }
  return values;
}

// Render values as JSONL text (one canonical compact JSON value per line, trailing newline
// if any). Same canonical serializer as the source-lane siblings.
function toJSONL(values) {
  var s = "";
  for (var i = 0; i < values.length; i++) s += JSON.stringify(values[i]) + "\n";
  return s;
}

/* ---- exports (browser + Node) ------------------------------------ */
if (typeof window !== "undefined") {
  window.ForestGifts = window.ForestGifts || {};
  window.ForestGifts.ndjsonSource = { parse: parse, toJSONL: toJSONL };
}
if (typeof module !== "undefined" && module.exports) {
  module.exports = { parse: parse, toJSONL: toJSONL };
}

/* ---- CLI (runs only when invoked directly, never on require) ------ */

function parseArgs(args) {
  var file;
  var i = 0;
  while (i < args.length) {
    var a = args[i];
    if (a.charAt(0) === "-" && a !== "-") {
      throw new Error("unknown option " + a);
    } else {
      if (file !== undefined) throw new Error("only one input file may be given (got a second: " + JSON.stringify(a) + ")");
      file = a;
      i += 1;
    }
  }
  return { file: file };
}

function readAll(stream) {
  return new Promise(function (resolve, reject) {
    var chunks = [];
    stream.on("data", function (c) { chunks.push(c); });
    stream.on("end", function () { resolve(Buffer.concat(chunks).toString("utf8")); });
    stream.on("error", reject);
  });
}

function helpText() {
  return (
    "ndjson-source.js — validate and normalize an NDJSON stream into canonical JSONL.\n\n" +
    "  node ndjson-source.js data.ndjson\n" +
    "  cat data.ndjson | node ndjson-source.js\n" +
    "  node ndjson-source.js --help\n\n" +
    "  [FILE]   NDJSON file to read (default: read stdin). One JSON value per line.\n\n" +
    "Emits one canonical compact JSON line per non-blank input line.\n\n" +
    "Edge: reads NDJSON (one JSON value per line) — NOT a JSON array (use json-source) and\n" +
    "NOT JSON5. Blank lines are skipped; a malformed line is refused (exit 2) with its\n" +
    "1-based line number, never skipped or repaired. Each value is re-serialized to canonical\n" +
    "compact JSON (number tokens and whitespace normalized, key order preserved). Reads the\n" +
    "whole input, not an incremental stream.\n"
  );
}

function main(argv) {
  var args = argv.slice(2);
  if (args.indexOf("--help") !== -1 || args.indexOf("-h") !== -1) {
    process.stdout.write(helpText());
    return Promise.resolve(0);
  }
  var parsed;
  try { parsed = parseArgs(args); }
  catch (e) { process.stderr.write("ndjson-source: " + e.message + "\n"); return Promise.resolve(2); }

  var getText;
  if (parsed.file !== undefined) {
    getText = new Promise(function (resolve, reject) {
      require("fs").readFile(parsed.file, "utf8", function (err, data) {
        if (err) reject(new Error("cannot read " + JSON.stringify(parsed.file) + ": " + err.code));
        else resolve(data);
      });
    });
  } else {
    getText = readAll(process.stdin);
  }

  return getText.then(function (text) {
    var values = parse(text);   // throws -> caught below
    process.stdout.write(toJSONL(values));
    return 0;
  }).catch(function (e) {
    process.stderr.write("ndjson-source: " + e.message + "\n");
    return 2;
  });
}

if (typeof require !== "undefined" && require.main === module) {
  main(process.argv).then(function (code) { process.exitCode = code; });
}
test_ndjson-source.js135 lineson GitHub →
#!/usr/bin/env node
/* test_ndjson-source.js — battery for the ndjson-source gift.
   node test_ndjson-source.js  ->  exit 0 PASS / non-zero FAIL. Zero dependencies.

   Cross-checks the gift against an INDEPENDENT reduce-route oracle (a different route than
   the gift's index loop — regex line-split + reduce), frozen hand goldens, the
   canonical-re-serialization contract, blank-line skipping, CRLF handling, determinism,
   and every fail-closed edge (a malformed line, refused with its line number). */
"use strict";
var ns = require("./ndjson-source.js");

var pass = 0, fail = 0;
function ok(name, cond) { if (cond) pass++; else { fail++; console.log("  FAIL  " + name); } }
function eqS(name, a, b) {
  if (a === b) pass++;
  else { fail++; console.log("  FAIL  " + name + "\n    exp " + JSON.stringify(b) + "\n    got " + JSON.stringify(a)); }
}
function throws(name, fn) {
  var threw = false; try { fn(); } catch (e) { threw = true; }
  if (threw) pass++; else { fail++; console.log("  FAIL  " + name + " (expected a throw)"); }
}

/* ---- independent oracle: regex-split + reduce route (different than the gift's loop) ---- */
function oracle(text) {
  return text.split(/\r?\n/).reduce(function (acc, line) {
    if (line.trim() === "") return acc;              // blank lines carry no record
    return acc + JSON.stringify(JSON.parse(line)) + "\n";  // may throw -> caller counts as error
  }, "");
}

/* ---- 1. differential grid: gift == oracle on every good vector ---- */
var good = [
  "",                                          // empty -> empty stream
  "\n\n\n",                                     // all blank -> empty stream
  '{"id":1}\n{"id":2}',                         // objects, no trailing nl
  '{"id":1}\n{"id":2}\n',                       // objects, trailing nl
  "1\n2\n3",                                    // scalars
  '"x"\n"y"\n"z"',                              // string scalars
  "true\nfalse\nnull",                          // literals
  '{"a":1,"b":2}\n{"a":3,"b":4}',              // multi-field records
  '{ "a" : 1 }\n[ 1 , 2 ]',                     // whitespace normalized
  '{"n":1e3}\n[1.0, 100]',                      // number tokens normalized
  '{"b":2,"a":1}',                              // key order preserved from input
  '{"a":1}\n\n{"b":2}\n\n',                     // interior + trailing blank lines skipped
  '{"a":1}\r\n{"b":2}\r\n',                     // CRLF accepted
  '{"nested":{"k":[1,2]}}',                     // nested value verbatim-as-value
  '  {"a":1}  \n\t{"b":2}\t',                   // leading/trailing whitespace on a value line
];
for (var i = 0; i < good.length; i++) {
  eqS("differential[" + i + "] gift==oracle", ns.toJSONL(ns.parse(good[i])), oracle(good[i]));
}

/* ---- 2. hand goldens (frozen) ---- */
eqS("golden: empty -> empty stream", ns.toJSONL(ns.parse("")), "");
eqS("golden: all-blank -> empty stream", ns.toJSONL(ns.parse("\n \n\t\n")), "");
eqS("golden: objects", ns.toJSONL(ns.parse('{"id":1}\n{"id":2}')), '{"id":1}\n{"id":2}\n');
eqS("golden: scalars", ns.toJSONL(ns.parse("1\n2\n3")), "1\n2\n3\n");
eqS("golden: whitespace normalized", ns.toJSONL(ns.parse('{ "a" : 1 }')), '{"a":1}\n');
eqS("golden: number token 1e3 -> 1000", ns.toJSONL(ns.parse("[1e3]")), "[1000]\n");
eqS("golden: number token 1.0 -> 1", ns.toJSONL(ns.parse("[1.0]")), "[1]\n");
eqS("golden: key order preserved", ns.toJSONL(ns.parse('{"b":2,"a":1}')), '{"b":2,"a":1}\n');
eqS("golden: blank lines skipped", ns.toJSONL(ns.parse('{"a":1}\n\n{"b":2}')), '{"a":1}\n{"b":2}\n');
eqS("golden: CRLF stripped", ns.toJSONL(ns.parse('{"a":1}\r\n{"b":2}\r\n')), '{"a":1}\n{"b":2}\n');
eqS("golden: trailing nl same as no trailing nl", ns.toJSONL(ns.parse('{"a":1}\n')), ns.toJSONL(ns.parse('{"a":1}')));

/* ---- 3. one line per non-blank input line ---- */
var recs = ns.parse('{"a":1}\n\n{"a":2}\n{"a":3}\n');
ok("count: 3 values (2 blanks skipped)", recs.length === 3);
ok("lines: JSONL has 3 lines", ns.toJSONL(recs).split("\n").filter(Boolean).length === 3);

/* ---- 4. fail-closed (exit-2 class): a malformed line, with its line number ---- */
throws("bad: malformed line (unterminated)", function () { ns.parse('{"a":1}\n{"a":2'); });
throws("bad: bare word", function () { ns.parse("hello"); });
throws("bad: trailing garbage after a value", function () { ns.parse('{"a":1} oops'); });
throws("bad: two values on one line", function () { ns.parse('{"a":1} {"b":2}'); });
throws("bad: a JSON array top-level is not per-line NDJSON of that array — but a single [..] line IS one value; a broken one throws", function () { ns.parse("[1,2,"); });
throws("bad: non-string input", function () { ns.parse(123); });
// the error names the 1-based line number of the offending line
(function () {
  var msg = "";
  try { ns.parse('{"a":1}\n{"a":2}\nnope'); } catch (e) { msg = e.message; }
  ok("error names the 1-based line number (line 3)", /line 3\b/.test(msg));
})();
// a blank line before the bad line does NOT shift the reported number (line counts raw lines)
(function () {
  var msg = "";
  try { ns.parse('{"a":1}\n\nnope'); } catch (e) { msg = e.message; }
  ok("blank line counted in the line number (line 3)", /line 3\b/.test(msg));
})();

/* ---- 5. determinism: parse twice -> byte-identical JSONL ---- */
var buf = "";
for (var k = 0; k < 50; k++) buf += JSON.stringify({ n: k, s: "v" + k }) + "\n";
ok("determinism: two parses byte-identical", ns.toJSONL(ns.parse(buf)) === ns.toJSONL(ns.parse(buf)));

/* ---- 6. toJSONL shape ---- */
ok("toJSONL: one value per line + trailing nl", ns.toJSONL([{ a: 1 }]) === '{"a":1}\n');
ok("toJSONL: empty -> empty string", ns.toJSONL([]) === "");

/* ---- 7. mutation tripwire A: a canonicalization-dropping mutant must diverge ---- */
(function () {
  // mutant: emits the raw trimmed line instead of the re-serialized value; must diverge on
  // a non-canonical input (whitespace / number token).
  function mutant(text) {
    return text.split("\n").reduce(function (acc, line) {
      if (line.charAt(line.length - 1) === "\r") line = line.slice(0, -1);
      if (line.trim() === "") return acc;
      return acc + line.trim() + "\n";     // raw line, NOT canonical value
    }, "");
  }
  var v = '{ "a" : 1e3 }';
  ok("mutation A: raw-passthrough (skips canonicalization) is CAUGHT",
     mutant(v) !== ns.toJSONL(ns.parse(v)));
})();

/* ---- 8. mutation tripwire B: a skip-the-bad-line mutant must diverge (honesty axis) ---- */
(function () {
  // mutant: silently drops a malformed line instead of throwing; on a bad-line input the
  // gift throws (-> exit 2) while the mutant succeeds. The behavioral divergence is the bite.
  function mutantSkipBad(text) {
    return text.split("\n").reduce(function (acc, line) {
      if (line.trim() === "") return acc;
      try { return acc + JSON.stringify(JSON.parse(line)) + "\n"; }
      catch (e) { return acc; }            // swallow the bad record — dishonest
    }, "");
  }
  var v = '{"a":1}\nBROKEN\n{"b":2}';
  var giftThrew = false;
  try { ns.parse(v); } catch (e) { giftThrew = true; }
  ok("mutation B: skip-bad-line is CAUGHT (gift throws where mutant survives)",
     giftThrew === true && mutantSkipBad(v) === '{"a":1}\n{"b":2}\n');
})();

console.log((fail === 0 ? "PASS" : "FAIL") + "  " + pass + "/" + (pass + fail));
process.exit(fail === 0 ? 0 : 1);
Take the whole folder → MIT Node / browser, no dependencies