Vite Meets wasm

Build a Chess Game with Rust and WebAssembly

Two builds exist side by side now: cargo’s and Vite’s. This lesson makes one import statement — import 'chess-engine' — mean the right thing in dev, in production, and (two lessons early but deliberately) in tests. The payoff is an ASCII chessboard printed by Rust into a browser page. Checkpoint: lesson-05.

The alias trio

chess-engine/pkg/ is not an npm package in node_modules; it’s a build artifact sitting in the repo. Three configuration entries, in two files, make it importable by its bare name — and all three are needed:

// vite.config.ts
import wasm from 'vite-plugin-wasm';

export default defineConfig({
  plugins: [wasm()],
  build: { target: 'esnext' },
  resolve: {
    alias: {
      'chess-engine': path.resolve(import.meta.dirname, 'chess-engine/pkg/chess_engine.js')
    }
  },
  optimizeDeps: { exclude: ['chess-engine'] },
});
// tsconfig.json, inside compilerOptions
"paths": {
  "chess-engine": ["./chess-engine/pkg/chess_engine"]
}

The vite alias is the runtime: it points the bare specifier at the generated glue file. The tsconfig paths entry is the types: it points the same specifier at the generated .d.ts, so import type { ChessGame } resolves and your editor autocompletes the whole lesson-4 surface. They name the same thing in two type systems, which is why they travel together.

The third entry is the one everyone forgets. Vite pre-bundles dependencies with esbuild for speed, and esbuild would inline the glue file — breaking the new URL('chess_engine_bg.wasm', import.meta.url) reference it uses to locate the binary. optimizeDeps.exclude tells Vite to keep its hands off. build.target: 'esnext' rounds out the set, because the glue and vite-plugin-wasm produce module patterns older targets can’t express.

Initialization is explicit

A wasm module doesn’t just import; it must be fetched, compiled, and instantiated. The generated glue exports that step as the default export. Replace src/main.ts with a temporary smoke test:

// Temporary smoke test: prove the engine reaches the browser. The board
// component replaces all of this in the next lesson.
import init, { ChessGame } from 'chess-engine';

await init();
const game = new ChessGame(true);

console.log('Engine loaded. FEN:', game.get_fen());
document.body.insertAdjacentHTML('beforeend', `<pre>${game.get_ascii_board()}</pre>`);

init() is where the browser fetch()es the .wasm file. Everything before it resolves is a module that owns no memory; call a method too early and you get a hard-to-read error about null pointers. The top-level await compiles because of the esnext target you just set.

Run the dev server and there it is: shakmaty’s debug rendering of the starting position, in a <pre> tag, put there by Rust. Run npm run build and check the output listing — the .wasm appears as a hashed asset next to your JavaScript, which is the whole story of wasm in production: it’s a static file.

Where the seams are

Nothing was configured for tests yet, but notice what this setup already implies. The glue’s init() guards itself with an “already initialized?” check, and it has a synchronous sibling, initSync(). Lesson 11 will exploit that pair to run this exact component in Node, where fetch() doesn’t serve local files. Knowing the seam exists is enough for today.

Build it

File Action What goes in it
vite.config.ts modify The wasm plugin, build.target, the alias, the optimizeDeps exclusion
tsconfig.json modify The paths entry
package.json modify Add vite-plugin-wasm to devDependencies
src/main.ts write The smoke test above

Done when: the dev server shows an ASCII board and logs the starting FEN, and npm run build lists a chess_engine_bg-*.wasm asset.

Answer key: lesson-04...lesson-05.

Challenge: break it on purpose

Remove optimizeDeps.exclude, hard-refresh the dev server, and read the failure you get. Then restore it. Configuration you’ve watched fail is configuration you can debug at 11 pm; this one line produces one of the more confusing error messages in the pipeline.

Next lesson, pixels: a Lit component turns get_fen() into 64 squares, and the ASCII board retires.