@leumas/exporters
The ONE way anything in Leumas leaves the app — serialize a value to JSON/NDJSON/CSV/text, hand it to the browser as a download, and wrap it in a versioned envelope so a file exported from one Leumas...
@leumas/exporters
The ONE way anything in Leumas leaves the app.
Before this existed, pnpm atlas find "download file blob export csv" answered "nothing like it" while twenty-odd surfaces each hand-rolled URL.createObjectURL inline. This package is what they should all have been calling.
import { exportAs, copyText } from '@leumas/exporters';
exportAs('csv', events, 'mqtt-capture'); // → "mqtt-capture_2026-08-11_14-30-05.csv"
await copyText(toJSON(result));
Three layers
| Subpath | Needs a DOM | What it is |
|---|---|---|
@leumas/exporters/serialize | no | a value → the text of a file — toJSON toNDJSON toCSV toText serialize FORMATS |
@leumas/exporters/download | yes | that text → the user's downloads — saveAs downloadAs exportAs copyText readFileText canDownload |
@leumas/exporters/envelope | no | the portable wrapper — wrap read |
@leumas/exporters/filename | no | stampName exportFilename |
The barrel (@leumas/exporters) re-exports all four. Nothing runs at module scope, so importing the barrel in Node is safe — canDownload() simply answers false and saveAs becomes a no-op.
The UI is not in here. <ExportMenu> lives in @leumas/ui; a package that renders nothing must not depend on React.
The envelope — why an export is not just JSON
A bare JSON.stringify(request) is not portable, it is a blob of JSON that happens to work today. wrap() adds the three things a payload cannot say about itself:
{ "leumas": 1, "kind": "protocols.request", "version": 1,
"createdAt": "2026-08-11T14:30:05.000Z",
"meta": { "transport": "mqtt" },
"data": { }
}
read(text, 'protocols.request') returns {ok, kind, version, meta, data, error} and never throws — it is fed files a human dragged in, half of which are the wrong file. A parse error, a missing marker, the wrong kind and a newer envelope are all normal inputs, and each comes back as a sentence you can put straight in a <Notice>.
A bare payload with no envelope is rejected on purpose. Accepting it would mean guessing what it was.
The four things that were wrong in the hand-rolled copies
Each is pinned by a test in test/exporters.test.js, and none of them throws — which is why none was ever noticed by the person who wrote it.
JSON.stringifyon anything holding a socket, a DOM node or a parent pointer throws from inside
a click handler, so the button appears to do nothing at all. toJSON is total.
- The obvious circular-reference guard eats non-circular data. A
WeakSetof everything seen also
fires on a reference that merely appears twice — the same headers object on the request and the response — and silently writes "[Circular]" over real data. safeReplacer tracks ancestors.
- CSV is not
rows.map(r => r.join(',')). A value holding a comma, a quote or a newline must be
quoted with its quotes doubled (RFC 4180), or the file loses column alignment from that row on — and a spreadsheet opens it without complaining, which is worse.
URL.revokeObjectURLin the same tick as the click races the browser's read of the href and
produces a zero-byte file on some versions; never revoking pins the blob for the life of the tab. Revoke on the next frame. And the anchor must be in the document — a detached <a> is silently ignored by Firefox and works fine in Chrome.
Related
@leumas/ui → ExportMenu (the control) and @leumas/ui/dnd (the import direction). @leumas/devices/media → downloadBlob is now a thin re-export of saveAs.
What it exports
42 exports: 5 components, 0 hooks, 37 helpers. Import from @leumas/exporters.
CLIPBOARD_IMAGE_TYPESENVELOPE_VERSIONENVELOPE_VERSIONFORMATSFORMATScanCopyImagecanDownloadcanDownloadcopyBlobcopyImagecopyTextcopyTextdownloadAsdownloadAsexportAsexportAsexportFilenameexportFilenameformatOfformatOfformatXmlformatXmlreadreadreadFileTextreadFileTextsaveAssaveAsserializeserializestampNamestampNametoCSVtoCSVtoJSONtoJSONtoNDJSONtoNDJSONtoTexttoTextwrapwrap