web


git clone https://radroots.dev/git/web.git
Log | Files | Refs | Submodules | README | LICENSE

service-worker.js (6230B)


      1 import { build, files, prerendered, version } from "$service-worker";
      2 import { _env } from "$lib/_env";
      3 import { DEFAULT_SQL_WASM_PATH } from "@radroots/client/sql/constants";
      4 import { DEFAULT_GEOCODER_DATABASE_PATH } from "@radroots/geocoder/constants";
      5 import { RADROOTS_ASSET_CACHE_NAME, RADROOTS_ASSET_CACHE_PREFIX } from "@radroots/utils";
      6 
      7 const APP_SHELL_URL = new URL(self.registration.scope).pathname;
      8 const normalize_env_path = (value) =>
      9     typeof value === "string" && value.trim().length ? value.trim() : undefined;
     10 const parse_env_path = (route_path) => {
     11     const path_end = route_path.search(/[?#]/u);
     12     return path_end >= 0 ? route_path.slice(0, path_end) : route_path;
     13 };
     14 const ensure_env_wasm_path = (value, env_name) => {
     15     const path = parse_env_path(value);
     16     const normalized = path.toLowerCase();
     17     if (!normalized || normalized.endsWith("/"))
     18         throw new Error(`${env_name} must include a .wasm filename`);
     19     if (!normalized.endsWith(".wasm"))
     20         throw new Error(`${env_name} must end with .wasm`);
     21     return value;
     22 };
     23 const ensure_env_asset_path = (value, env_name) => {
     24     const path = parse_env_path(value);
     25     if (!path || path.endsWith("/"))
     26         throw new Error(`${env_name} must include a file path`);
     27     return value;
     28 };
     29 const SQL_WASM_ENV = normalize_env_path(_env.SQL_WASM_URL);
     30 const SQL_WASM_URL = SQL_WASM_ENV
     31     ? ensure_env_wasm_path(
     32         SQL_WASM_ENV,
     33         "RADROOTS_WEB_SQL_WASM_URL",
     34     )
     35     : DEFAULT_SQL_WASM_PATH;
     36 const GEOCODER_DB_ENV = normalize_env_path(_env.GEOCODER_DB_URL);
     37 const GEOCODER_DB_URL = GEOCODER_DB_ENV
     38     ? ensure_env_asset_path(GEOCODER_DB_ENV, "RADROOTS_WEB_GEOCODER_DB_URL")
     39     : DEFAULT_GEOCODER_DATABASE_PATH;
     40 const ASSET_URLS = [...new Set([SQL_WASM_URL, GEOCODER_DB_URL])];
     41 const PRECACHE_URLS = [...new Set([...build, ...files, ...prerendered, APP_SHELL_URL])].filter(
     42     (url) => !url.includes("/.")
     43 );
     44 const PRECACHE_LIST = PRECACHE_URLS.map((url) => ({
     45     url,
     46     revision: version
     47 }));
     48 const APP_CACHE = `cache-app-shell-v${version}`;
     49 const APP_CACHE_PREFIX = "cache-app-shell-v";
     50 const ASSET_CACHE = RADROOTS_ASSET_CACHE_NAME;
     51 const ASSET_CACHE_PREFIX = RADROOTS_ASSET_CACHE_PREFIX;
     52 
     53 const normalize_asset_url = (url) => {
     54     const resolved = new URL(url, self.location.origin);
     55     resolved.search = "";
     56     resolved.hash = "";
     57     return resolved.href;
     58 };
     59 const ASSET_URL_KEYS = new Set(ASSET_URLS.map((url) => normalize_asset_url(url)));
     60 const ASSET_URLS_ABS = ASSET_URLS.map((url) => new URL(url, self.location.origin).href);
     61 
     62 const cache_assets = async () => {
     63     const cache = await caches.open(ASSET_CACHE);
     64     await Promise.all(
     65         ASSET_URLS_ABS.map(async (url) => {
     66             const cached = await cache.match(url);
     67             if (cached) return;
     68             try {
     69                 const response = await fetch(url);
     70                 if (response.ok || response.type === "opaque") await cache.put(url, response.clone());
     71             } catch { }
     72         })
     73     );
     74 };
     75 
     76 const is_asset_request = (request_url) => ASSET_URL_KEYS.has(normalize_asset_url(request_url));
     77 
     78 const precache = async () => {
     79     const cache = await caches.open(APP_CACHE);
     80     await cache.addAll(PRECACHE_LIST.map((entry) => entry.url));
     81     await cache_assets();
     82 };
     83 
     84 const cleanup_caches = async () => {
     85     const keys = await caches.keys();
     86     for (const key of keys) {
     87         const is_app_cache = key.startsWith(APP_CACHE_PREFIX) && key !== APP_CACHE;
     88         const is_asset_cache = key.startsWith(ASSET_CACHE_PREFIX) && key !== ASSET_CACHE;
     89         if (!is_app_cache && !is_asset_cache) continue;
     90         await caches.delete(key);
     91     }
     92 };
     93 
     94 const range_response = async (request, response) => {
     95     const range = request.headers.get("range");
     96     if (!range || !response || response.type === "opaque") return response;
     97     const bytes = /bytes=(\d+)-(\d+)?/u.exec(range);
     98     if (!bytes) return response;
     99     const start = Number(bytes[1]);
    100     const end_raw = bytes[2];
    101     const buffer = await response.arrayBuffer();
    102     const end = end_raw ? Number(end_raw) : buffer.byteLength - 1;
    103     if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) return response;
    104     const sliced = buffer.slice(start, end + 1);
    105     const headers = new Headers(response.headers);
    106     headers.set("Content-Range", `bytes ${start}-${end}/${buffer.byteLength}`);
    107     headers.set("Content-Length", `${sliced.byteLength}`);
    108     return new Response(sliced, { status: 206, statusText: "Partial Content", headers });
    109 };
    110 
    111 const cache_first = async (request, cache_name = APP_CACHE) => {
    112     const cache = await caches.open(cache_name);
    113     const cached = await cache.match(request);
    114     if (cached) return await range_response(request, cached);
    115     const response = await fetch(request);
    116     if (response.ok || response.type === "opaque") await cache.put(request, response.clone());
    117     return response;
    118 };
    119 
    120 const network_first = async (request, cache_name = APP_CACHE) => {
    121     const cache = await caches.open(cache_name);
    122     try {
    123         const response = await fetch(request);
    124         if (response.ok || response.type === "opaque") await cache.put(request, response.clone());
    125         return response;
    126     } catch {
    127         const cached = await cache.match(request);
    128         if (cached) return await range_response(request, cached);
    129         const fallback = await cache.match(APP_SHELL_URL);
    130         if (fallback) return fallback;
    131         return new Response("offline", { status: 503 });
    132     }
    133 };
    134 
    135 self.addEventListener("install", (event) => {
    136     event.waitUntil(precache().then(() => self.skipWaiting()));
    137 });
    138 
    139 self.addEventListener("activate", (event) => {
    140     event.waitUntil(cleanup_caches().then(() => self.clients.claim()));
    141 });
    142 
    143 self.addEventListener("fetch", (event) => {
    144     const request = event.request;
    145     if (request.method !== "GET") return;
    146     const url = new URL(request.url);
    147     if (is_asset_request(request.url)) {
    148         event.respondWith(cache_first(request, ASSET_CACHE));
    149         return;
    150     }
    151     if (request.mode === "navigate") {
    152         event.respondWith(network_first(request));
    153         return;
    154     }
    155     if (url.origin === self.location.origin) event.respondWith(cache_first(request));
    156 });