ブラウザSQLiteとOPFSで実現する"ローカルファースト"なWebアプリ開発

なぜ今、WebアプリでSQLiteなのか?
Webブラウザは日々進化し、今やOSに匹敵するアプリケーション実行環境となりつつあります。しかし、これまでのデータ永続化API(Web Storage、IndexedDBなど)は、複雑なクエリやトランザクション管理には不向きでした。ここに、WebAssembly版SQLiteとOrigin Private File System (OPFS)が加わることで、Web開発の常識が大きく変わります。この組み合わせにより、サーバー通信に依存しない、高速かつ信頼性の高い"ローカルファースト"なアプリケーションの構築が可能になります。
特に、OPFSはIndexedDB
とは一線を画します。IndexedDB
が非同期のオブジェクトストアであるのに対し、OPFSはファイルシステムAPIを提供します。これにより、SQLiteのようなリレーショナルデータベースがファイルとして直接データを読み書きでき、複雑なデータ構造やトランザクション処理、大量データの取り扱いにおいて圧倒的なパフォーマンスと柔軟性を発揮します。
この技術が真価を発揮するユースケースとして、完全オフライン対応のPWA
ノートアプリ、大規模なCSVファイルのクライアントサイド分析ツール、モバイルPOSシステムの一時データ管理などが挙げられます。
この記事でわかること
- WebAssembly版SQLiteとOPFSを組み合わせるメリット
Worker
とSharedArrayBuffer
を活用したスレッド間での同期的なデータベースアクセス- データベース利用に必須となる
COOP
/COEP
ヘッダーの設定方法 - 実践的なデモアプリケーションの構築と、ハマりやすいポイント
ブラウザSQLiteの基本とOPFSの活用
WebAssembly版SQLiteは、ブラウザ内で高速なSQLクエリ実行を可能にします。このデータベースのストレージとしてOPFSを利用します。OPFSはオリジンごとに隔離されたファイルシステムで、特にWorker
から同期的なファイルアクセスができる点が大きな特長です。この同期アクセスは、SQLiteのトランザクション管理に不可欠であり、データベース操作をWorker
スレッドに任せることで、メインスレッドのUI
をブロックすることなく、円滑なユーザー体験を提供します。
Worker
とSharedArrayBuffer
を活用した実装例
複数のスレッド(メインスレッド、他のWorker
)からデータベースにアクセスする際、SharedArrayBuffer
とAtomics
を使用することで、スレッド間でメモリを共有し、同期的な操作が可能になります。SQLiteのWasm版ライブラリは、この仕組みを利用してスレッドセーフなデータベースアクセスを実現します。
以下のコードは、Worker
でSQLiteをセットアップし、メインスレッドからコマンドを送信する基本的な構成です。
index.html
<!DOCTYPE html>
<html>
<head>
<title>SQLite OPFS Demo</title>
<style>pre { background-color: #f4f4f4; padding: 10px; border-radius: 5px; }</style>
<script type="module" src="main.js"></script>
</head>
<body>
<h1>ブラウザSQLite OPFSデモ</h1>
<p>コンソールと画面下の出力で動作を確認してください。</p>
</body>
</html>
main.js
// メインスレッド
const worker = new Worker('worker.js', { type: 'module' });
// OPFSのデータをブラウザに永続化するよう要求
async function requestPersistentStorage() {
if (navigator.storage && navigator.storage.persisted) {
const isPersisted = await navigator.storage.persisted();
if (!isPersisted) {
const result = await navigator.storage.persist();
console.log(`ストレージの永続化要求: ${result ? '成功' : '失敗'}`);
}
}
}
requestPersistentStorage();
worker.onmessage = (event) => {
const msg = event.data;
console.log('Workerから受信:', msg);
if (msg.type === 'env') {
console.log(`環境チェック: crossOriginIsolated=${msg.data.crossOriginIsolated}, OPFS利用可=${msg.data.opfs}`);
} else if (msg.type === 'status') {
console.log('情報:', msg.data);
} else if (msg.type === 'error') {
console.log('エラー:', msg.error);
} else if (msg.type === 'ready') {
// テーブル作成を指示
worker.postMessage({
type: 'exec',
sql: 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT);'
});
// データ作成を指示
worker.postMessage({
type: 'exec',
sql: 'INSERT INTO users (name) VALUES ("Alice"), ("Bob");'
});
// クエリー実行を指示
worker.postMessage({
type: 'query',
sql: 'SELECT * FROM users;'
});
} else if (msg.type === 'exec-done') {
console.log('実行完了:', msg.sql);
} else if (msg.type === 'result') {
document.body.insertAdjacentHTML('beforeend', `<h3>取得結果:</h3><pre>${JSON.stringify(msg.data, null, 2)}</pre>`);
// データベースのクローズを指示
worker.postMessage({ type: 'close' });
} else if (msg.type === 'closed') {
console.log('DBをクローズしました');
// DBをクローズしたらワーカーも終了させる
worker.terminate();
} else {
console.log('Worker:', msg);
}
};
// データベースの初期化を指示
worker.postMessage({ type: 'init' });
worker.js
// Workerスレッド
import sqlite3InitModule from '@sqlite.org/sqlite-wasm';
self.onmessage = async (event) => {
if (event.data.type === 'init') {
try {
const sqlite3 = await sqlite3InitModule({
print: console.log,
printErr: console.error
});
// OPFSが使える場合はOpfsDb、ダメならメモリ上にデータベースを一時作成
const db = sqlite3.oo1.OpfsDb
? new sqlite3.oo1.OpfsDb('/my.db')
: new sqlite3.oo1.DB();
self.db = db;
const usingOpfs = !!sqlite3.oo1.OpfsDb && (db instanceof sqlite3.oo1.OpfsDb);
self.postMessage({ type: 'env', data: { crossOriginIsolated, opfs: usingOpfs } });
self.postMessage({ type: 'status', data: usingOpfs ? 'OPFS で初期化しました' : 'OPFS なしで初期化しました' });
// 初期化完了を通知
self.postMessage({ type: 'ready' });
} catch (e) {
console.error('データベースの初期化に失敗:', e);
}
} else if (event.data.type === 'exec') {
if (!self.db) return;
try {
self.db.exec(event.data.sql);
self.postMessage({ type: 'exec-done', sql: event.data.sql });
} catch (e) {
self.postMessage({ type: 'error', error: String(e) });
}
} else if (event.data.type === 'query') {
if (!self.db) return;
try {
// returnValueオプションで結果を戻り値として受け取る
// returnValue: 'resultRows'は2022年10月に追加された正式なオプションです
const rows = self.db.exec({ sql: event.data.sql, rowMode: 'object', returnValue: 'resultRows' });
self.postMessage({ type: 'result', data: rows });
} catch (e) {
self.postMessage({ type: 'error', error: String(e) });
}
} else if (event.data.type === 'close') {
if (!self.db) return;
try {
self.db.close();
self.db = null;
self.postMessage({ type: 'closed' });
} catch (e) {
self.postMessage({ type: 'error', error: String(e) });
}
}
};
注意点: 本サンプルはライブラリのバージョンやAPIの変更により動作しない場合があります。実際の開発では、SQLite Wasm公式ドキュメントを参照してください。
COOP
とCOEP
ヘッダーの設定
SharedArrayBuffer
を利用するには、セキュリティ上の理由から、Webサーバーが以下のHTTPレスポンスヘッダーを返す必要があります。これらのヘッダーを設定しないと、SharedArrayBuffer
が無効となり、SQLiteの同期的なアクセスが正しく機能しません。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
これらのヘッダーにより、ページは"cross-origin isolated"状態となり、Spectreなどのサイドチャネル攻撃のリスクを軽減しつつ、SharedArrayBuffer
などの強力なAPIを利用可能にします。
ヘッダー設定例
Node.js (Express)
const express = require('express');
const app = express();
app.use((req, res, next) => {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
});
app.use(express.static('public'));
app.listen(3000);
Nginx
server {
listen 80;
server_name your-domain.com;
location / {
add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;
root /path/to/your/app;
}
}
トラブル時のチェックポイント
- 永続化とフォールバック: OPFSが利用できない場合、サンプルコードはインメモリデータベースにフォールバックします。このデータベースはブラウザをリロードするとデータが消えるため、永続化が必要な場合は
kvvfs
などの代替VFSを検討してください。また、navigator.storage.persist()
による永続化がブラウザによって拒否される場合があるため、その際は容量制限に注意し、定期的なデータのバックアップを検討しましょう。 - ESMの読み込み:
worker.js
内のimport
文はベアインポート(@sqlite.org/sqlite-wasm
のように相対パスではない)です。これは標準のHTTPサーバーでは解決できません。Viteのようなバンドラを利用するか、import map
をHTMLに記述して解決する必要があります。 - ヘッダーの設定: デベロッパーツールのネットワークタブで、ドキュメントのレスポンスヘッダーに
COOP
とCOEP
が正しく含まれているか確認しましょう。 - よくあるトラブル: サーバーの
COOP/COEP
ヘッダー設定漏れにより、SharedArrayBuffer is not defined
エラーが出るケースが非常に多いです。このエラーに遭遇したら、まずはサーバー設定を見直してください。また、COEP: require-corp
を設定した場合、埋め込む外部リソース側がCORS
かCORP
に対応していないと読み込めなくなる点にも注意が必要です。COEP: credentialless
を代替値として利用することも検討できます。 - Workerスクリプトの配信:
COEP
を有効にする場合、読み込むWorkerスクリプトも同一オリジンから配信するか、CORSやCORPヘッダーを適切に設定する必要があります。 - Workerでの同期API利用: メインスレッドではOPFSの同期APIは利用できません。これは、UIをブロックするような重いファイルI/Oを避けるためのブラウザの設計思想です。SQLiteのデータベース操作を
Worker
に任せることで、この制約を回避できます。 - OPFSの容量上限: OPFSの容量はブラウザごとの制限に依存します。大量データを扱う場合は、
StorageManager.estimate()
メソッドでストレージクォータを確認することも検討してください。 - COOP/COEP不要の代替VFS: 公式で提供されている
opfs-sahpool
VFSは、SharedArrayBuffer
を使用せず、FileSystemSyncAccessHandle
をWorker
スレッドプールで管理することで、COOP
/COEP
ヘッダーなしで動作します。要件によっては、こちらを採用することで実装の簡素化が可能です。 - デバッグのヒント: Chrome DevToolsのOPFS Explorer拡張機能を利用すると、ブラウザ上のOPFSに保存されたファイルを直接確認できます。
チェックリスト
- サーバーが
Cross-Origin-Opener-Policy: same-origin
ヘッダーを返しているか? - サーバーが
Cross-Origin-Embedder-Policy: require-corp
ヘッダーを返しているか? - データベース操作を
Worker
スレッド内で実行しているか? - デベロッパーツールのコンソールにエラーが出ていないか(特に
SharedArrayBuffer is not defined
)?
まとめ
WebAssembly版SQLiteとOPFSの組み合わせは、Webアプリ開発に"ローカルファースト"という新たな選択肢をもたらします。これにより、オフライン対応、高速なデータ処理、複雑なトランザクション管理が可能になり、PWA
などの次世代Webアプリケーションの可能性を大きく広げます。
この強力なツールを活用するには、Worker
とSharedArrayBuffer
の仕組みを理解し、COOP
/COEP
ヘッダーを正しく設定することが不可欠です。これらのセキュリティ要件をクリアすることで、Webアプリは従来の枠を超え、デスクトップアプリケーションに匹敵するパフォーマンスと機能性を手に入れることができます。
この記事が、あなたの次のWebプロジェクトをさらに豊かなものにする一助となれば幸いです。ご覧いただきありがとうございました。