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.
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
buildIndexonce (orrefreshIndexafter append) and then seek. - You want the same calls on a path, a
Buffer, or{ text }. - You want
follow()that reopens afterlogrotate(inode change). - You want a small CLI that prints one line, a range, or a match count.
When something else is better
| Job | Use instead |
|---|---|
| A few kilobytes, one shot | fs.readFile + split |
| Walk every line once | Node readline, or n-readlines |
| Search a tree / max throughput | ripgrep (rg) |
| Browser | this package is Node.js 18+ only |
.gz / .zst random access | decompress first, or a compression-aware tool |
| Parse JSON / syslog fields | a parser; this only yields strings |
| Structured queries | SQLite (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.
npm install line-seekernpx 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 --indexIndex (optional)
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:
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:
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
cat app.log | npx line-seeker - --grep ERRORconst 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.
const ac = new AbortController();
for await (const rec of seeker.follow({
fromStart: false,
signal: ac.signal,
})) {
console.log(rec.line);
}