npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

line-seeker

v1.2.0

Published

Line reader with optional sparse index. Stream, seek, or splice by line number. Zero runtime deps.

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-seeker

Docs: 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 bytes

A 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 ERROR

seeker.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 / getRange called 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 / deleteLines run 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() honours maxLineLength: an unbounded appended line now throws LineTooLongError instead 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 / Uint8Array sources are adopted zero-copy over their backing ArrayBuffer (no transient doubling of a large input).
  • A failed index build removes its .tmp partial 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