Command Palette
Search for a command to run...

Generate a deck in the browser

Building an .apkg client side, from loading the sql.js WASM binary to handing the file to the user, plus what ankipack 0.3.0 and earlier do to a webpack or Vite build.

how-to · intermediate · 8m
applies to ankipack >= 0.3, anki 26.08
by Oliver Seifert & Claude ·

Generate a deck in the browser

ankipack has no filesystem dependency in its build path, so the whole library works client side. What changes is where sql.js finds its WASM binary, and what you do with the bytes once you have them.

Point sql.js at its WASM binary

ankipack never creates the sql.js instance, you do, and that is what puts you in charge of how its WASM binary loads. In Node sql.js finds the binary sitting next to itself. A browser has no filesystem to look in, so it asks you instead.

Copy sql-wasm.wasm out of node_modules/sql.js/dist/ into whatever your bundler serves as static assets, public/ in Vite, and hand back its URL:

import initSqlJs from "sql.js";const SQL = await initSqlJs({  locateFile: () => "/assets/sql-wasm.wasm", });
  1. Your own path, rather than the filename sql.js asked for. Which name it asks for changed in sql.js 1.14, so answering with a fixed URL is the version proof way to do it.

Nothing checks any of this while you build, so a wrong path compiles cleanly and 404s at runtime, which your browser's network panel will show you.

Serve the file yourself rather than pointing at a CDN. https://sql.js.org/dist/ carries no version in its URL, so what it hands back can drift away from the sql.js you installed.

Take bytes, not a file

writeToFile is Node and Bun only. In a browser use toUint8Array, which returns the same package contents in memory.

import initSqlJs from "sql.js";import { Deck, Note, Notetype, Package } from "ankipack";export async function buildDeck(): Promise<Uint8Array> {  const SQL = await initSqlJs({ locateFile: () => "/assets/sql-wasm.wasm" });  const my_notetype = Notetype.basic();  const my_deck = new Deck({ name: "My Deck" });  my_deck.addNote(new Note({ notetype: my_notetype, fields: ["front", "back"] }));  const pkg = new Package();  pkg.addDeck(my_deck);  return pkg.toUint8Array(SQL);}

Hand the file to the user

The bytes are yours to deliver. The ordinary route is an object URL and a synthetic anchor click, which is MDN's territory rather than ankipack's.

export function download(bytes: Uint8Array, filename: string): void {  const blob = new Blob([bytes as unknown as BlobPart], {    type: "application/octet-stream",  });  const url = URL.createObjectURL(blob);  const anchor = document.createElement("a");  anchor.href = url;  anchor.download = filename;  anchor.click();  URL.revokeObjectURL(url);}
Why the cast

toUint8Array is typed Promise<Uint8Array>, which in current TypeScript is Uint8Array<ArrayBufferLike>, while BlobPart wants an ArrayBuffer-backed view. The cast is a type-level annoyance with no runtime effect.

Do not name the file collection.apkg or anything matching backup-*.apkg. Anki's desktop importer treats both as collection packages, and your user is asked whether to replace their whole collection instead of being given your deck. The Package reference has the detail.

The bundler warning

writeToFile imports node:fs/promises dynamically. Bundlers resolve dynamic imports statically, so a browser build sees a Node module it can never load. From 0.3.1 the import carries webpackIgnore and @vite-ignore markers and both bundlers leave it alone. Nothing below applies once you upgrade.

On 0.3.0 and earlier, webpack with target: "web" fails the whole build with UnhandledSchemeError, and resolve.alias cannot match a node: request. The workaround redirects the request to an empty module:

webpack.config.js
js
new webpack.NormalModuleReplacementPlugin(  /^node:fs\/promises$/,  "data:text/javascript,export default {}",)

Vite on those versions builds, but warns:

Vite 8, production build
[plugin rolldown:vite-resolve] Module "node:fs/promises" has been externalized for browser compatibility, imported by ".../node_modules/ankipack/dist/package.js". See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.

That one is harmless. If your build treats warnings as errors, add a build.rollupOptions.onwarn handler that returns early when the message names node:fs/promises. That silences this warning without suppressing the whole class.

Browser extensions

Extensions add one constraint, and it is narrower than it looks. Manifest V3 defaults to script-src 'self', which blocks compiling WebAssembly but leaves fetching alone. So the binary downloads fine and then dies at WebAssembly.instantiate with a CompileError naming the policy.

Grant the extension pages permission to compile WASM in the manifest:

{  "content_security_policy": {    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"  }}

Ship the binaries in your extension assets and resolve them at runtime:

declare const chrome: { runtime: { getURL(path: string): string } };import initSqlJs from "sql.js";const SQL = await initSqlJs({  locateFile: () => chrome.runtime.getURL("sql/sql-wasm.wasm"),});

Bundling them rather than fetching from a CDN is the right call, but for a policy reason rather than a technical one: the Chrome Web Store does not allow remotely hosted code. web_accessible_resources is a separate matter, and is only needed if a content script or a web page has to reach the file, not for your own extension pages.

Did this work on your setup?

Not rated yet