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

@maleus/xlsx

v0.4.0

Published

Streaming XLSX (Excel) reader and writer for Node, in Rust: typed rows, dates as ISO 8601, at bounded memory

Readme

@maleus/xlsx

This package reads and writes XLSX files, and has the following design goals:

  • spreadsheets submitted by users are not safe, and reading those should guard against well-crafted malicious XLSX files;
  • worksheets are read or written using streams, at bounded memory, whatever their sizes;
  • reading or writing spreadsheets should be fast, and should never block the event loop.

This NPM package relies on Rust libraries, through a napi-rs binding:

import { xlsxRows, xlsxWriteStream } from "@maleus/xlsx";

for await (const row of xlsxRows(upload.path, {
  maxDecompressedBytes: 512 * 1024 * 1024,
  maxRows: 1_000_000,
})) {
  // row: Array<string | number | boolean | null>
}

await pipeline(
  Readable.from(records, { objectMode: true }),
  xlsxWriteStream({
    sheet: "Export",
    columns: [{ header: "Client" }, { header: "Signed", type: "date" }],
  }),
  createWriteStream("export.xlsx"),
);

Install

npm install @maleus/xlsx

Prebuilt binaries ship as one package per platform in optionalDependencies, constrained by os, cpu and libc: linux-x64-gnu, linux-arm64-gnu, linux-x64-musl, linux-arm64-musl, darwin-arm64. A platform nobody built for installs nothing and fails when the module loads — at boot, not part way through an import.

Reading

function xlsxRows(path: string, options: XlsxRowsOptions): XlsxRows;
function listSheets(path: string, options: BudgetOptions): Promise<SheetInfo[]>;

interface XlsxRowsOptions {
  sheet?: string; // defaults to the first sheet
  maxDecompressedBytes: number; // required, see below
  maxRows: number; // required, see below
  batchSize?: number; // defaults to 1000
}

xlsxRows returns a Readable stream in object mode.

Needs an existing file on the disk: XLSX files are compressed archives, and the central directory sits at the end of the archive, so a correct read needs to seek.

Rows

A row is Array<string | number | boolean | null>, each value at its column index. Holes stay holes; rows are padded to the widest row seen so far, which a streaming reader cannot know in advance:

      A     B     C     D
  1   1                                  →  [1]
  2   2     3     4     5                →  [2, 3, 4, 5]
  3                                      →  (empty row, not materialised)
  4   6                                  →  [6, null, null, null]

Real sheets settle their width on the header row, so this mostly concerns malformed input — but index by header position rather than by row.length.

Values

| Cell | Yields | | -------------------------------- | ---------------------------------------------------- | | shared or inline string | string | | number | number | | boolean | boolean | | date, time or datetime | string, ISO 8601 UTC: "2024-03-25T00:00:00.000Z" | | elapsed time ([h]:mm:ss) | string, ISO 8601 duration: "PT30H0M0S" | | error | string, the sheet spelling: "#DIV/0!" | | formula | its cached result, typed as any of the above | | empty, absent, or past the width | null |

XLSX carries no timezone, so the stored wall clock is reported verbatim with a Z, whatever TZ the process runs under. An elapsed time is a duration, not a point in time, and its hours do not wrap at 24.

Protections

Reading takes two mandatory budgets:

  • maxRows is rows the sheet may yield, header included, raised on the row that crosses it.
  • maxDecompressedBytes is what the archive may expand to, and guards against zip/sheet bombs

Only the five predefined XML entities are resolved; any entity a document declares is an error, not an expansion.

Every hostile workbook in the fixtures/ test directory is refused before the thing it was built to cause. On an 8 MiB memory budget, one process per figure:

| Archive | Upload | Refused with | Time | Peak RSS | | --------------------- | ------- | ------------------------------ | ------- | -------- | | bomb-sharedstrings | 7.6 MB | DECOMPRESSED_BUDGET_EXCEEDED | 3.8 ms | 2.4 MB | | bomb-styles | 0.8 MB | DECOMPRESSED_BUDGET_EXCEEDED | 2.4 ms | 2.4 MB | | bomb-rels | 11.7 MB | DECOMPRESSED_BUDGET_EXCEEDED | 2.5 ms | 2.4 MB | | bomb-package-rels | 11.2 MB | DECOMPRESSED_BUDGET_EXCEEDED | 11.0 ms | 2.4 MB | | bomb-workbook | 10.0 MB | DECOMPRESSED_BUDGET_EXCEEDED | 5.7 ms | 2.5 MB | | bomb-inline | 0.3 MB | DECOMPRESSED_BUDGET_EXCEEDED | 1.2 ms | 2.5 MB | | lying-sizes | 0.2 MB | DECOMPRESSED_BUDGET_EXCEEDED | 11.4 ms | 2.4 MB | | bomb-entries | 14.4 MB | TOO_MANY_ENTRIES | 0.4 ms | 2.1 MB | | bomb-wide-rows | 9.7 kB | DECOMPRESSED_BUDGET_EXCEEDED | 13.9 ms | 10.5 MB | | bomb-column-ref | 1.9 kB | CORRUPT | 0.7 ms | 2.5 MB | | xxe-billion-laughs | 2.0 kB | CORRUPT | 0.6 ms | 2.5 MB | | xxe-external-entity | 2.0 kB | CORRUPT | 0.7 ms | 2.5 MB |

The peak does not follow the size of the archive.

Writing

function xlsxWriteStream(options?: XlsxWriteStreamOptions): XlsxWriteStream;

interface XlsxWriteStreamOptions {
  sheet?: string; // sheet a bare row goes to, defaults to "Sheet1"
  columns?: Array<{ header?: string; type?: "date" }>;
  sheets?: Record<
    string,
    { columns?: Array<{ header?: string; type?: "date" }> }
  >;
  maxSheets?: number; // defaults to 256
  tempDir?: string; // defaults to the platform temporary directory
  batchSize?: number; // defaults to 1000
}

xlsxWriteStream returns a Transform: rows in on the writable side in object mode, Buffer chunks out on the readable side.

It buffers, then emits

Nothing comes out of the readable side until you call end().

Rows written go to a temporary file, not to the output — that is what keeps memory flat. Only at end() is the .xlsx assembled from them and emitted, in chunks.

So time-to-first-byte is the time to write every row: an export is not a live feed you can start sending to a client early. And do not wait on a data event before calling end() — none will come.

Rows

Array<string | number | boolean | Date | null/undefined>, at their column index. null and undefined leaves a blank without shifting neighbours.

Several sheets

Columns are declared in the options; a row names the sheet it goes to:

await pipeline(
  Readable.from(
    [
      ["Ada", 1], // the default sheet
      { sheet: "Q2", data: ["Alan", new Date()] }, // named
    ],
    { objectMode: true },
  ),
  xlsxWriteStream({
    sheet: "Q1",
    columns: [{ header: "Client" }, { header: "N" }],
    sheets: {
      Q2: {
        columns: [{ header: "Client" }, { header: "Signed", type: "date" }],
      },
    },
  }),
  createWriteStream("export.xlsx"),
);

A sheet the stream names but the options do not is created on first sight, with no header and no date columns. Sheet names are compared the way Excel compares them, without regard to case.

Dates are declared, never guessed

xlsxWriteStream({ columns: [{ header: "Signed", type: "date" }] });

In an XLSX a date is a serial plus a numFmt. Write the serial alone and the cell reads back as 45376 — a number where the business expects a day. So a Date in an undeclared column is refused, and the error names the column to declare. Inference is not on offer either: "2024-03-25" is a valid product reference as much as a valid day.

Strings in a declared date column are parsed as ISO 8601, so the reader's output feeds straight back in. Two refusals rather than silent wrong answers: a UTC offset (XLSX stores no timezone, so honouring it would shift the value and ignoring it would keep the wrong one), and a date before 1900 (Excel has no serial for it — write it as text if you need to keep it).

What is and is not supported

Reads one worksheet at a time, writes as many as you like, and creates new files only — there is no open-change-save.

| | Reading | Writing | | ---------------------------------------- | -------------------------------------- | ------------------------------------------ | | Values, typed | yes | yes | | Dates | yes | yes, with the format that makes them dates | | Multiple sheets | read any by name; list all | yes, filled one after another | | Formulas | cached result only, never recalculated | cannot be written at all | | Macros, charts, images, embedded objects | ignored, not reported | not written | | Cell formatting, widths, merges | ignored | not written | | CSV, ODS, .xls | no | no | | Synchronous API | no | no |

Error Codes

Every refusal is an XlsxError with a stable code. Branch on the code, not on the message.

| code | Raised by | Meaning | | ------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------- | | NOT_AN_ARCHIVE | reading | Not a ZIP, or its central directory is too damaged to read | | SHEET_NOT_FOUND | reading | No sheet by that name; the message lists the ones the workbook declares | | DECOMPRESSED_BUDGET_EXCEEDED | reading | The archive expands past maxDecompressedBytes | | TOO_MANY_ENTRIES | reading | Indexing the archive's entries alone would cost more than the budget allows | | ROW_BUDGET_EXCEEDED | reading | The sheet holds more rows than maxRows | | CORRUPT | reading | A ZIP but not a readable workbook: a missing part, XML that does not parse, a cell reference that makes no sense | | INVALID_DATETIME | writing | A timestamp that cannot be placed: not ISO 8601, a UTC offset, or before 1900 | | INVALID_VALUE | writing | A cell that cannot be written: a Date in an undeclared column, a non-finite number, a type with no cell for it | | SHEET_LIMIT_EXCEEDED | writing | Past Excel's grid of 1 048 576 rows by 16 384 columns | | INVALID_SHEET_NAME | writing | A sheet name Excel refuses | | WRITE_FAILED | writing | The workbook could not be assembled | | IO | both | The file could not be read or written, or a temporary file could not be made | | INVALID_OPTION | both | An option is missing or malformed | | CLOSED | both | The stream was closed, or rows were added after the file started streaming | | UNSUPPORTED_PLATFORM | both | No native binary was built for this platform; raised when the module loads |

Measurements

node fixtures/generate.mjs   # rebuild the fixtures
pnpm run build               # release binary
node scripts/bench.mjs       # the tables below

One process per figure, VmHWM for the peak, which includes Node's own ~40 MB baseline.

Reading

| Workbook | Rows | Time | Peak RSS | | ------------------------- | ------- | ------- | -------- | | large-200000 | 200 001 | 1.3 s | 66 MB | | large-600000 | 600 001 | 3.7 s | 68 MB | | large-600000, list only | — | 0.003 s | 49 MB |

Writing

Four columns of which one holds dates:

| Rows | Time | Output | Peak RSS | | --------- | ----- | ------- | -------- | | 100 000 | 0.6 s | 1.9 MB | 78 MB | | 600 000 | 3.6 s | 11.3 MB | 100 MB | | 1 048 575 | 6.2 s | 19.8 MB | 134 MB |

Budget 80–140 MB for an ordinary export, and under 200 MB even when the file itself runs to hundreds of megabytes. Most of that is Node rather than this package: an idle node is 40 MB, and pushing the row arrays through a plain Transform with no addon involved accounts for most of the rest. The Rust writer holds 4.2 MB.

Those peaks are a sawtooth, and the garbage collector is why. Output chunks are handed to V8 and only collected in batches, so the figure climbs by roughly 64 MB and drops back, whatever the size of the export — a 432 MB file peaks no higher than a 123 MB one. That ceiling is V8's, not ours, and it is the reason memory does not grow with the output. Under a hard container limit, budget for the peak rather than the average.

Flat memory is bought with disk. Rows spill to one unlinked temporary file — ~178 MB for a sheet at Excel's maximum — released by the kernel on close. Where the temporary directory is memory-backed (tmpfs, /dev/shm, a Kubernetes emptyDir with medium: Memory) that spill is RAM again and does not appear in RSS. Point tempDir at real disk there.

Contributing

Building, testing, fixtures and releases are in CONTRIBUTING.md.

Licence

MIT.