Skip to content

Guide ​

line-seeker reads and edits lines by number. It does not load a file as one string. It is not a log parser, a search engine, or a general file editor.

js
import { createSeeker, readLine, setLine } from "line-seeker";

await readLine("./notes.txt", 3);
await setLine(new URL("./notes.txt", import.meta.url), 3, "updated");

const mem = createSeeker({ text: "a\nb\nc\n" });
await mem.replaceLine(2, "B");
console.log(mem.text());

Line numbers are 1-based. Negative numbers count from EOF (-1 is the last line). getLine returns null past either end.

When it is a good fit ​

  • You need line N more than once from a file too large to readFile.
  • You will buildIndex once (or refreshIndex after append) and then seek.
  • You want the same calls on a path, a Buffer, or { text }.
  • You want follow() that reopens after logrotate (inode change).
  • You want a small CLI that prints one line, a range, or a match count.

When something else is better ​

JobUse instead
A few kilobytes, one shotfs.readFile + split
Walk every line onceNode readline, or n-readlines
Search a tree / max throughputripgrep (rg)
Browserthis package is Node.js 18+ only
.gz / .zst random accessdecompress first, or a compression-aware tool
Parse JSON / syslog fieldsa parser; this only yields strings
Structured queriesSQLite (or similar)

find() / --grep decode lines and test them in JavaScript. That is a linear scan. It is not competitive with rg.

Costs that the API does not hide ​

No index. getLine(n) scans from byte 0, or from the sequential cursor if you are moving forward. The first jump to a late line on a huge file is still O(that prefix).

Building an index. buildIndex() reads the whole file once and writes a .lseek sidecar. Later seeks read 8 bytes from the sidecar, pread from that offset, then walk at most stride lines (default 64). Skip the index if you will seek once.

Disk edits. replaceLine / insertLines / deleteLines copy the file through a temp path and rename. Memory stays a chunk; disk I/O is the whole file. Editing one line of a 5 GB log rewrites 5 GB. If a sidecar existed, it is rebuilt afterwards (another full read). In-memory sources splice the buffer you already passed in. Edits serialize against each other — parallel writes queue instead of racing over shared offsets.

Array caps. getRange / getLast / getLines / getAround throw RangeTooLargeError above maxRange (default 100_000). copyRange has no such cap because it streams.

Concurrency. The same seeker is safe to share: reads share one open file handle (no descriptor leak under a parallel first burst) and edits are serialized, so two parallel replaceLine / insertLines / deleteLines cannot corrupt each other. Mixing an edit with a read on the same seeker is still a single-caller model.

Zero-copy memory sources. A Buffer / Uint8Array source keeps its backing ArrayBuffer instead of copying it. Do not mutate that source while an in-memory seeker is reading from it.

Follow limits. follow() holds an incomplete last line in memory. A single appended line beyond maxLineLength throws LineTooLongError rather than growing that buffer without bound.

Stdin (-). Forward-only. No index, no edits, no negative seeks that need the length in advance (except a ring for --tail / getLast).

In-memory { text } / Buffer. The whole source stays in RAM. That is the point of passing it in; there is no disk to stream.

Install ​

Node.js 18+. Zero runtime dependencies.

bash
npm install line-seeker
bash
npx line-seeker notes.txt --set 3 --with "updated"
npx line-seeker app.log --line 50
npx line-seeker app.log --grep ERROR --limit 20
npx line-seeker app.log --index

Index (optional) ​

js
const seeker = createSeeker("./huge-log-file.log");

await seeker.buildIndex({ stride: 64 });
// ./huge-log-file.log.lseek  (~8 bytes per 64 lines)

const line = await seeker.getLine(500_000);

After a hit, stay in that neighbourhood without materializing the file:

js
const hit = await seeker.locate(35_495_596);
const window = await seeker.getAround(35_495_596, { before: 5, after: 5 });
await seeker.copyRange(35_495_000, 35_496_000, "./slice.log");

When the file is appended, extend the sidecar instead of rebuilding:

js
await seeker.refreshIndex();

Already-indexed lines stay seekable. Lines past the old EOF resume from the last known offset. Truncate or rewrite invalidates the sidecar (prefix and suffix checksums).

On a read-only volume, set cacheIndex: true, or let buildIndex() fall back to ~/.cache/line-seeker on EACCES.

Layout: Sidecar index.

Times on the machine in front of you: npm run bench.

Sequential cursor ​

The same seeker keeps a forward cursor. getLine(100) then getLine(101) continues from the last offset.

seeker.cursorLineNumber is 0 before the first read.

Stdin ​

bash
cat app.log | npx line-seeker - --grep ERROR
js
const seeker = createSeeker("-", { input: process.stdin });

Follow ​

follow() yields newly appended lines. An incomplete last line waits for a newline. If the path is replaced or truncated, follow reopens from byte 0. fs.watch wakes the loop; poll still runs as a fallback (watch: false disables the watcher). onRotate reports { reason: "rotate" | "truncate" }. An appended line over maxLineLength throws LineTooLongError.

js
const ac = new AbortController();

for await (const rec of seeker.follow({
  fromStart: false,
  signal: ac.signal,
})) {
  console.log(rec.line);
}

Zero dependencies. Node.js 18+.