パントレの管理人室

ServiceWorker を用いて PMTiles をキャッシュする

page_eyecatch

 PMTiles とは、タイル化されたデータ(ラスタ/ベクトル含む)をサーバレス/単一ファイルで配信できるファイル形式です。従来のマップタイルと遜色なく、高速に動作します。MapLibre 等を用いて、ブラウザ上で表示することが可能です。

 この PMTiles を ServiceWorker を用いてキャッシュすることを考えます。目的はキャッシュすることによって Web 地図をオフラインでも使用できるようにすることです。なお、ここで使用する PMTiles は Protomaps の全世界ズームレベル 15 の PMTiles とします(130 GB, 参考)。

 結論から言うと、具体的な ServiceWorker のコードの例は以下の通りです。当サイトのトップページにも実装しているので、詳しくはそちらをご参照ください。

const PMTILES_CACHE = 'pantre-pmtiles-v1';

const MESH_LIMIT = 10;
const PMTILES_MAX_BYTES = 50 * 1024 * 1024;

self.addEventListener('fetch', event => {
    const req = event.request;
    const pathname = url.pathname;

    if (pathname.endsWith('.pmtiles')) {
        event.respondWith(cachePmtiles(req));
        return;
    }
});

async function cachePmtiles(request) {
    const cache = await caches.open(PMTILES_CACHE);
    const rangeHeader = request.headers.get('Range');
    const baseUrl = request.url.split('?')[0];
    const cacheKey = rangeHeader
        ? `${baseUrl}::${rangeHeader.replace('bytes=', '')}`
        : baseUrl;

    const cached = await cache.match(cacheKey);
    if (cached) return cached;

    const res = await safeFetch(request);
    if (!res) {
        return new Response('PMTiles fetch failed', { status: 503 });
    }

    let bodyBuffer;
    try {
        bodyBuffer = await res.arrayBuffer();
    } catch (e) {
        return new Response('PMTiles read failed', { status: 503 });
    }

    const isPartial = res.status === 206;
    const cacheResponse = new Response(bodyBuffer, {
        status: 200,
        headers: {
            'Content-Type': res.headers.get('Content-Type') || 'application/octet-stream',
            'Content-Length': String(bodyBuffer.byteLength),
            'X-SW-Range': rangeHeader || '',
        }
    });

    (async () => {
        try {
            await cache.put(cacheKey, cacheResponse.clone());
            await enforcePmtilesLimit(cache, PMTILES_MAX_BYTES);
        } catch (e) {
            console.warn('[SW] PMTiles cache write failed:', e);
        }
    })();

    return new Response(bodyBuffer, {
        status: isPartial ? 206 : 200,
        statusText: isPartial ? 'Partial Content' : 'OK',
        headers: res.headers,
    });
}

async function enforcePmtilesLimit(cache, maxBytes) {
    const keys = await cache.keys();
    if (keys.length === 0) return;

    let totalBytes = 0;
    const entries = [];

    for (const req of keys) {
        const res = await cache.match(req);
        if (!res) continue;
        const cl = res.headers.get('Content-Length');
        const size = cl ? parseInt(cl, 10) : (await res.arrayBuffer()).byteLength;
        entries.push({ req, size });
        totalBytes += size;
    }

    let i = 0;
    while (totalBytes > maxBytes && i < entries.length) {
        totalBytes -= entries[i].size;
        await cache.delete(entries[i].req);
        i++;
    }
}

async function safeFetch(request) {
    try {
        return await fetch(request);
    } catch (e) {
        return null;
    }
}

 色んな LLM にも相談して格闘しましたが、結局 Claude Code に作ってもらいました。ポイントは以下の通りです。

① 206 → 200 に変換して保存する

 ブラウザ Cache API は 206 を保存できない。そこで以下のような実装をする。

  • fetch で 206 を受け取る
  • arrayBuffer に吸い上げる
  • 200 OK の Response に再構成して cache.put()

 これにより Cache API の制限を回避している。
 

② Range をキャッシュキーに含める

const cacheKey = `${baseUrl}::${rangeHeader.replace('bytes=', '')}`;

 PMTiles は内部的に「巨大ファイルの中の特定バイト範囲」を要求するので、Range をキーに含めないとキャッシュが成立しない。
 

③ Content-Length を自前で付与

'Content-Length': String(bodyBuffer.byteLength)

 これがあるから enforcePmtilesLimit() が正しく動く。
 

④ LRU 風の削除ロジック

while (totalBytes > maxBytes) {
    await cache.delete(entries[i].req);
    i++;
}

 PMTiles の Range キャッシュは無限に増えるので、この制御がないとブラウザが SW を殺す。ここではキャッシュサーバーの最大保存容量を 50 MB にしている。
 

ちょっと宣伝

 当サイトは地図を使った海外旅ブログまとめサイトとなっています。トップページに地図がありますが、地名を押すと画像が開き、画像を押すと関連記事一覧(クリック数順)が開きます。記事数が多い国ほど赤く、地名もその国で記事が多い都市の文字が大きくなるようにしています。ぜひ覗いてみてください。
 当サイトで海外旅ブログを執筆することも可能です(無料)! また既にブログ/ YouTube をお持ちの方も、当サイトからリンクを貼ることができるようになっています。気になる方は「当サイトについて」をご覧ください。

パントレ管理部