Browser API (wasm)¶
@laterite/ags4-wasm is the engine compiled to WebAssembly: the same validator,
reader, repairer and emitter the Python wheel, the
Node addon and the DuckDB extension
run, in a page, with no server.
The web app is built on this same crate, which makes it a worked example rather than a demo — everything below is a practice taken from its source, with the reason it is done that way. (It compiles the crate itself, with every feature on; see what's in the package.)
What's in the package¶
A browser downloads what you ship it, so the package carries the surfaces a page actually needs and leaves the rest to a from-source build:
in @laterite/ags4-wasm |
|
|---|---|
validate · read · build_ags4 |
✅ |
compute_fixes · apply_fixes |
✅ |
list_rules · dictionary · version · engine_version · engine_fingerprint |
✅ |
arrow_ipc (Arrow IPC for duckdb-wasm) · build_ags4_ipc |
build from source |
ags4_to_xlsx · xlsx_to_ags4 |
build from source |
certify · diff · merge · censor |
build from source |
That is 1.8 MiB raw / 749 KiB gzipped, against 5.1 MiB / 1.71 MiB for everything — roughly 2.3× smaller on the wire. The whole read → validate → fix → write chain is present; nothing shipped breaks it in the middle.
Two of the omissions have a replacement rather than simply being absent:
rows_json() reads a group without Arrow (below), and build_ags4 takes the
same data as JSON that build_ags4_ipc takes as Arrow.
Building a bigger engine¶
The crate's cargo features are excel, arrow, certify, diff, merge and
censor, and they are on by default — so a source build gives you the full
engine, and the published package is the deliberately trimmed one:
# everything
wasm-pack build rust-packages/laterite-ags4-wasm --target web --release
# the published shape, plus just the one you want back
wasm-pack build rust-packages/laterite-ags4-wasm --target web --release \
-- --no-default-features --features arrow
Cargo flags go after --; wasm-pack forwards everything past it and exits
zero if they land in the wrong place, so check the artifact, not the exit code.
Init once, and await that one promise¶
// what this shows: init ONCE, at module scope, and let every call await that
// one promise. Every other example here takes this shape.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import init, { version } from "@laterite/ags4-wasm";
// Pass `module_or_path` EXPLICITLY. Left out, the glue falls back to fetching
// relative to `import.meta.url`, which breaks under a non-root `base` — the app
// hit exactly that. In a bundler this is an asset URL:
//
// import wasmUrl from "@laterite/ags4-wasm/ags4_wasm_bg.wasm?url";
// await init({ module_or_path: wasmUrl });
//
// Under Node there is no fetch for a file path, so hand it the bytes instead.
// The CALL is the same either way; only the argument differs.
const wasmPath = fileURLToPath(
import.meta.resolve("@laterite/ags4-wasm/ags4_wasm_bg.wasm"),
);
const ready = init({ module_or_path: readFileSync(wasmPath) });
// One promise, awaited everywhere. Calls that arrive before the module is
// instantiated queue behind it rather than racing a live-before-ready export —
// every wasm function throws if it runs first.
await ready;
console.log(version());
assert.match(version(), /^\d+\.\d+\.\d+/);
Two things here are easy to get wrong and expensive to debug.
Pass module_or_path explicitly. Omitted, the glue falls back to fetching
relative to import.meta.url — which breaks the moment your app is served from a
non-root base. The app hit exactly that.
Init once, at module scope, and let everything await the same promise. Every export throws before instantiation, so the alternative is a live-before-ready race that only shows up when a user acts fast. Awaiting a shared promise makes early calls queue instead.
The one difference between these examples and your app
Everything on this page runs under Node so it can be tested, and Node has no
fetch for a file path — so module_or_path gets the bytes. In a bundler it
gets an asset URL: import wasmUrl from "@laterite/ags4-wasm/ags4_wasm_bg.wasm?url".
The call is the same; only that argument differs.
Validate, and read severity correctly¶
// what this shows: validate() over bytes, and the ONE rule for reading a
// finding's severity — an absent `severity` means "error".
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import init, { validate } from "@laterite/ags4-wasm";
await init({
module_or_path: readFileSync(
fileURLToPath(import.meta.resolve("@laterite/ags4-wasm/ags4_wasm_bg.wasm")),
),
});
// A dirty file: the DATA row is SHORT — fewer fields than HEADING declares (Rule 4).
const dirty =
'"GROUP","LOCA"\r\n' +
'"HEADING","LOCA_ID","LOCA_TYPE","LOCA_NATE"\r\n' +
'"UNIT","","",""\r\n' +
'"TYPE","ID","PA","2DP"\r\n' +
'"DATA","BH01","BH"\r\n';
const report = validate(new TextEncoder().encode(dirty));
// The engine OMITS `severity` for errors rather than spelling it out, so the
// default is load-bearing: `?? "warning"` would silently reclassify every error
// in your UI. Resolve it in one place and call that everywhere.
const severityOf = (f) => f.severity ?? "error";
const counts = { error: 0, warning: 0, fyi: 0 };
for (const group of report.findings) {
for (const item of group.items) counts[severityOf(item)] += 1;
}
console.log(report.dict_version, report.resolution, report.finding_count);
console.log(JSON.stringify(counts));
assert.equal(report.ok, false);
assert.equal(report.error, null); // parseable — findings, not a hard failure
assert.ok(counts.error > 0, "a short DATA row is a Rule 4 error");
An absent severity means error. The engine omits the field rather than
spelling it out, so the default you write is load-bearing — and it belongs in one
resolver that everything calls. The app defaulted to "warning" at five separate
sites, which silently reclassified every error in the browser: the summary banner
counted errors as warnings, and the severity filter hid them from the "error"
selection while showing them under "warning".
Note also report.error versus report.findings. A parseable file with problems
returns findings and a null error; only an input that is not AGS4 at all comes
back with error set.
Take the types from the package¶
import type is erased at compile time, so this costs no runtime import — a
module that only needs the shapes stays free of the wasm entirely, which is what
lets the app share types between its main thread and its worker.
Do not hand-mirror them. The app used to re-declare these interfaces because
wasm-bindgen once typed the returns as any; the mirror was wrong about
severity, and a mirror can only ever be right by accident. Since 0.9.0 the
crate publishes every result shape and there is no any left in the .d.ts.
Read: the dataset is a handle, not a copy¶
// what this shows: the ParsedDataset lifecycle — read() once, pull each group
// off it, then free it. Getting the order wrong is the one way to misuse this
// API.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import init, { read } from "@laterite/ags4-wasm";
await init({
module_or_path: readFileSync(
fileURLToPath(import.meta.resolve("@laterite/ags4-wasm/ags4_wasm_bg.wasm")),
),
});
// `read` returns a handle into wasm memory, not a copy of your data.
const dataset = read(new Uint8Array(readFileSync("examples/sample_site.ags")));
try {
console.log(dataset.group_codes().join(" "));
// `meta` describes the columns, `rows_json` carries the values, and the two
// are POSITIONAL against each other: headings[i] names rows[r][i]. Each
// group is built LAZILY on the call and dropped on return, so the dataset has
// to outlive every pull — hold it, don't chain off the call.
const meta = dataset.meta("LOCA");
const rows = JSON.parse(dataset.rows_json("LOCA"));
// Values arrive TYPED, off the file's own TYPE row — a `2DP` heading is a
// JSON number here, not the source text, and a blank cell is null. The cast
// is the same one the Python wheel and the DuckDB extension apply.
const nate = meta.headings.indexOf("LOCA_NATE");
console.log(`${meta.headings[nate]} is ${meta.types[nate]}:`, rows[0][nate]);
assert.ok(dataset.group_codes().includes("LOCA"));
assert.equal(typeof rows[0][nate], "number");
} finally {
// Free before the next parse, or wasm memory holds both datasets at once.
// `using dataset = read(...)` does this for you where `Symbol.dispose` is
// supported; the explicit call is the portable form.
dataset.free();
}
read() returns a ParsedDataset that lives in wasm memory. Each group is built
lazily on the call and dropped on return, so the dataset has to outlive every
pull — hold it rather than chaining off the call — and free it before the next
parse, or wasm memory holds two datasets at once. using dataset = read(…)
does that for you where Symbol.dispose is supported.
meta() and rows_json() are positional against each other: headings[i] names
rows[r][i]. Values arrive typed, off the file's own TYPE row and through
the same cast the Python wheel and the DuckDB extension apply — a 2DP heading
is a JSON number, a DT a "yyyy-mm-dd hh:mm:ss" string, a blank cell null.
Arrow IPC, and why it isn't in the package¶
There is a second read door, arrow_ipc(), that frames a group as an Arrow IPC
stream for duckdb-wasm — with
keys: true prepending the content-addressed _id / _parent_id columns (the
same UUIDv8s the wheel, Node and the DuckDB extension produce, from the one
shared keychain) so cross-group joins resolve.
It is not in the published package. Arrow is roughly a third of the compiled
engine and it exists to feed duckdb-wasm; a caller who is not doing that pays
half a megabyte for bytes they will only parse back. If you want it, build the
crate from source with the arrow feature (it is on by default — see
Building a bigger engine).
Repair: propose, then apply¶
// what this shows: the two-step repair — compute_fixes() proposes, apply_fixes()
// rewrites. They are separate so you can show the user what will change first.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import init, {
compute_fixes,
apply_fixes,
validate,
} from "@laterite/ags4-wasm";
await init({
module_or_path: readFileSync(
fileURLToPath(import.meta.resolve("@laterite/ags4-wasm/ags4_wasm_bg.wasm")),
),
});
// The DATA row is SHORT — three fields where HEADING declares four (Rule 4).
const dirty = new TextEncoder().encode(
'"GROUP","LOCA"\r\n' +
'"HEADING","LOCA_ID","LOCA_TYPE","LOCA_NATE"\r\n' +
'"UNIT","","",""\r\n' +
'"TYPE","ID","PA","2DP"\r\n' +
'"DATA","BH01","BH"\r\n',
);
const rulesIn = (report) => report.findings.map((g) => g.rule).sort();
// Each fix carries its `kind`, the `rule` it answers, the `line` and a `risk` —
// which is why this is a separate call: the app renders them as a reviewable
// before/after list rather than rewriting the file behind the user's back.
const fixes = compute_fixes(dirty);
console.log(fixes.map((f) => `${f.kind}(${f.risk})`).join(" "));
// `apply_fixes` takes the ledger back, so you can hand it a SUBSET — whatever
// the user actually ticked — not just everything that was proposed.
const repaired = apply_fixes(dirty, null, fixes);
console.log("before:", rulesIn(validate(dirty)).join(" "));
console.log("after: ", rulesIn(validate(repaired)).join(" "));
// Rule 4 is gone. The rest are the mandatory catalogs this fragment never had —
// and repair will not invent them, any more than the emitter will: a PROJ or a
// TRAN is an authorial fact, so it is REPORTED, not fabricated. "Fixed" here
// means "the defects a machine can settle are settled", not "now valid".
assert.ok(fixes.some((f) => f.kind === "pad_short_row"));
assert.ok(rulesIn(validate(dirty)).includes("AGS Format Rule 4"));
assert.ok(!rulesIn(validate(repaired)).includes("AGS Format Rule 4"));
assert.equal(validate(repaired).ok, false); // still missing PROJ / TRAN / UNIT / TYPE
pad_short_row(safe)
before: AGS Format Rule 13 AGS Format Rule 14 AGS Format Rule 15 AGS Format Rule 16 AGS Format Rule 17 AGS Format Rule 4
after: AGS Format Rule 13 AGS Format Rule 14 AGS Format Rule 15 AGS Format Rule 16 AGS Format Rule 17
compute_fixes and apply_fixes are separate calls so you can show the user
what will change before anything is rewritten — each fix carries its kind, the
rule it answers, the line and a risk. And because apply_fixes takes the
ledger back, you can hand it a subset: whatever the user actually ticked.
Notice what repair does not do. Rule 4 is gone; Rules 13/14/15/17 remain, because those are the mandatory catalogs this fragment never had and no repair will invent them. "Fixed" means the defects a machine can settle are settled — it does not mean valid.
Produce AGS4¶
// what this shows: build_ags4() — per-group data in, byte-faithful AGS4 out —
// and which catalogs it will derive for you versus which it refuses to invent.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import assert from "node:assert/strict";
import init, { build_ags4, validate } from "@laterite/ags4-wasm";
await init({
module_or_path: readFileSync(
fileURLToPath(import.meta.resolve("@laterite/ags4-wasm/ags4_wasm_bg.wasm")),
),
});
// An ARRAY of `{ code, headings, rows }` — rows are positional cell arrays, not
// objects. `units`/`types` are optional: omit them and they fill from the chosen
// edition's dictionary. Only the headings you supply are written, so a sparse
// group builds clean rather than padding out the whole dictionary.
const groups = [
{
code: "PROJ",
headings: ["PROJ_ID", "PROJ_NAME"],
rows: [["P1", "Demo"]],
},
{
code: "LOCA",
headings: ["LOCA_ID", "LOCA_TYPE", "LOCA_GL"],
rows: [
["BH01", "CP", "23.68"],
["BH02", "RC", "32.49"],
],
},
];
// `synthesiseMetadata` derives UNIT and TYPE from your columns (and ABBR when PA
// codes are used). It does NOT invent PROJ, DICT or TRAN: a project identity, a
// schema extension and a record of transmission are authorial facts. A fabricated
// TRAN would SATISFY Rule 14 while asserting a transmission that never happened,
// so the gap is reported instead — pass `tran` to state it.
const report = build_ags4(JSON.stringify(groups), {
synthesiseMetadata: true,
tran: {
issue: "1",
date: "2026-08-03",
producer: "Demo Producer",
recipient: "Demo Recipient",
status: "Final",
},
});
const built = validate(new TextEncoder().encode(report.text));
console.log("fixes applied:", report.fixes_applied);
console.log("valid:", built.ok, "findings:", built.finding_count);
assert.ok(report.text.includes('"GROUP","UNIT"'), "UNIT derived");
assert.ok(report.text.includes('"GROUP","TYPE"'), "TYPE derived");
assert.ok(report.text.includes('"GROUP","TRAN"'), "TRAN stated, not invented");
assert.equal(built.ok, true);
synthesiseMetadata derives UNIT and TYPE from your columns, and ABBR when
PA codes are used. It will not invent PROJ, DICT or TRAN, and that
asymmetry is deliberate: a stub TRAN reading TBC / 1900-01-01 still
satisfies Rule 14, so a recipient could not tell an invented transmission
record from a real one and nothing downstream would flag it. Who produced a file,
for whom, when and at what status is knowable only to you — so state it via
tran, or let Rule 14 report the gap.
Keep it off the main thread¶
The engine is synchronous and uninterruptible once entered. A pathologically
dirty file — millions of findings — will hold the thread for tens of seconds, so
the app puts every wasm call in a worker and talks to it over a small request /
response protocol where each message carries a monotonic id.
The consequence is worth stating plainly: "cancel" can only mean discard the stale result, never abort mid-rule. But because the work happens off the main thread, a superseded run never blocks the next paint — the UI stays live and simply ignores the answer when it arrives.
Transfer the file's ArrayBuffer to the worker rather than copying it; the
worker views it as bytes and the main thread does not need it back.
Related¶
Browser (web app) · One engine, many doors · Cross-surface parity