line-seeker
v1.2.0
Published
Line reader with optional sparse index. Stream, seek, or splice by line number. Zero runtime deps.
Maintainers
Readme
line-seeker
Line reader for Node.js 18+. Path, Buffer, or { text }. Optional sparse index for later seeks. Zero runtime dependencies.
It does not load a file as one string. It also does not replace readFile + split on a small file, Node readline for a single sequential pass, or ripgrep for search.
import { createSeeker, readLine, setLine } from "line-seeker";
await readLine("./notes.txt", 3);
await setLine("./notes.txt", 3, "updated");
const mem = createSeeker({ text: "a\nb\nc\n" });
await mem.replaceLine(2, "B");
console.log(mem.text());On a large log, index once, then seek. Without an index, getLine(n) still scans the prefix.
const log = createSeeker("./huge-log-file.log");
await log.buildIndex({ stride: 64 });
const line = await log.getLine(500_000);Line numbers are 1-based. Negative numbers count from EOF (-1 is the last line). getLine returns null past the ends of the file.
Fit / not a fit
Fit: repeated line-N on a file you cannot readFile; refreshIndex after append; follow across logrotate; same API for a snippet and a log.
Not a fit: one sequential pass (readline / n-readlines); a 2 KB file (split); fast search (rg); browser; gzip; parsing log fields.
Disk edits (replaceLine and friends) copy the whole file through a temp path. Memory stays a chunk; I/O does not. If a .lseek sidecar existed, it is rebuilt after the edit.
Full limits: guide.
Install
npm install line-seekerDocs: moaaz-i.github.io/line-seeker
npx line-seeker notes.txt --set 3 --with "updated"
npx line-seeker app.log --line 50
npx line-seeker app.log --tail 100
npx line-seeker app.log --grep ERROR --limit 20
npx line-seeker app.log --index--grep is a linear scan of decoded lines, not ripgrep.
Index
const seeker = createSeeker("./huge-log-file.log");
await seeker.buildIndex({ stride: 64 });
// writes ./huge-log-file.log.lseek (~8 bytes per 64 lines)
const line = await seeker.getLine(500_000);buildIndex is one full read. After that, a lookup reads 8 bytes from the sidecar, preads that offset, then walks at most stride lines.
When a log is appended:
await seeker.refreshIndex(); // scans only the new bytesA compatible index stays usable after an append. Truncate or rewrite drops the sidecar (prefix/suffix checksums).
On a read-only volume, set cacheIndex: true (or let buildIndex() fall back to ~/.cache/line-seeker on EACCES).
API
createSeeker(source, options?)
source is a path (string or file: URL), - for stdin, a Buffer / Uint8Array, { text }, or { bytes }. Buffer and Uint8Array sources are adopted zero-copy over their backing ArrayBuffer — handy to avoid a transient copy of a large in-memory log.
| Option | Default | Meaning |
| --------------- | --------------------- | -------------------------------------------- |
| encoding | 'utf8' | Line decoding |
| highWaterMark | 256 * 1024 | Stream / pread chunk size |
| maxLineLength | 8 * 1024 * 1024 | Cap on a single line (LineTooLongError) |
| maxRange | 100000 | Cap for getRange / getLast / getLines |
| indexPath | filePath + '.lseek' | Sidecar index location |
| cacheIndex | false | Store the index under ~/.cache/line-seeker |
| cacheDir | os cache | Override cache directory |
| stride | 64 | Index density for buildIndex() |
| input | process.stdin | Byte source when filePath is - |
seeker.getLine(n) → Promise<string \| null>
n may be negative. Without an index this scans from the start or from the forward cursor.
seeker.getRange(start, end) → Promise<string[]>
Inclusive. getRange(-50, -1) is the last 50 lines. Throws RangeTooLargeError above maxRange.
seeker.getLast(n) → Promise<string[]>
Walks backward from EOF when no index covers the whole file.
seeker.getLines([n1, n2, n3]) → Promise<Array<string \| null>>
One forward pass. Result order matches the input. Negative indexes resolve against a single line count, not one scan per negative.
seeker.streamLines({ onLine, start, end, signal })
Return false from onLine to stop.
seeker.lines({ start, end, signal })
for await (const { line, lineNumber } of seeker.lines({ start: 100 })) {
// ...
}seeker.replaceLine(n, text) / insertLines(n, lines) / deleteLines(start, end?)
On disk: copy through a temp file, then rename (I/O ≈ file size). In memory: splice the buffer. Concurrent edits are serialized, so two parallel replaceLine calls cannot corrupt each other. One-shot: readLine, setLine, insertLines, removeLines.
seeker.find(pattern, { start, end, limit, signal })
pattern is a string, a RegExp, or (line, lineNumber) => boolean. Linear scan of decoded lines.
seeker.follow({ pollMs, fromStart, signal, watch, onRotate })
Yields newly appended lines (tail -f). Incomplete last lines wait for a newline. If the path is replaced (logrotate / new inode) or truncated, follow reopens from byte 0. fs.watch wakes the poll loop (watch: false to disable). A single appended line over maxLineLength throws LineTooLongError instead of buffering without bound.
The same seeker keeps a forward cursor: getLine(100) then getLine(101) continues from the last offset instead of scanning the file again.
Pass - as the path to read stdin once, forward-only, with no index:
cat app.log | npx line-seeker - --grep ERRORseeker.countLines() → Promise<number>
Uses the index prefix when present; counts only the appended tail if the file grew. Otherwise a full scan.
seeker.buildIndex() / seeker.refreshIndex() / seeker.hasIndex() / seeker.close()
How the index is laid out
0..3 magic "LSK1"
4 version 2
5 flags bit0 = ended with newline
6..9 stride uint32 LE
10..41 lineCount, fileSize, mtimeMs, entryCount uint64 LE
42..49 lastLineOffset uint64 LE
50..57 prefix/suffix checksums of the indexed bytes
64.. offsets uint64 LE (line 1, 1+stride, 1+2*stride, ...)A 50 million line file with stride: 64 needs about 6 MB on disk. Each later seek reads 8 bytes of index, then at most stride lines from that offset.
Why a new release?
1.2.0 hardens concurrency and closes file-handle leaks under parallel access.
getLine/locate/getRangecalled at the same time on a fresh seeker now share a single open file handle (an in-flight promise instead of open-over-open). Previously the first concurrent burst leaked one descriptor per racing call until GC.- Edits are serialized:
replaceLine/insertLines/deleteLinesrun the whole read–locate–splice sequence under one lock, so two parallel edits cannot read the same offsets and overwrite each other into a corrupt file. follow()honoursmaxLineLength: an unbounded appended line now throwsLineTooLongErrorinstead of growing an in-RAM buffer without limit.getLines([-1, -2, -3])resolves the length once instead of re-scanning the file for every negative index.Buffer/Uint8Arraysources are adopted zero-copy over their backingArrayBuffer(no transient doubling of a large input).- A failed index build removes its
.tmppartial instead of leaving it behind. close()waits for in-flight copies and edits before releasing handles.
The previous publish (1.1.2) was a documentation-only release clarifying the
library's fs usage to resolve a Socket.dev security
advisory. The table below still explains every filesystem call so reviewers can
verify the library's behaviour at a glance.
Security note on fs usage
This library uses Node.js fs and fs/promises modules for standard file I/O operations. All filesystem access is read-oriented and limited to the files you explicitly provide. Nothing is executed, injected, or hidden.
| Operation | Purpose |
| --- | --- |
| open / fh.read | Random-access reads at specific byte offsets for line lookups |
| createReadStream | Stream file bytes to iterate or count lines |
| stat / fh.stat | Get file size and detect log rotation (inode/device change) |
| fs.watch | Wake the follow() tail-poll loop instantly on file changes |
| mkdir | Create the .lseek index sidecar directory |
| rename | Atomic file replacement after index builds or splice edits |
| rm | Remove stale or partially-written index/temp files |
| fh.write | Write index entries to .lseek sidecar files |
Examples and tests additionally use writeFile / appendFile / readFile / mkdtemp purely for fixture setup and verification.
License
MIT
