Skip to content

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.

npm i @laterite/ags4-wasm

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+/);
0.10.1

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");
4.1.1 fallback 6
{"error":6,"warning":0,"fyi":0}

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 {
  FindingDto,
  RuleGroup,
  ValidationReport,
} from "@laterite/ags4-wasm";

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();
}
PROJ TRAN UNIT TYPE ABBR LOCA SAMP LLPL
LOCA_NATE is 2DP: 451105.75

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);
fixes applied: 0
valid: true findings: 0

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.

Browser (web app) · One engine, many doors · Cross-surface parity