Repository navigation
Viewer and Browser
JAZMIN files can be opened, queried and written in a web browser, on the person's own device: nothing is uploaded, and only the parts needed are read from disk.
| Piece | What it is | Page section |
|---|---|---|
| The viewer | A ready-made web app that opens a .jzm file and shows its document, data and files |
The viewer |
| The browser reader |
@smithsoft-studios/jazmin/browser: an async reader with no dependencies, for your own pages |
The browser reader |
openUrl |
Reads a file from a web server or object storage with range requests | Opening a file by URL |
| The browser writer | Writes files on the device, for records sent back to the owner | Writing files in the browser |
The viewer opens a .jzm file and shows:
- its document, the package's entry page (Embedded Files and Documents), if it has one;
- its data, as a table you can filter, sort and page;
- its files, to open or save.
It comes in these forms:
| Form | How | Good for |
|---|---|---|
| Installed app | Host js/viewer and js/browser side by side over HTTPS; people choose "Install". Chrome and Edge then open .jzm files with it, also offline |
People who receive .jzm files often |
| Hosted page | The same folders on any web server; choose or drop a file | Anyone with a link |
| Folder on disk | The same folders on a computer; double-click viewer/index.html, then choose or drop a file (From disk) |
No web server and no Node; files of any size |
| In your own page |
<iframe src="viewer/index.html"> in a page of yours, on a web server or on disk; people choose a file in the viewer, or your page passes it one (From disk) |
Your own page around the viewer |
| One HTML file | "Save as HTML" in the viewer: the viewer and the .jzm in one file, opened by double-click, offline |
Sending a document to someone once (best under about 50 MB, because the browser holds the whole file; many mail systems block .html attachments) |
Try it locally with npm run viewer in the repository's js/ folder. A saved HTML file still asks for the key: the data
inside it stays encrypted.
Keys in the viewer:
- Files with one key or a password open with that key or password.
-
Shared files (Access Control) open only with an access key, plus its unlock token for online
access. The viewer and the browser reader refuse a shared file's owner key: it controls the whole file, and a web
page is the easiest place to steal a key from (extensions, injected scripts, whoever serves the page). To see
everything in a browser, the owner grants themselves a full-read access key:
grantAccess(file, owner, key, { rows: '*', columns: '*' }).
Filtering a large file (1.5.0):
- The first page of matches shows at once, and the rest are counted while the rows show: "Rows 1–50 · 361 matches so far · counting 7%" with Stop, then the total and how long the filter took.
- When the first page takes a moment (few matches, or none), an overlay covers the rows: "Searching 25,000,000 rows…", how far it is, the matches so far, and Stop.
-
Make filters fast where people use them: index the columns people filter by, or sort the file by one. On a 500 MB
file of 25,000,000 rows,
{ "amount": 88 }(no index) shows its first page in 0.4 s and counts all 4,777 matches in about 20 s; with an index onamount, counting takes milliseconds.
Exporting (1.5.0):
-
Export ▾ beside the filter saves the rows it matches (every row without a filter) as CSV, JSON or XML: the
same text as the libraries'
toCSV,toJSONandtoXML(Converting JSON, CSV and XML). A long export shows its progress, with Stop. -
Export ▾ → With a shape… opens a box for an export shape: one entry per client with their
transactions and totals, a chart's data set, rows linked across tables. The shape is checked as you type against
the file's tables and the columns your key can see: for a key that can't see
balance, it saysShape at shape[].balance: unknown or hidden column 'balance'. Preview shows the start of the output, JSON Schema its schema, and Export JSON / XML saves it, for the filter's rows or every row. - Shapes saved in the file (Export Shapes) are listed under Export ▾, the default first, each with JSON and XML. A key sees only the shapes it can use. The shape box can start from one.
Locked files, masked columns and online documents (1.7.0):
- A locked file shows in the Files tab with a padlock and Unlock…, which asks for what the lock needs and refuses a wrong password or token. When the file or its lock names a key service, Sign in at … opens the service in a window of its own, where the person signs in and passes its 2FA; the service sends the token back. A locked file no longer stops the document from showing: its page gets the file once it is unlocked. See locked files.
- A masked column shows in its place with what it shows, and Unlock… in its header. Once unlocked, Reveal shows one value at a time (or the column, if the file says so) with a countdown and Hide; it is masked again after 30 seconds by default, when the window is hidden, or after 60 s without input. Exports stay masked unless Include revealed values is ticked in the Export menu. See Masked Columns.
-
Files with
list: false(a script or a font the document loads) are left out of the Files tab. -
A document that needs the internet (
package.online) is marked so; with'required', offline, the viewer shows a message instead of the document, and the document once the device is back online. See Documents that need the internet. -
Loading less: the viewer loads the browser reader's core, and fetches the shapes and writer add-ons the first
time they are used (Save as HTML and
portableHtmlput them inline). A file with no saved shapes needs only the 187 KB core, not the 328 KB full script.
Limits:
- Brotli sections can't be read: browsers have no Brotli decompressor. Write files meant for browsers with deflate (the default).
- Expiry is weaker in a browser. The viewer refuses an expired key and a clock set before the file's date, but keeps no last-seen record, because people can clear browser storage. Use online keys when expiry matters.
- Tested in Chrome, Edge, Firefox and Safari, with phones (Safari on iPhone, Chrome on Android) checked by hand before each release.
The viewer needs no web server and no Node. A page opened from disk (file://) can't read a file beside it by itself
(browsers refuse), so the person chooses the file or drops it, and the viewer reads only the parts it needs, straight
from disk. Three ways:
-
The viewer's folder:
viewerandbrowserside by side (from the npm package, orjs/in the repository); double-clickviewer/index.html. -
The viewer in your own page:
<iframe src="viewer/index.html">; people choose or drop the file in it. - Your page passes the file: your page has its own button or drop area, and hands the viewer the file.
New in 1.5.0: way 3. Ways 1 and 2 work in 1.4.0 too.
A sample to try: js/examples/from-disk builds a folder you can copy anywhere and double-click, with demo data (
node examples/from-disk/make.mjsinjs/, about 7 seconds): a statement that opens by itself, with totals and a search; a start page that asks for a file and opens it in the viewer; and 1,000,000 card transactions, indexed on the columns people filter by, which answer the filters it suggests in 10–80 ms, totals included. Its README explains how both ways work. Four demos (next release) show what a file can do: export shapes (a chart's data from a saved shape in one call, a shape lab), several tables linked by a saved shape, documents (a print-ready report and a task list that saves changes) and one file opened with three access keys side by side.
<input id="pick" type="file" accept=".jzm">
<iframe id="viewer" src="viewer/index.html" title="JAZMIN viewer"></iframe>
<script>
const viewer = document.getElementById('viewer');
document.getElementById('pick').addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) viewer.contentWindow.postMessage({ type: 'jazmin:open', file, name: file.name }, '*');
});
// How it opens: 'opened', 'locked' (the person types the key in the viewer), or 'failed' with a message.
addEventListener('message', (event) => {
if (event.source === viewer.contentWindow && event.data?.type === 'jazmin:status') {
console.log(event.data.name, event.data.state, event.data.message ?? '');
}
});
</script>- The file stays on disk: your page hands over a reference to it, and the viewer reads the slices it needs.
-
Your page can also show a filter's rows:
postMessage({ type: 'jazmin:filter', filter }, '*')does what typing it in the viewer's Filter box does, once a file is open (1.5.0). The sample's Try buttons use it. -
Send the message once the iframe has loaded; one sent earlier is lost. Send it to
'*'from a page on disk (originnull), and to the viewer's origin from a web server. - Your page hears how the file opens, and nothing else: the key is typed in the viewer, and the data stays there.
-
Only the page holding the iframe can pass files: a document inside a
.jzmcan't, and a viewer that isn't in a frame takes none this way. -
What your page can reach: across sites, and from disk in Chrome and Edge, nothing inside the viewer. On the same
site, and from disk in Firefox (within one folder), it can, key field included: put the viewer only in pages you
trust. Hosting the viewer for others?
Content-Security-Policy: frame-ancestors 'self'stops other sites showing it.
Measured (npm run bench:viewer) on a 201 MB file of 10,000,000 rows in Chrome, Edge and Firefox (Windows 11,
i7-12700H). The three ways read and use the same:
| Step | Time | Read from disk | Extra memory |
|---|---|---|---|
| Open, first page of rows | 62–130 ms | 0.07 MB | |
| Find one row by id | 67–81 ms | 0.15 MB | 19–30 MB after the lookup (once 92 MB, Firefox) |
| A filter that reads every row | 8.3–11 s | 166 MB, at most 69 KB at a time | peak 100–212 MB |
A larger file reads about as little to open and to look up a row; only filters that read every row take longer. To open a file with no choosing, it must come inside the page ("Save as HTML") or as a script (below): the browser then holds all of it.
New in 1.5.0.
A page opened from disk can't read a .jzm by its path, but it can load a script from any folder. Make the file into
one, and your page opens it with no choosing:
jazmin script reports/statement.jzm # writes reports/statement.jzm.jsIn code: portableScript(file) in Node, JazminFile.PortableScript(path) in .NET (the same text). Your page, with the
browser reader:
<script src="jazmin-browser.js"></script>
<script>
JazminBrowser.openScript('../reports/statement.jzm.js', { password }) // or 'file:///C:/Reports/statement.jzm.js'
.then((table) => table.query({ country: 'ZA' }, { limit: 50 }))
.then(({ rows, total }) => console.log(total, rows));
</script>-
The path is relative to the page, or a
file:///address (forward slashes,%20for spaces). A page can open several such files. -
The file inside is the
.jzmas it is, still encrypted: the page still needs the key or password. -
To show it in the viewer (1.5.0):
const bytes = await JazminBrowser.scriptBytes(url), thenviewer.contentWindow.postMessage({ type: 'jazmin:open', file: new Blob([bytes]), name }, '*'). -
The page holds the whole file in memory. Make the script again whenever the
.jzmchanges. -
A
.jzm.jsis a script, and the page runs it: load only scripts you made, or trust. -
Size: best under about 20 MB. Chrome and Edge can't load a script of a file over about 380 MB, so
portableScriptrefuses one.
Measured (npm run bench:viewer -- --script) in Chrome, Edge and Firefox:
| File (script) | Load and open | One lookup | Memory the page holds |
|---|---|---|---|
| 0.4 MB (0.5 MB) | 9–12 ms | 7–18 ms | within the browser's own variation |
| 21 MB (28 MB) | 0.2–0.4 s | 7–14 ms | about 60–110 MB |
| 101 MB (135 MB) | 1.3–6.2 s | 15–22 ms | 400–450 MB, peaks of 740–850 MB |
<script src="jazmin-browser.js"></script> <!-- a classic script: works from file:// too; defines window.JazminBrowser -->
<input type="file" id="pick">
<script>
document.getElementById('pick').onchange = async (e) => {
const reader = await JazminBrowser.open(e.target.files[0], { key: prompt('Key') }); // a File is read in slices, never whole
const { rows, total } = await reader.query({ country: 'ZA' }, { offset: 0, limit: 50 });
console.log(total, rows);
};
</script>From 1.4, the browser reader decodes filtered queries as the library does: the filter's
columns first (a nested column the rows don't return with only the fields the filter reads), and the
columns it returns only for chunks with matching rows, their text only for those rows. Statistics of nested fields
skip chunks for any and match. On 5,000 companies with departments and employees, finding those with an employee
of a given id takes 7 ms instead of 411 ms; on 200,000 plain rows, returning 667 whole rows takes 160 ms instead of
240 ms.
It is the same reader in every respect that matters: the same filters, the same query planning (chunk statistics, sort order, sorted and trigram indexes, whole chunks skipped by offsets), the same files. It also runs in Node 22+, which is how the example below checks it against the library:
import { JazminKey, open, write } from '@smithsoft-studios/jazmin';
import '@smithsoft-studios/jazmin/browser'; // defines globalThis.JazminBrowser
const { JazminBrowser } = globalThis;
const key = JazminKey.generate();
const bytes = write(null, Array.from({ length: 20_000 }, (_, i) => ({
id: i + 1, account: `ACC-${String(i % 400).padStart(3, '0')}`, at: new Date(Date.UTC(2026, 0, 1) + i * 3_600_000), amount: (i % 997) / 4,
})), { key, sortedBy: ['id'], indexes: { account: 'sorted' }, files: [{ path: 'terms.txt', content: 'Terms and conditions' }] });
const reader = await JazminBrowser.open(bytes, { key: key.export() }); // a File, Blob, bytes, or { size, read(offset, length) }
console.log(reader.rowCount, 'rows;', reader.columns.map((c) => c.name).join(', '));
const page = await reader.query({ account: 'ACC-007' }, { offset: 10, limit: 2, select: ['id', 'amount'] });
console.log(page);
console.log(await reader.count({ amount: { gte: 200 } }), 'large amounts');
for await (const row of reader.find({ id: { gte: 19_999 } })) console.log(row.id, row.at.toISOString());
const { values } = await reader.columnArrays({ account: 'ACC-001' }, { select: ['amount'] }); // typed arrays, for charts
console.log(values.amount.constructor.name, values.amount.length);
const { ms, ...cost } = await reader.explain({ id: 12_345 }, { analyze: true });
console.log(cost);
console.log(new TextDecoder().decode(await reader.readFile('terms.txt')));
// Same answers as the library
console.log('library agrees:', open(bytes, { key }).count({ amount: { gte: 200 } }) === await reader.count({ amount: { gte: 200 } }));20000 rows; id, account, at, amount
{
rows: [ { id: 4008, amount: 4.75 }, { id: 4408, amount: 104.75 } ],
total: 50
}
3940 large amounts
19999 2028-04-13T06:00:00.000Z
20000 2028-04-13T07:00:00.000Z
Float64Array 50
{
strategy: 'scan',
chunks: 5,
chunksSkipped: 4,
rows: 1,
bytesRead: 4305,
chunksRead: 1,
indexPagesRead: 0,
columnsDecoded: 4
}
Terms and conditions
library agrees: true
| Browser reader | Returns |
|---|---|
await JazminBrowser.open(source, { key, password, unlockToken, table }) |
A reader |
reader.find(filter, { select, offset, limit, signal, onProgress }) |
An async iterator of rows |
await reader.query(filter, { select, offset, limit, total, signal, onProgress }) |
{ rows, total }; total: false skips counting |
await reader.count(filter, { signal, onProgress }) |
The number of matching rows |
await reader.columnArrays(filter, { select }) |
Typed arrays per column |
await reader.explain(filter, { analyze }) |
The plan and, with analyze, what it read |
await reader.estimate(filter, { offset }) (1.7.1) |
What a read will cost before it runs: the same figures as the library's estimate()
|
await reader.readFile(path) |
An embedded file's bytes |
await JazminBrowser.toJSON / toCSV / toXML(reader, { filter, select, offset, limit, shape, maxLength, signal, onProgress, ... }) |
The libraries' export text (1.5.0); maxLength stops early, for previews |
await JazminBrowser.exportBlob(reader, 'csv' | 'json' | 'xml', options) |
The export as a Blob to save, written in parts (1.5.0) |
await JazminBrowser.shapeSchema(reader, shape) |
The JSON Schema of a shape's output; throws where the shape doesn't fit (1.5.0) |
await reader.shapes() |
The export shapes saved in the file that this key can use; pass one's name as shape (1.5.0) |
JazminBrowser.from(reader) |
A LINQ-style query with arrow functions, awaited: await JazminBrowser.from(reader).where((t) => t.city === 'Durban').toArray() (Reading and Querying; 1.5.0) |
await reader.files(), reader.metadata, reader.access, reader.tables, reader.submissionKey
|
As in the library (files() is a method here) |
Long reads (1.5.0): onProgress({ done, total, matches }) is called after each chunk read (chunks done, chunks
to read, matches so far); aborting the AbortSignal stops the read between chunks, rejecting with an AbortError.
Last first, locks and masks (1.7.0): find and query take reverse: true
(Reading last first). await reader.files() lists each
file with its lock, await reader.locks() and await reader.masked() list locks and masked columns, and
await reader.unlock(id, { password, unlockToken }) opens a lock; reading a locked file before throws
JazminFileLockedError. Unlocking a masked column builds every open table reader of the file again with it, and the
exports give it as one column, masked unless unlocked (revealMasked: false keeps it masked):
import { JazminKey, write } from '@smithsoft-studios/jazmin';
import '@smithsoft-studios/jazmin/browser';
const { JazminBrowser } = globalThis;
const owner = JazminKey.generate();
const clerk = owner.createAccessKey();
const bytes = write(null, [{ id: 1, name: 'Thandi', account: '4821 0393 7712 4821' }, { id: 2, name: 'Pieter', account: '5100 2231 9083 1177' }], {
key: owner, access: { grants: [{ key: clerk }] }, locks: { pay: { password: 'correct horse battery staple' } },
columns: [{ name: 'id', type: 'int' }, { name: 'name', type: 'string' }, { name: 'account', type: 'string', mask: { lock: 'pay', show: { last: 4 } } }],
files: [{ path: 'contract.pdf', content: 'The contract', lock: 'pay' }],
});
const reader = await JazminBrowser.open(bytes, { key: clerk.export() });
console.log((await reader.files()).map((f) => `${f.path}: lock ${f.lock.id}, unlocked ${f.lock.unlocked}`));
console.log((await reader.masked()).map((m) => `${m.column}: shown in ${m.shown}, at ${m.at}`));
console.log(await JazminBrowser.toCSV(reader));
await reader.unlock('pay', { password: 'correct horse battery staple' }); // one lock: the file and the column
console.log(new TextDecoder().decode(await reader.readFile('contract.pdf')));
console.log((await reader.query(null, { reverse: true, limit: 1, select: ['name', 'account'] })).rows);[ 'contract.pdf: lock pay, unlocked false' ]
[ 'account: shown in account_shown, at 2' ]
id,name,account
1,Thandi,••••4821
2,Pieter,••••1177
The contract
[ { name: 'Pieter', account: '5100 2231 9083 1177' } ]
jazmin-browser.js holds everything. A page that only reads can load the core instead, and add what else it uses
after it, as plain scripts (they work from file:// too):
| Script | What it has | Size (gzipped) |
|---|---|---|
jazmin-browser.js |
Everything below, as before | 328 KB (85 KB) |
jazmin-browser-core.js |
Opening, unlocking and reading files; JSON, CSV and XML exports | 187 KB (50 KB) |
jazmin-browser-shapes.js |
Export shapes, reader.shapes(), exports with { shape }
|
45 KB (12 KB) |
jazmin-browser-writer.js |
createWriter, write, writeChanges
|
53 KB (15 KB) |
jazmin-browser-queries.js |
JazminBrowser.from (queries with arrow functions) |
47 KB (12 KB) |
<script src="jazmin-browser-core.js"></script>
<script src="jazmin-browser-shapes.js"></script> <!-- add-ons after the core, in any order -->- A call that needs an add-on that isn't loaded says which: "This needs jazmin-browser-shapes.js, loaded after jazmin-browser-core.js (or load the full jazmin-browser.js)".
-
From npm:
@smithsoft-studios/jazmin/browser/core,…/browser/shapes,…/browser/writerand…/browser/queries;@smithsoft-studios/jazmin/browseris still the full script. - Pages that load the full script need no change. The from-disk sample's pages each load only what they use.
openUrl reads a file straight from a web server or object storage (S3, a CDN) with HTTP range requests. Only the parts
a query needs are downloaded.
const reader = await JazminBrowser.openUrl('https://files.example.com/statements-2025.jzm', {
key, // as for open()
headers: { Authorization: `Bearer ${token}` }, // sent with every request (optional)
});
const { rows } = await reader.query({ account: 'ACC-100250' }, { limit: 50 });-
Few requests: the first fetches the end of the file (its size from
Content-Range, the header and usually the directories); later reads fetch 64 KiB blocks, kept in a small cache (blockSizechanges the size). On a 3 MB file of 200,000 rows, opening it and reading one account's first page took 3 requests and 256 KB. - The server must support range requests. One that answers with the whole file still works, but downloads it all.
-
Another origin (CORS): the server must send
Access-Control-Allow-Headers: RangeandAccess-Control-Expose-Headers: Content-Range.
The browser module writes files too: one table, with embedded files (photos, PDFs), locked with a key, a password or nothing. It is made for sending records back: records a person captures, often offline, and sends later.
import { open } from '@smithsoft-studios/jazmin';
import '@smithsoft-studios/jazmin/browser';
const { JazminBrowser } = globalThis;
const columns = [
{ name: 'id', type: 'string', nullable: false },
{ name: 'note', type: 'string' },
{ name: 'attachments', type: 'json' },
];
const writer = await JazminBrowser.createWriter({ columns, password: 'field app demo', kdfIterations: 100_000 });
await writer.writeRows([{ id: 'v-17', note: 'site visit', attachments: ['v-17/photo.jpg'] }]); // await each call
await writer.addFile({ path: 'v-17/photo.jpg', content: new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]) }); // a File, Blob, bytes or text
const blob = await writer.finish(); // a Blob: keep it (IndexedDB, say) and upload it when online
// The library reads what the browser wrote
const reader = open(Buffer.from(await blob.arrayBuffer()), { password: 'field app demo' });
console.log([...reader.rows()], reader.files.map((f) => `${f.path} ${f.type} ${f.size}`));[
{ id: 'v-17', note: 'site visit', attachments: [ 'v-17/photo.jpg' ] }
] [ 'v-17/photo.jpg image/jpeg 7' ]
-
Options as in the library's
write():columns,keyorpassword(withkdfIterations),metadata,codec('deflate'or'none'),chunkRows,chunkBytesandfiles.JazminBrowser.write(rows, options)does it in one call. -
The same bytes as the library: given the same rows, options and random bytes, the browser writes exactly what the
library writes (CI checks this). It uses only the browser's own encryption (
crypto.subtle), randomness and compression (CompressionStream). -
Not written in browsers: shared (access-controlled) files, indexes, several tables, package settings,
sortedByand Brotli. Each is refused with a message that says why. The owner's service adds indexes when it compacts. - Memory: each embedded file is read once to hash it, and the finished file is held in memory until you store it, so keep a batch to tens of megabytes.
-
Requirements: encryption needs a secure page (
https://orlocalhost); compression needs Chrome 103, Firefox 113 or Safari 16.4 or later (elsewhere, passcodec: 'none').
JAZMIN 1.2 · file format 1.0 · Repository · npm · NuGet · MIT license · Examples in this wiki are run and checked by tools/check-examples.mjs
Start
Using files
- Writing Files
- Nested Columns
- Reading and Querying
- Filter Language
- .NET Serializer and LINQ
- Updating and Appending
- Several Tables
- Converting JSON, CSV, XML
- Export Shapes
Security and sharing
- Encryption and Keys
- Access Control
- Masked Columns
- Time-Limited Access
- Sending Records Back
- Security Model
- Verified Publishers
Documents and apps
Performance
Reference