# @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...


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.

```js
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:

```json
{ "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.

1. **`JSON.stringify` on 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.
2. **The obvious circular-reference guard eats non-circular data.** A `WeakSet` of 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*.
3. **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.
4. **`URL.revokeObjectURL` in 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`.


---
Source: shared/packages/exporters/README.md
Canonical: https://docs.leumas.tech/p/packages/exporters
