@farscrl/hunspell-wasm
v1.0.1
Published
WebAssembly bindings for the Hunspell spellchecker, with a build pipeline included
Readme
Hunspell compiled to WebAssembly
@farscrl/hunspell-wasm is a TypeScript API over Hunspell
spellchecking, compiled to WebAssembly. Building the wasm binary, the JS bindings, tests, and
publishing all live in this one repository — there's no separate build repo and no runtime
download step.
import { loadModule } from '@farscrl/hunspell-wasm';
import { readFileSync } from 'node:fs';
const factory = await loadModule();
const affPath = factory.mountBuffer(readFileSync('en_US.aff'), 'en_US.aff');
const dicPath = factory.mountBuffer(readFileSync('en_US.dic'), 'en_US.dic');
const hunspell = factory.create(affPath, dicPath);
hunspell.spell('hello'); // true
hunspell.suggest('helo'); // ['hello', ...]
hunspell.dispose();import { HUNSPELL_VERSION } from '@farscrl/hunspell-wasm';
// '1.7.3' — whatever Hunspell version is compiled into this package buildWorks the same from CJS (require('@farscrl/hunspell-wasm')) and ESM (import ... from '@farscrl/hunspell-wasm'),
and bundles cleanly under strict exports-map resolution (Vite, Webpack 5, Rollup, esbuild,
Turbopack) — see test/bundlers for runnable proof of each.
API
loadModule(): Promise<HunspellFactory>
Loads the wasm module and returns a factory bound to it.
HunspellFactory
mountBuffer(buffer: Uint8Array, fileName: string): string— writesbufferinto the wasm virtual filesystem and returns the path it was mounted at (pass this tocreate/addDictionary).unmount(mountedPath: string): void— removes a previously mounted file.create(affPath: string, dictPath: string): HunspellInstance— creates a hunspell instance from a mounted.affand.dicpath.
HunspellInstance
spell(word: string): boolean— whetherwordis spelled correctly.suggest(word: string): string[]— spelling suggestions forword, best-first.stem(word: string): string[]— morphological stems forword(affix-stripped root forms).addWord(word: string): void— adds a word to the in-memory dictionary.addWordWithAffix(word: string, example: string): void— adds a word, taking its affix flags fromexample(an existing dictionary entry).removeWord(word: string): void— removes a word from the in-memory dictionary.addDictionary(dictPath: string): boolean— merges an additional mounted.dicfile into this instance; returnsfalseon failure.dispose(): void— frees the underlying wasm-side hunspell instance. The instance is unusable afterwards.
HUNSPELL_VERSION: string
The Hunspell version compiled into this package build (e.g. '1.7.3').
Why
Prior art in this space (hunspell-asm /
docker-hunspell-wasm) splits the native build
and the JS wrapper across two repos, downloads the compiled binary over wget at npm install
time, and resolves the node/browser variant via the package.json browser field, which
strict-ESM bundlers don't reliably honor. This repo keeps the native build and JS wrapper together,
ships the wasm binary inside the npm package itself, and compiles one universal build
(ENVIRONMENT=web,node) that serves both runtimes instead of swapping between two.
Repository layout
native/ Hunspell → wasm build (Docker + Emscripten)
vendor/hunspell/ git submodule, pinned to a tagged release
Dockerfile FROM emscripten/emsdk:<pinned>
build.sh final em++ link step (flags documented inline)
src/ TypeScript wrapper
lib/hunspell.mjs generated by `pnpm build:wasm` (gitignored) — isomorphic web+node build
lib/hunspell.web.mjs generated by `pnpm build:wasm` (gitignored) — browser-only build, no Node-only code at all
lib/hunspell.d.mts hand-written types shared by both generated glue files (checked in)
hunspellVersion.ts generated by `pnpm generate:version` from configure.ac (gitignored)
test/
integration/ runs against the real compiled wasm module
bundlers/ Vite / Turbopack / Node-ESM / Node-CJS / browser-esbuild smoke tests — the regression guard for interop
fixtures/ small hand-written .aff/.dic, not full language dictionariesThe wasm glue is built twice — once with -s ENVIRONMENT=web,node (Emscripten's own runtime
detection picks the right code path in either environment) and once with just -s
ENVIRONMENT=web. src/loadModule.ts loads it through the #hunspell-glue entry in
package.json's imports field, which
resolves to the browser-only build under the "browser" condition (honored by webpack, esbuild
platform: 'browser', and other browser-targeting bundlers) and to the isomorphic build
otherwise. This exists because the isomorphic build's runtime-guarded import("node:module")/
require("node:fs") branches (never reached in a browser) still get resolved statically by
bundlers that build for the browser, which hard-errors since those are Node builtins with no
browser equivalent — see #17.
Building
pnpm install
pnpm run build:wasm # compiles native/vendor/hunspell → src/lib/hunspell.mjs (Docker)
pnpm build # bundles src/ → dist/ (CJS + ESM + .d.ts) and copies the wasm glue in
pnpm test # unit + integration tests against the real wasm modulepnpm run build:wasm only needs to be re-run when native/ changes; CI caches its output keyed
on the pinned submodule commit, so everyday JS/TS work never has to rebuild it.
Bundler smoke tests
pnpm build # the fixtures import the real dist/ output, not src/ directly
node test/bundlers/node-esm/check.mjs
node test/bundlers/node-cjs/check.cjs
pnpm --filter bundler-smoke-vite run check
pnpm --filter bundler-smoke-turbopack run check
pnpm --filter bundler-smoke-browser-esbuild run checkNot part of pnpm test — these run the actual built package through Node's ESM loader, Node's
CJS require, a real Vite/Rollup bundling pass (ssr.noExternal: true, so it's forced to bundle
rather than just resolve the import), a Next.js production build/serve on Turbopack, and a raw
esbuild bundle pass targeting the browser — the direct regression guard for the strict-ESM
interop bug this project exists to fix. The Turbopack fixture guards against
emscripten-core/emscripten#26134,
where a combined web,node Emscripten build can trip up bundlers that statically analyze the
generated glue's guarded require("node:fs")-style calls. The browser-esbuild fixture guards
against the browser-target variant of the same class of bug
(#17): unlike the Vite/Turbopack fixtures,
which build for a Node-capable target, it bundles with platform: 'browser' — the same mode
Angular's esbuild- and webpack-based builders use — and asserts the output references no node:
builtins at all. CI runs these same commands as its own bundler-smoke job.
Updating the Hunspell version
cd native/vendor/hunspell
git fetch --tags
git checkout <tag>
cd ../../..
git add native/vendor/hunspellHUNSPELL_VERSION (and test/integration's check that it's well-formed) picks up the new version
automatically on the next build — no separate bump needed. Hunspell's C API has been stable across
every release so far, so bumping the submodule is normally just a patch release of this package via
a changeset, not something that needs its own version matrix.
Releasing
Versioning and changelogs are managed with Changesets
— every release traces back to a file committed in a PR, not a manual npm publish. Publishing
never happens from a local machine, only from CI.
To release a new version:
- In your PR, run
pnpm changeset. It asks for a bump type (patch/minor/major) and a one-line summary, then writes a file into.changeset/— commit it along with your code change. - Merge the PR to
mainas normal. Nothing else to do here. - CI (
release.yml) notices the pending changeset and opens (or updates) a "Version Packages" PR on its own — this bumpspackage.json's version, writesCHANGELOG.md, and removes the consumed changeset file. If multiple PRs land changesets before this one is merged, they all accumulate into the same "Version Packages" PR. - Review and merge the "Version Packages" PR when you're ready to actually ship. This merge is
what triggers the real release: CI rebuilds the package fresh and runs
changeset publish, publishing to npm with provenance.
So day-to-day, releasing is: write a changeset in your PR, and later merge the bot's PR when
you want those changes to go out. No manual version bump, changelog edit, or npm publish step.
License
MIT
