Running QIP In JavaScript

QIP components can be imported as WebAssembly ES modules and wrapped as ordinary synchronous JavaScript functions. The wrapper writes directly into the component's memory, calls render, and reads the result. It does not need generated glue or a QIP JavaScript runtime.

Direct .wasm imports depend on support from your runtime or bundler. The explicit-instantiation fallback uses the same wrapper and contract.

The examples on this page use components built and tested in this repository. They assume the module is a known-valid QIP component and do not revalidate its ABI after every call. That trust boundary is different from accepting arbitrary Wasm supplied by a user or third party; see [Known And Untrusted Components](/docs/content-component#known-and-untrusted-components) in the Content contract.

Direct Import: E.164 #

This example wraps the E.164 canonicalizer:

import {
  memory,
  input_ptr,
  input_utf8_cap,
  output_ptr,
  render,
} from "/components/utf8/e164.wasm";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

export function normalizeE164(phoneNumber) {
  const input = new Uint8Array(
    memory.buffer,
    input_ptr(),
    input_utf8_cap(),
  );

  const { read, written } = encoder.encodeInto(phoneNumber, input);
  if (read !== phoneNumber.length) {
    throw new RangeError("phone number exceeds input capacity");
  }

  const outputSize = render(written);
  return decoder.decode(
    new Uint8Array(memory.buffer, output_ptr(), outputSize),
  );
}

console.log(normalizeE164("+1 (212) 555-0100"));
// +12125550100

The input Uint8Array is a view over the component's linear memory. encodeInto writes UTF-8 bytes into that memory directly, avoiding the temporary encoded array created by TextEncoder.encode().

GFM CommonMark To HTML #

The same wrapper shape works for a larger text transformation. This component accepts Markdown and returns HTML, including GFM tables and task lists:

import {
  memory,
  input_ptr,
  input_utf8_cap,
  output_ptr,
  render,
} from "/components/text/markdown/gfm-commonmark.0.31.2.wasm";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

export function markdownToHtml(markdown) {
  const input = new Uint8Array(
    memory.buffer,
    input_ptr(),
    input_utf8_cap(),
  );

  const { read, written } = encoder.encodeInto(markdown, input);
  if (read !== markdown.length) {
    throw new RangeError("Markdown exceeds input capacity");
  }

  const outputSize = render(written);
  return decoder.decode(
    new Uint8Array(memory.buffer, output_ptr(), outputSize),
  );
}

const html = markdownToHtml(`
# Release checklist

- [x] Build
- [ ] Deploy
`);

The returned string is HTML, not a sanitization policy. Sanitize it before assigning it to innerHTML when the Markdown is untrusted.

This version of the GFM CommonMark component has no uniforms; its behavior is fixed.

Currency Formatting With A Uniform #

Uniforms configure a component without changing its content input. The en-US currency formatter accepts an exact decimal string and uses an ISO 4217 numeric code to select the currency:

import {
  memory,
  input_ptr,
  input_utf8_cap,
  output_ptr,
  uniform_set_currency,
  render,
} from "/components/utf8/currency-format-en-us.wasm";

const encoder = new TextEncoder();
const decoder = new TextDecoder();

export function formatCurrency(amount, currency) {
  const input = new Uint8Array(
    memory.buffer,
    input_ptr(),
    input_utf8_cap(),
  );

  const { read, written } = encoder.encodeInto(amount, input);
  if (read !== amount.length) {
    throw new RangeError("amount exceeds input capacity");
  }

  uniform_set_currency(currency);
  const outputSize = render(written);
  return decoder.decode(
    new Uint8Array(memory.buffer, output_ptr(), outputSize),
  );
}

console.log(formatCurrency("1234567.895", 840));
// $1,234,567.90

Call uniform setters after writing the input and before render(). Direct imports generally share one instance, so set every value needed by each invocation instead of relying on state left by an earlier call. See Uniforms for setter types, returned values, and CLI mapping.

Capacity And UTF-8 Length #

encodeInto does not throw when its destination is too small. It returns two progress values:

The complete input fitted only when read === source.length. Pass written, not read, to render:

const { read, written } = encoder.encodeInto(source, input);
if (read !== source.length) {
  throw new RangeError("input exceeds component capacity");
}

const outputSize = render(written);

The distinction is invisible for ASCII but required for other text. JavaScript strings use UTF-16, while QIP input sizes count UTF-8 bytes. encodeInto only writes complete UTF-8 sequences, so a short destination does not leave a partial encoded character at the end.

QIP inputs are length-delimited. Do not append a zero terminator unless a particular non-QIP library interface explicitly requires one.

Call Order #

For a UTF-8 content component:

  1. Call input_ptr() and input_utf8_cap().
  2. Create a Uint8Array view over that input region.
  3. Encode into the view and verify that the full string was read.
  4. Call render(written).
  5. Call output_ptr() and read exactly the returned number of bytes.

Call output_ptr() after render(). A component may select its output region based on the current input. If render() traps, do not read the output buffer; it may contain stale or partial bytes.

The returned size and output pointer can be used directly because a valid QIP component guarantees that they describe output within its declared region.

JavaScript-Specific Nuances #

Binary Components #

For input_bytes_cap, the caller already has bytes, so copy them directly:

export function runBytes(bytes) {
  if (!(bytes instanceof Uint8Array)) {
    throw new TypeError("input must be a Uint8Array");
  }
  if (bytes.length > input_bytes_cap()) {
    throw new RangeError("input exceeds component capacity");
  }

  new Uint8Array(memory.buffer, input_ptr(), bytes.length).set(bytes);
  const outputSize = render(bytes.length);
  return new Uint8Array(memory.buffer, output_ptr(), outputSize).slice();
}

The final slice() owns its bytes independently of component memory. Without it, a later render could overwrite the returned view.

Traps And Reuse #

A WebAssembly trap appears as a JavaScript exception. A trap does not roll back memory or mutable globals, and the module instance can usually be called again. Write fresh input before retrying and assume scratch/output memory is dirty.

Explicit Instantiation #

When direct .wasm imports are unavailable, only the loading step changes:

const { instance } = await WebAssembly.instantiateStreaming(
  fetch("/components/utf8/e164.wasm"),
  {},
);

const {
  memory,
  input_ptr,
  input_utf8_cap,
  output_ptr,
  render,
} = instance.exports;

The server must send the module as Content-Type: application/wasm for instantiateStreaming. After destructuring the exports, use the same normalizeE164 wrapper shown above.

Native browser import support for .wasm is still less portable than the WebAssembly JavaScript API, and bundlers differ in how they expose Wasm exports. Use instantiateStreaming when direct imports are unavailable. A restrictive Content Security Policy may also need to permit WebAssembly compilation.