Table of Contents

Build a browser binary inspector

For full-size files, the Binary Inspector application passes the full Blob to the WASM source reader and loads visible hex ranges on demand. Search scans the file in chunks; session edits compose Blob ranges without rewriting the original file. Parsing can be cancelled. That application is desktop-only (at least 1200 CSS pixels wide). Its edits are temporary and have no download or save operation. Replacing or closing the session discards them. Its standalone schemas describe the structures listed in the catalog, usually headers; a successful parse is not full validation or decoding of a file format. Changing the schema, settings or source invalidates the previous result and selection. Run again to inspect the new snapshot. See large files, buffers, and streams to use this mechanism in your own application.

The separate standalone teaching example below is a small in-memory read/update workflow with its own download button. That button is part of this example, not a feature of the desktop application.

This advanced example builds on the standalone browser starter. It uses the same teaching format as the C# file walkthrough, with a fixed count of two records. The browser API does not accept the C# runtime-variable dictionary, so this page explicitly checks that the stored count is 2.

Run it

Serve the complete bundle with node serve.mjs and open http://127.0.0.1:8080/starter/inspector.html. Select Load the ten-byte example, then select a field in the field map. You can also choose a local ten-byte file with this format. The file is read in your browser.

The fixture is 43 53 01 02 01 00 10 02 00 20. Its signature is CS, version is 1, count is 2, and the records have ids 1 and 2. Set record 1's flags to 165 and select Update flags. The final byte becomes A5. Select Download updated file to save the result. The other nine bytes must remain unchanged.

Complete implementation

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Inspect a binary record file</title>
    <style>
      body {
        font: 1rem/1.6 system-ui;
        max-width: 55rem;
        margin: 2rem auto;
        padding: 0 1rem;
      }
      button,
      input {
        font: inherit;
        margin: 0.3rem;
        padding: 0.4rem;
      }
      pre {
        white-space: pre-wrap;
        overflow-wrap: anywhere;
        background: #eee;
        padding: 1rem;
      }
      #fields button {
        display: block;
        text-align: left;
      }
    </style>
  </head>
  <body>
    <h1>Inspect a binary record file</h1>
    <p>
      This teaching format contains a CS signature, version 1, count 2, and two records. Each record
      has a two-byte id and one-byte flags value.
    </p>
    <p id="status" role="status">Loading WebAssembly…</p>
    <button id="fixture" disabled>Load the ten-byte example</button>
    <label>Or open a file <input id="file" type="file" disabled /></label>
    <pre id="bytes" aria-label="Current bytes"></pre>
    <label
      >Record 1 flags (0–255) <input id="flags" type="number" min="0" max="255" value="165"
    /></label>
    <button id="patch" disabled>Update flags</button>
    <a id="download" hidden download="records-updated.bin">Download updated file</a>
    <h2>Field map</h2>
    <p>Select a field to see its byte positions and bytes.</p>
    <div id="fields"></div>
    <p id="selection" role="status"></p>
    <pre id="result" aria-live="polite"></pre>
    <script type="module" src="./inspector.js"></script>
  </body>
</html>
const find = (id) => document.getElementById(id);
const definition =
  "struct record { uint16 id; uint8 flags; }; struct root { uint16 signature; uint8 version; uint8 count; record records[2]; };";
const options = {
  root: "root",
  aligned: false,
  littleEndian: true,
  pointerSize: 8,
  maxArrayElements: 2,
  maxTotalBytesRead: 64,
  maxNestingDepth: 8,
};
let current = null;
let downloadUrl = null;
const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(" ");

function clearDownload() {
  if (downloadUrl) URL.revokeObjectURL(downloadUrl);
  downloadUrl = null;
  find("download").hidden = true;
  find("download").removeAttribute("href");
}

function fail(message) {
  find("result").textContent = message;
  find("patch").disabled = true;
  clearDownload();
}

function dataOrThrow(result) {
  if (!result.success)
    throw new Error(
      `${result.error.code}: ${result.error.message} (path ${result.error.path ?? "unknown"}, offset ${result.error.offset ?? "unknown"})`,
    );
  return result.data;
}

try {
  const { loadCStructSharpWasm, parseWithDebug, update } = await import("../cstructsharp-wasm.js");
  await loadCStructSharpWasm();
  find("status").textContent = "Ready";
  find("fixture").disabled = false;
  find("file").disabled = false;

  async function inspect(bytes) {
    current = null;
    clearDownload();
    find("fields").replaceChildren();
    find("selection").textContent = "";
    find("bytes").textContent = hex(bytes);
    if (
      bytes.length !== 10 ||
      bytes[0] !== 0x43 ||
      bytes[1] !== 0x53 ||
      bytes[2] !== 1 ||
      bytes[3] !== 2
    ) {
      throw new Error(
        "Expected exactly ten bytes: signature CS, version 1, count 2, and two complete records.",
      );
    }
    const parsed = await parseWithDebug(definition, bytes, options);
    find("result").textContent = JSON.stringify(dataOrThrow(parsed), null, 2);
    for (const field of parsed.debug) {
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = `${field.path} · ${field.type} · ${field.value}`;
      button.addEventListener("click", () => {
        find("selection").textContent =
          `Offset ${field.start}; width ${field.end - field.start} bytes; ${hex(bytes.slice(field.start, field.end))}`;
      });
      find("fields").append(button);
    }
    current = bytes;
    find("patch").disabled = false;
  }

  find("fixture").addEventListener("click", () => {
    inspect(new Uint8Array([0x43, 0x53, 1, 2, 1, 0, 0x10, 2, 0, 0x20])).catch((error) =>
      fail(error.message),
    );
  });
  find("file").addEventListener("change", async (event) => {
    try {
      const file = event.target.files[0];
      if (!file) return;
      if (file.size !== 10)
        throw new Error("This fixed-count example accepts a ten-byte file only.");
      await inspect(new Uint8Array(await file.arrayBuffer()));
    } catch (error) {
      fail(error.message);
    }
  });
  find("patch").addEventListener("click", async () => {
    try {
      const flags = Number(find("flags").value);
      if (!current || !Number.isInteger(flags) || flags < 0 || flags > 255)
        throw new Error("Enter an integer from 0 through 255, then reload the fixture if needed.");
      const before = current;
      const result = await update(definition, before, "root.records[1].flags", flags, {
        ...options,
        maxTotalBytesWritten: 10,
      });
      const bytes = dataOrThrow(result);
      if (
        bytes.length !== before.length ||
        bytes.slice(0, 9).some((value, index) => value !== before[index])
      )
        throw new Error("An unrelated byte changed.");
      await inspect(bytes);
      downloadUrl = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
      find("download").href = downloadUrl;
      find("download").hidden = false;
    } catch (error) {
      fail(error.message);
    }
  });
} catch (error) {
  find("status").textContent = "Could not load WebAssembly";
  fail(`Serve the complete bundle over HTTP and check missing runtime files. ${error.message}`);
}
window.addEventListener("pagehide", clearDownload);

The application checks file size before reading the file into memory, then validates signature, version, and count. It passes explicit read limits. The debug list provides field positions with an exclusive end offset, so a range [7,9) covers bytes 7 and 8. The field buttons expose positions and bytes as text, without relying on color.

An update result contains a Uint8Array. The example verifies the unchanged prefix and reads the new bytes again before offering a download. It revokes old download URLs when data changes. An operation error is shown with its stable code; loading failures are reported separately.

Extend it carefully

Try changing the final byte to FF, or load a nine-byte file. Answer: FF is valid flags 255; a nine-byte file is rejected as incomplete. A count other than 2 is also rejected even if the byte length happens to fit.

The current record uses small integers. If you add uint64, preserve decimal strings or BigInt values rather than converting everything to JavaScript Number. See value conversion. If you support larger files, define format-specific count and traversal limits, and consider how processing affects the page's responsiveness.

Adding variable-length records requires a different format and application workflow. A fixed-field update cannot insert space or relocate pointer targets automatically.