Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Files and standard streams

read(path) gives a file’s bytes as an Enumerable, and writeTo(path) writes bytes to a file. In between, the data streams a chunk at a time.

import { read } from "@j50n/proc";

// Copy the error lines into a file of their own.
await read("app.log")
  .lines
  .filter((line) => line.includes("ERROR"))
  .writeTo("errors.txt"); // each line written with a "\n"

console.log(await read("errors.txt").lines.collect());
[
  "2026-10-05 09:01:13 ERROR db: connection refused",
  "2026-10-05 09:02:45 ERROR GET /orders/17 500"
]

read() opens the file when reading starts, not at the call, and closes it when reading ends, including when the consumer stops early. A missing file throws Deno.errors.NotFound from the consumer’s await. readLines(path) is shorthand for read(path).lines.

Writing a file

writeTo(path) creates the file, or replaces what it held, and closes it when the sequence ends. It writes items as toStdout() does: bytes as they are, and each string as a line, with a "\n" added. If the source throws, including a command in the pipeline that fails, the file is closed holding what was written so far, and the error comes out of writeTo. The file is emptied before anything is read, so what it held before is gone either way, and a pipeline that reads the same file (read(path) … writeTo(path)) finds it already empty, as cmd < f > f does in a shell.

To replace a file only once everything has worked, or to rewrite one in place, pass { atomic: true }:

import { read } from "@j50n/proc";

// Rewrite fruit.txt in place: atomic writes a new file and renames it over
// the old one, so reading the old one while writing works.
await read("fruit.txt")
  .lines
  .map((line) => line.toUpperCase())
  .writeTo("fruit.txt", { atomic: true });

console.log((await Deno.readTextFile("fruit.txt")).trimEnd());
CHERRY
APPLE
BANANA

With atomic, proc writes a new file beside the old one, flushes it to disk, and renames it into place once everything is written. A failure leaves the old file as it was, with nothing beside it, and so does a program that exits partway under main. A symlink stays a symlink, and the file keeps its mode. Anything reached through /dev or /proc, such as /dev/stdout, is written in place. It needs read permission on the file and write permission on its directory, and the result is a new file, so a hard link to the old one still shows the old content.

To add to a file instead of replacing it, open it yourself and pass its writable:

import { enumerate, toBytes } from "@j50n/proc";

// writeTo(path) replaces the file. To add to it, open it yourself.
await enumerate(["first run"]).transform(toBytes).writeTo("notes.txt");

const file = await Deno.open("notes.txt", { append: true });
await enumerate(["second run"]).transform(toBytes).writeTo(file.writable);

console.log(JSON.stringify(await Deno.readTextFile("notes.txt")));
"first run\nsecond run\n"

writeTo(stream) takes any WritableStream and closes it at the end, which closes the file too.

stdout

import { enumerate, read, toBytes } from "@j50n/proc";

// Strings are written as lines; bytes as they are.
await enumerate(["one", "two"]).toStdout();
await read("fruit.txt").toStdout();

// writeTo() closes what it writes to, unless told not to.
await enumerate(["three"])
  .transform(toBytes)
  .writeTo(Deno.stdout.writable, { noclose: true });
console.log("stdout is still open");
one
two
cherry
apple
banana
three
stdout is still open

toStdout() writes each string with a "\n" added, and bytes as they are, and leaves stdout open. Use it for a command’s output too: run("ls").toStdout(). When stdout’s reader goes away before the program is done writing, as when its output is piped into head, toStdout() stops as a consumer that stops early does: it closes the source and resolves, without an error.

writeTo(Deno.stdout.writable) works, but closes stdout when it finishes unless you pass { noclose: true }. After that, every console.log in the program throws BadResource.

stdin

Deno.stdin.readable is a stream of bytes, so enumerate() wraps it like any other source:

import { enumerate } from "@j50n/proc";

// Print the lines of stdin that mention ERROR.
await enumerate(Deno.stdin.readable)
  .lines
  .filter((line) => line.includes("ERROR"))
  .toStdout();
deno run stdin-errors.ts < app.log
2026-10-05 09:01:13 ERROR db: connection refused
2026-10-05 09:02:45 ERROR GET /orders/17 500

Reading stdin needs no permission. A child process started with run() gets no stdin by default; to feed it, pipe into it with .run() (Pipelines and input).

Compressed files

CompressionStream and DecompressionStream take byte chunks, so the bytes from read(), a command’s output, or toBytes go straight in:

import { enumerate, gunzip, gzip, read } from "@j50n/proc";

// Bytes go straight into the web streams.
const errors = await read("app.log.gz")
  .transform(new DecompressionStream("gzip"))
  .lines
  .count((line) => line.includes("ERROR"));
console.log(`${errors} errors`);

await read("app.log")
  .transform(new CompressionStream("gzip"))
  .writeTo("copy.log.gz");

// gzip and gunzip also take lines of text.
await enumerate(["alpha", "beta"]).transform(gzip).writeTo("words.gz");
console.log(await read("words.gz").transform(gunzip).lines.collect());
2 errors
[ "alpha", "beta" ]

The gzip and gunzip steps do the same, and also accept lines of text, which they turn into bytes first. Data that isn’t valid gzip throws TypeError. .run("gzip", "-dc") works too, in a child process.

Lines or bytes

.lines decodes UTF-8 and splits on "\n", dropping a "\r" before it, so CRLF files work. Invalid UTF-8 throws TypeError. Keep the bytes for anything that isn’t text: compressed data, images, or a copy that must be exact. toByteLines splits bytes into lines without decoding them. For files with a great many short lines, .chunkedLines gives the same lines an array at a time, and is several times faster.

Large files

A chain that streams from read() to writeTo() holds only a few chunks at a time, whatever the size of the file: filtering a 1.2 GB log through gzip peaked at 150 MB of process memory, no more than a 300 MB log took. Two things break that: collect(), which keeps every item, and a single line so long that it doesn’t fit, since .lines holds a line until it ends. Count, reduce, or write as you go instead of collecting.