- カテゴリー
- ブラウザDB・ストレージ
- 公開日
- 2026.09.22
Contents
はじめに
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 --versionとnpm --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」と表示されることを確認します。
データが残ることを確認する
- TODOを2件追加する
- 1件を完了にする
- ページを再読み込みする
- ブラウザを閉じて再度開く
- 同じオリジンとポートで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アプリを作れます。


コメント