By the end of this lesson
Run a bounded Node CLI that validates a whole document before reporting its counts.
Before you start
- Node.js 24 with a terminal and plain-text editor; the browser-only lesson is labelled separately.
- Complete lesson 23 or be comfortable with its examples.
Three words to know
- Input boundary
- The point where external bytes enter a program.
- Bounded read
- Reading at most a limit plus one byte to detect excess.
- Whole-document validation
- Rejecting the entire document if any record violates the contract.
1. Write the input contract first
The input is one ordinary local UTF-8 JSON file, at most 65,536 bytes. Its top-level array has at most 1,000 records, each with exactly title and status. A title must be nonblank text of at most 100 Unicode code points. status is exactly open or closed. Empty lists are valid. A BOM, invalid UTF-8, malformed JSON or invalid record is rejected.
2. Save the source and fixture
Use your editor or the download buttons to save task_report.mjs, tasks.json and task_report.test.mjs in the same fresh practice folder. The JSON block is data, not a terminal command. The source below matches the downloadable program.
[{"title":"Read","status":"open"},{"title":"Test","status":"closed"},{"title":"Review","status":"open"}]
3. Understand the complete implementation
summarise validates all records and returns plain data. readRecords opens a file, checks its type, reads up to the limit plus one byte and closes the handle in finally. Fatal decoding rejects invalid UTF-8 rather than replacing bytes. main prints one summary only after those steps succeed. The URL comparison at the end calls main for direct execution but keeps imports from running the CLI.
// SPDX-License-Identifier: MIT
// Learning project: bounded reporting for ordinary local task files.
import { open } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
export const MAX_BYTES = 65536;
export const MAX_RECORDS = 1000;
export function summarise(records) {
if (!Array.isArray(records) || records.length > MAX_RECORDS) {
throw new Error("Expected at most 1000 records");
}
const counts = { closed: 0, open: 0 };
for (const record of records) {
if (record === null || typeof record !== "object" || Array.isArray(record) ||
Object.keys(record).sort().join(",") !== "status,title") {
throw new Error("Expected title and status only");
}
const { title, status } = record;
if (typeof title !== "string" || title.trim().length === 0 || [...title].length > 100) {
throw new Error("Expected a nonblank title of at most 100 code points");
}
if (status !== "open" && status !== "closed") {
throw new Error("Expected open or closed status");
}
counts[status] += 1;
}
return { by_status: counts, records: records.length };
}
export async function readRecords(path) {
const handle = await open(path, "r");
try {
if (!(await handle.stat()).isFile()) throw new Error("Expected an ordinary file");
const buffer = Buffer.alloc(MAX_BYTES + 1);
let length = 0;
while (length < buffer.length) {
const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
if (bytesRead === 0) break;
length += bytesRead;
}
if (length > MAX_BYTES) throw new Error("Input exceeds 65536 bytes");
const text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true })
.decode(buffer.subarray(0, length));
if (text.startsWith("\uFEFF")) throw new Error("BOM is not allowed");
return JSON.parse(text);
} finally {
await handle.close();
}
}
export async function main(args = process.argv.slice(2)) {
if (args.length === 1 && args[0] === "--help") {
console.log("Usage: node task_report.mjs INPUT.json");
return 0;
}
if (args.length !== 1) {
console.error("error: expected one input file; use --help");
return 2;
}
let result;
try {
result = summarise(await readRecords(args[0]));
} catch {
console.error("error: invalid or unreadable task file");
return 2;
}
console.log(JSON.stringify(result));
return 0;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
process.exitCode = await main();
}
What you should see · example output
{"by_status":{"closed":1,"open":2},"records":3}
4. Run and inspect
node task_report.mjs tasks.json
What you should see · example output
{"by_status":{"closed":1,"open":2},"records":3}Expect three records: one closed and two open. The program does not modify the input. Known invalid input produces no stdout summary, one generic stderr message and status 2. The generic boundary message avoids echoing private input; it does not replace tests or debugging inside the reusable functions. This script is a teaching tool for small files, not a production ingestion service or a security sandbox.
YOUR TURN
A small check before you continue
Change only the last status to pending. Should the program still print a count for the valid first records?
Show a suggested answer
No. It rejects the document with empty stdout, a generic stderr message and status 2. Restore open or closed before checking the normal result again.
Sources and scope
Documentation and examples reviewed 2026-10-18. Provider screens, package versions and output wording can differ. Examples show expected outcomes; they are not a claim that OpenViST connected to your server or verified your setup.
