Run in the browser
Load the WASM engine in a Web Worker, run expectGeo on bytes the page already holds, and serve the module.
The spec code that runs under Node runs in a browser tab unchanged; only the engine differs. In a browser geospec-engine resolves to the WASM engine, which the demos on this site run in a worker, and geospec/model and expectGeo run on top of it. The page below was built with Vite 8 and run in headless Chromium.
Python has no browser form: the wheel runs the engine inside CPython, and the TypeScript worker on this page is the way to run a spec in a tab.
1. Load the engine in a worker
The module is 16.7 MB and compiles once; admission and evaluation run on the thread that calls them. In a worker, none of that blocks input handling. Initialize once per worker and keep the loader.
import { expectGeo } from 'geospec';
import { GeoSpecAssertionError } from 'geospec/assertion-client';
import { createModelLoader } from 'geospec/model';
import { Engine, initialize } from 'geospec-engine';
const ready = initialize().then(() => createModelLoader({ engine: new Engine() }));
self.addEventListener('message', async ({ data }: MessageEvent<ArrayBuffer>) => {
const loadModel = await ready;
const plate = await loadModel({ source: new Uint8Array(data), format: 'step' });
try {
const { report } = expectGeo(plate).toHaveCircularHole({ diameter: 10 });
self.postMessage(`${report?.claimId} ${report?.status}`);
} catch (error) {
if (!(error instanceof GeoSpecAssertionError)) throw error;
self.postMessage(`${error.report.claimId} ${error.report.status}`);
}
});loadModel takes the bytes themselves, so it needs no source reader. A failed or refused claim throws, so the worker catches Geo and posts the report's status either way. Bytes cross postMessage; the engine never does.
2. Send the model from the page
Only the model's source differs: a fetch, a file input or a buffer the page already holds. Transfer the buffer rather than copy it.
const worker = new Worker(new URL('./engine.worker.ts', import.meta.url), { type: 'module' });
const out = document.querySelector('#out')!;
worker.addEventListener('message', ({ data }) => {
out.textContent = data;
});
const response = await fetch('/plate-with-through-bore.step');
const model = await response.arrayBuffer();
worker.postMessage(model, [model]);The page prints geospec-claim-1 passed after one fetch of the module. The same claim runs here:
const part = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });Tab indents. Press Escape, then Tab, to leave the editor.
The spec as a file, with the model loaded over the native addon
import { readFile } from 'node:fs/promises';import { expectGeo } from 'geospec';import { createModelLoader } from 'geospec/model';import { Engine } from 'geospec-engine/node';const engine = new Engine();const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)) });const part = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' });expectGeo(part) .toHaveCircularHole({ diameter: 10 });await loadModel.dispose();engine.close();plate-with-through-bore.step · 19 KB · copied from an engine test fixture
Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.
3. Serve the module
initialize() with no argument loads the .wasm file from beside the package's own script, through new URL(. Vite follows that reference and emits the file as a hashed asset, so the worker above needs no configuration; initialize(url) names the file yourself for a bundler or a server that places it elsewhere. Vite reports the node: imports that the engine and geospec/model keep for Node as externalized for browser compatibility; the page runs without them. Install covers the engine entry points.
MIME type. The module compiles with Web, which needs Content-Type: application/wasm. Served as anything else, Chromium logs wasm streaming compile failed: TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'. and then falling back to ArrayBuffer instantiation, fetches the 16.7 MB file a second time, and prints the same verdict.
Caching. The asset's name carries a content hash, so serve it with Cache-Control: public, max-age=31536000, immutable. Compressed, the module is 5.8 MB as gzip and 4.1 MB as Brotli.
Variations
Cross-origin isolation. The page needs no COOP or COEP headers: the run above was served with neither.
Node and browser from one codebase. The worker's spec runs unchanged under Node, where initialize() takes about 90 ms; for the native addon, import Engine from geospec-engine/node and drop initialize(). Install covers the two engines.
Stopping a run. A WebAssembly call cannot be interrupted: terminate() the worker and start a fresh one. Give each chain a workUnitLimit, so a runaway claim refuses with MATCHER_TIMEOUT instead of holding the worker; Set tolerances and budgets covers budgets.