SQLite WASMとOPFSでブラウザ内TODOアプリを作る

ブラウザでTODOを作る。SQLite WASMとOPFSを使うアプリ開発を深緑と黄緑の文字で示したアイキャッチ ブラウザDB・ストレージ
カテゴリー
ブラウザDB・ストレージ
公開日
2026.09.22

はじめに

Webアプリのデータを端末内へ保存するとき、IndexedDBを使う方法があります。しかし、既存のSQL知識やSQLite向けの設計を活用したい場合もあります。

SQLite公式のWebAssembly版を使うと、SQLiteエンジンをブラウザ内で実行できます。さらにOPFSをVFS(Virtual File System)として使うことで、データベースファイルをページ再読み込み後も残せます。

この記事では、バックエンドサーバーを使わず、次のTODOアプリを作ります。

  • TODOを追加する
  • 完了・未完了を切り替える
  • TODOを削除する
  • ページ再読み込み後もデータを復元する
  • SQLへ値を安全にバインドする
  • OPFSを利用できない場合は一時DBとして動かし、警告する

SQLite WASMの構成

今回のアプリは次の役割に分かれます。

メインスレッド
  ├─ 入力とTODO一覧を表示
  └─ Workerへ操作を依頼

Dedicated Worker
  ├─ SQLite WASMを初期化
  ├─ SQLを実行
  └─ OPFSへtodo.sqlite3を保存

SQLiteは同期的なファイル操作を前提にする部分があります。OPFSのFileSystemSyncAccessHandleはDedicated Workerで利用できるため、SQLite処理をWorkerへ置きます。

@sqlite.org/sqlite-wasmにある古いWorker1/Promiser1 APIは、公式ドキュメントで非推奨と案内されています。この記事ではそれらを使わず、ES Module Workerの中でSQLiteのOO1 APIを直接呼びます。

COOPとCOEPが必要な理由

SQLite公式npmパッケージの標準opfs VFSをWorkerで利用する構成では、次のレスポンスヘッダーが必要です。

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

これらを正しく設定すると、ページはCross-Origin Isolatedな状態になります。外部画像、フォント、スクリプトなどにも追加の制約が生じるため、本番サイトへ設定する前に既存リソースへの影響を確認してください。

プロジェクトを作成する

HTMLとJavaScriptの基本を知っていて、端末内にデータを保存するアプリを初めて作る方向けです。Node.js 22.12以上の22系、または24系と、それに付属するnpmを用意します。ターミナルでnode --versionnpm --versionを実行できることを確認してください。ブラウザはまず通常モードのChromeで試します。

以下をターミナルで1行ずつ実行します。Viteは開発用サーバーで、利用者のTODOをサーバーへ保存するものではありません。

mkdir sqlite-opfs-todo
cd sqlite-opfs-todo
npm init -y
npm install @sqlite.org/sqlite-wasm@3.53.0-build1
npm install --save-dev vite@7.3.6

次の4ファイルを作成します。

sqlite-opfs-todo/
├─ index.html
├─ main.js
├─ database.worker.js
└─ vite.config.js

Viteへヘッダーを設定する

vite.config.jsへ次を記述します。

import { defineConfig } from "vite";

export default defineConfig({
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
  optimizeDeps: {
    exclude: ["@sqlite.org/sqlite-wasm"],
  },
});

SQLite WASMパッケージをViteの依存関係事前バンドルから除外する設定も、公式READMEに従っています。

HTMLを作成する

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>SQLite WASM TODO</title>
    <style>
      body { width: min(720px, calc(100% - 32px)); margin: 40px auto; font-family: system-ui; }
      form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
      #title { flex: 1 1 180px; min-width: 0; padding: 8px; }
      li { display: flex; gap: 8px; align-items: center; margin-block: 10px; }
      li span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
      .completed { text-decoration: line-through; color: #64748b; }
    </style>
  </head>
  <body>
    <main>
      <h1>SQLite WASM TODO</h1>
      <form id="todo-form">
        <label for="title">新しいTODO</label>
        <input id="title" maxlength="100" required />
        <button type="submit">追加</button>
      </form>
      <p id="storage"></p>
      <p id="status" role="status" aria-live="polite">SQLiteを初期化しています。</p>
      <ul id="todo-list"></ul>
    </main>
    <script type="module" src="/main.js"></script>
  </body>
</html>

WorkerへSQLite処理を実装する

database.worker.jsへ次を記述します。

import sqlite3InitModule from "@sqlite.org/sqlite-wasm";

let db;
let storageType = "memory";

const sqliteReady = sqlite3InitModule({
  print: (...args) => console.log(...args),
  printErr: (...args) => console.error(...args),
}).then((sqlite3) => {
  if (typeof sqlite3.oo1.OpfsDb === "function") {
    db = new sqlite3.oo1.OpfsDb("/todo.sqlite3", "c");
    storageType = "opfs";
  } else {
    db = new sqlite3.oo1.DB("/todo.sqlite3", "ct");
  }

  db.exec(`
    CREATE TABLE IF NOT EXISTS todos (
      id INTEGER PRIMARY KEY,
      title TEXT NOT NULL CHECK(length(title) BETWEEN 1 AND 100),
      completed INTEGER NOT NULL DEFAULT 0 CHECK(completed IN (0, 1)),
      created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
    )
  `);

  return sqlite3;
});

function listTodos() {
  return db.exec({
    sql: `
      SELECT id, title, completed, created_at
      FROM todos
      ORDER BY id DESC
    `,
    rowMode: "object",
    returnValue: "resultRows",
  });
}

function addTodo(title) {
  const normalized = String(title ?? "").trim();
  if (!normalized || normalized.length > 100) {
    throw new Error("TODOは1文字以上100文字以内で入力してください。");
  }

  db.exec({
    sql: "INSERT INTO todos(title) VALUES (?)",
    bind: [normalized],
  });
}

function toggleTodo(id) {
  if (!Number.isSafeInteger(id) || id <= 0) throw new Error("IDが不正です。");
  db.exec({
    sql: `
      UPDATE todos
      SET completed = CASE completed WHEN 0 THEN 1 ELSE 0 END
      WHERE id = ?
    `,
    bind: [id],
  });
}

function deleteTodo(id) {
  if (!Number.isSafeInteger(id) || id <= 0) throw new Error("IDが不正です。");
  db.exec({ sql: "DELETE FROM todos WHERE id = ?", bind: [id] });
}

self.addEventListener("message", async (event) => {
  const { requestId, command, payload } = event.data ?? {};

  try {
    await sqliteReady;

    if (command === "add") addTodo(payload?.title);
    else if (command === "toggle") toggleTodo(payload?.id);
    else if (command === "delete") deleteTodo(payload?.id);
    else if (command !== "list") throw new Error("未対応の操作です。");

    self.postMessage({
      requestId,
      ok: true,
      todos: listTodos(),
      storageType,
    });
  } catch (error) {
    self.postMessage({
      requestId,
      ok: false,
      error: error instanceof Error ? error.message : String(error),
    });
  }
});

SQL文字列へ利用者の入力を連結せず、?bindを使っています。

OPFSの利用可否はsqlite3.oo1.OpfsDbがインストールされたかで判定します。パッケージ同梱の型定義でも、OpfsDbまたはsqlite3_vfs_find("opfs")を確認する方法が案内されています。内部用のsqlite3.opfs名前空間は初期化後に削除される構成があるため、アプリ側の判定には使いません。

一時DBへ切り替えるのはOpfsDbがない場合だけです。利用可能なOPFSのDBを開く際にエラーが起きた場合は、エラーとして表示します。既存データが見えない問題を、一時DBへの切り替えで隠さないためです。

db.exec({
  sql: "INSERT INTO todos(title) VALUES (?)",
  bind: [normalized],
});

これにより、引用符を含むTODOでもSQL構文と入力値を分離できます。

メインスレッドを実装する

main.jsへ次を記述します。

const worker = new Worker(new URL("./database.worker.js", import.meta.url), {
  type: "module",
});

const form = document.querySelector("#todo-form");
const titleInput = document.querySelector("#title");
const list = document.querySelector("#todo-list");
const status = document.querySelector("#status");
const storage = document.querySelector("#storage");

let nextRequestId = 1;
const pending = new Map();
let workerFailure;

worker.addEventListener("error", () => {
  workerFailure = new Error("Workerの読み込みまたは実行に失敗しました。Consoleを確認してください。");
  for (const handler of pending.values()) handler.reject(workerFailure);
  pending.clear();
  status.textContent = workerFailure.message;
});

worker.addEventListener("message", (event) => {
  const handler = pending.get(event.data.requestId);
  if (!handler) return;
  pending.delete(event.data.requestId);
  event.data.ok ? handler.resolve(event.data) : handler.reject(new Error(event.data.error));
});

function request(command, payload = {}) {
  if (workerFailure) return Promise.reject(workerFailure);
  const requestId = nextRequestId++;
  return new Promise((resolve, reject) => {
    pending.set(requestId, { resolve, reject });
    worker.postMessage({ requestId, command, payload });
  });
}

function renderTodos(todos) {
  list.replaceChildren();

  for (const todo of todos) {
    const li = document.createElement("li");
    const checkbox = document.createElement("input");
    const text = document.createElement("span");
    const deleteButton = document.createElement("button");

    checkbox.type = "checkbox";
    checkbox.checked = Boolean(todo.completed);
    checkbox.setAttribute("aria-label", `${todo.title}の完了状態`);
    text.textContent = todo.title;
    text.className = todo.completed ? "completed" : "";
    deleteButton.type = "button";
    deleteButton.textContent = "削除";

    checkbox.addEventListener("change", () => run("toggle", { id: Number(todo.id) }));
    deleteButton.addEventListener("click", () => run("delete", { id: Number(todo.id) }));

    li.append(checkbox, text, deleteButton);
    list.append(li);
  }
}

async function run(command, payload) {
  status.textContent = "処理しています。";
  try {
    const result = await request(command, payload);
    renderTodos(result.todos);
    storage.textContent = result.storageType === "opfs"
      ? "保存先: OPFS(ページを閉じても残ります)"
      : "保存先: メモリ(一時保存です)";
    status.textContent = "更新しました。";
    return true;
  } catch (error) {
    console.error(error);
    status.textContent = `処理に失敗しました: ${error.message}`;
    return false;
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const title = titleInput.value.trim();
  if (!title) {
    status.textContent = "TODOを入力してください。";
    return;
  }
  if (await run("add", { title })) titleInput.value = "";
  titleInput.focus();
});

await run("list");

起動する

npx vite --host 127.0.0.1

ターミナルに表示されたLocalのURL(通常はhttp://127.0.0.1:5173/)をブラウザで開きます。HTMLファイルを直接ダブルクリックする方法では動きません。サーバーのターミナルは開いたままにし、終了するときはCtrl+Cを押します。ポートが使用中なら別の番号が表示されるので、実際のURLを使ってください。

開発者ツールのConsoleで次を確認します。

crossOriginIsolated

trueになり、画面に「保存先: OPFS」と表示されることを確認します。

データが残ることを確認する

  1. TODOを2件追加する
  2. 1件を完了にする
  3. ページを再読み込みする
  4. ブラウザを閉じて再度開く
  5. 同じオリジンとポートでTODOが復元されることを確認する

別ポートで起動すると別オリジンになり、別のOPFSを参照します。

動作確認

正常系

  • 日本語や引用符を含むTODOを追加できる
  • 完了状態を切り替えられる
  • TODOを削除できる
  • 再読み込み後にデータが残る
  • SQL入力値がバインドされる

エラー系

  • 空文字を追加しない
  • 100文字を超える値をHTMLとDB制約の両方で拒否する
  • 不正なIDをWorker側で拒否する
  • OPFSが使えない場合はメモリDBであることを明示する
  • 不明なコマンドを拒否する

よくあるトラブル

opfsが有効にならない

ViteのレスポンスヘッダーとcrossOriginIsolatedを確認します。また、WorkerでSQLiteを初期化しているか、sqlite3.oo1.OpfsDbが関数として存在するかを確認してください。メインスレッド用構成ではOPFS VFSを利用できません。

外部画像やCDNスクリプトが読み込めなくなった

COEPを有効にすると、クロスオリジンリソースにも適切なCORPまたはCORS設定が必要です。SQLiteのためにサイト全体へヘッダーを追加する前に、既存の外部リソースを洗い出してください。

2つのタブから同時に更新したい

SQLiteのロックとOPFS VFSの並行処理設計が必要です。SQLITE_BUSYを想定した再試行や、1タブを代表接続にする方法を検討します。次の記事でVFSごとの違いを扱います。

制約

OPFSのデータはサイトデータ削除の影響を受けます。重要データではDBのエクスポート、サーバー同期、バックアップを用意します。

保存できる容量は端末の空き容量やブラウザの割り当てに依存し、無制限ではありません。容量不足などで保存に失敗した場合は、画面のエラーを確認し、追加が成功するまで入力を残すようにしています。

また、ブラウザ内SQLiteはサーバー上の共有DBの代わりではありません。複数利用者の共同編集や中央集約が必要なら、別の同期層が必要です。

関連記事

まとめ

SQLite WASMとOPFSを組み合わせると、SQLデータベースをブラウザ内へ永続化できます。

SQLite処理をDedicated Workerへ置き、COOP/COEPを設定し、入力値をバインドすることで、再読み込み後もデータが残るTODOアプリを作れます。

参考リンク

コメント

タイトルとURLをコピーしました