by MintJams

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

Webアプリ開発の常識が変わる。WebAssembly版SQLiteとOPFSを組み合わせ、「ローカルファースト」なデータ永続化を実現する方法を、サンプルコードとトラブルシューティング付きで徹底解説します。

なぜ今、WebアプリでSQLiteなのか?

Webブラウザは日々進化し、今やOSに匹敵するアプリケーション実行環境となりつつあります。しかし、これまでのデータ永続化API(Web Storage、IndexedDBなど)は、複雑なクエリやトランザクション管理には不向きでした。ここに、WebAssembly版SQLiteOrigin Private File System (OPFS)が加わることで、Web開発の常識が大きく変わります。この組み合わせにより、サーバー通信に依存しない、高速かつ信頼性の高い"ローカルファースト"なアプリケーションの構築が可能になります。

特に、OPFSIndexedDBとは一線を画します。IndexedDBが非同期のオブジェクトストアであるのに対し、OPFSはファイルシステムAPIを提供します。これにより、SQLiteのようなリレーショナルデータベースがファイルとして直接データを読み書きでき、複雑なデータ構造やトランザクション処理、大量データの取り扱いにおいて圧倒的なパフォーマンスと柔軟性を発揮します。

この技術が真価を発揮するユースケースとして、完全オフライン対応のPWAノートアプリ、大規模なCSVファイルのクライアントサイド分析ツール、モバイルPOSシステムの一時データ管理などが挙げられます。


この記事でわかること

  • WebAssembly版SQLiteとOPFSを組み合わせるメリット
  • WorkerSharedArrayBufferを活用したスレッド間での同期的なデータベースアクセス
  • データベース利用に必須となるCOOP/COEPヘッダーの設定方法
  • 実践的なデモアプリケーションの構築と、ハマりやすいポイント

ブラウザSQLiteの基本とOPFSの活用

WebAssembly版SQLiteは、ブラウザ内で高速なSQLクエリ実行を可能にします。このデータベースのストレージとしてOPFSを利用します。OPFSはオリジンごとに隔離されたファイルシステムで、特にWorkerから同期的なファイルアクセスができる点が大きな特長です。この同期アクセスは、SQLiteのトランザクション管理に不可欠であり、データベース操作をWorkerスレッドに任せることで、メインスレッドのUIをブロックすることなく、円滑なユーザー体験を提供します。

WorkerSharedArrayBufferを活用した実装例

複数のスレッド(メインスレッド、他のWorker)からデータベースにアクセスする際、SharedArrayBufferAtomicsを使用することで、スレッド間でメモリを共有し、同期的な操作が可能になります。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公式ドキュメントを参照してください。


COOPCOEPヘッダーの設定

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に記述して解決する必要があります。
  • ヘッダーの設定: デベロッパーツールのネットワークタブで、ドキュメントのレスポンスヘッダーにCOOPCOEPが正しく含まれているか確認しましょう。
  • よくあるトラブル: サーバーのCOOP/COEPヘッダー設定漏れにより、SharedArrayBuffer is not definedエラーが出るケースが非常に多いです。このエラーに遭遇したら、まずはサーバー設定を見直してください。また、COEP: require-corpを設定した場合、埋め込む外部リソース側CORSCORPに対応していないと読み込めなくなる点にも注意が必要です。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を使用せず、FileSystemSyncAccessHandleWorkerスレッドプールで管理することで、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アプリケーションの可能性を大きく広げます。

この強力なツールを活用するには、WorkerSharedArrayBufferの仕組みを理解し、COOP/COEPヘッダーを正しく設定することが不可欠です。これらのセキュリティ要件をクリアすることで、Webアプリは従来の枠を超え、デスクトップアプリケーションに匹敵するパフォーマンスと機能性を手に入れることができます。

この記事が、あなたの次のWebプロジェクトをさらに豊かなものにする一助となれば幸いです。ご覧いただきありがとうございました。


参考資料