diff --git a/web/boot/index.html b/web/boot/index.html new file mode 100644 index 00000000..9cc47226 --- /dev/null +++ b/web/boot/index.html @@ -0,0 +1,56 @@ + + + + + + Kiri:Moto (loading) + + + + + + Kiri:Moto is Loading + + + \ No newline at end of file diff --git a/web/boot/service.js b/web/boot/service.js new file mode 100644 index 00000000..be51a7ae --- /dev/null +++ b/web/boot/service.js @@ -0,0 +1,177 @@ +// --- configurable --- +const CACHE_VERSION = 'kiri-v001'; +const BUNDLE_URL = '/kiri/bundle.bin'; +const VERSION_KEY = '/__version__'; + +let mode = 'cached'; +let currentVersion = null; + +// --- logging --- +const log = (...a) => console.log('[SW]', ...a); +self.addEventListener('install', e => log('install')); +self.addEventListener('activate', e => log('activate')); +// self.addEventListener('fetch', e => log('fetch', e.request.url)); + +// --- lifecycle --- +self.skipWaiting(); +self.addEventListener('activate', e => e.waitUntil(clients.claim())); + +// --- mode control --- +self.addEventListener('message', e => { + log({ message: e.data }); + const data = e.data || {}; + if (data.mode) mode = data.mode; + if (data.mode === 'cached' && data.version !== undefined) { + ensureVersion(data.version); + } +}); + +// --- version check and preload --- +async function ensureVersion(incomingVersion) { + const cache = await caches.open(CACHE_VERSION); + let needPreload = false; + + const existing = await cache.match(VERSION_KEY); + if (!existing) { + log('no version stored → preload'); + needPreload = true; + } else { + const text = await existing.text(); + const stored = parseInt(text, 10); + if (stored !== incomingVersion) { + log(`version mismatch ${stored} ≠ ${incomingVersion} → preload`); + needPreload = true; + } else { + log(`version OK (${stored})`); + } + } + + if (needPreload) { + await preloadBundle(); + await cache.put( + VERSION_KEY, + new Response(String(incomingVersion), { headers: { 'Content-Type': 'text/plain' } }) + ); + broadcast('preload-complete'); + } else { + broadcast('preload-cached'); + } +} + +// --- fetch with redirect handler --- +async function fetch_safe(req) { + const res = await fetch(req); + if (res.redirected) { + log({ was_redirected_cloning: res.url }); + // clone into a fresh non-redirect response + const clone = res.clone(); + return new Response(await clone.blob(), { + headers: clone.headers, + status: 200, + statusText: 'OK' + }); + } + return res; +} + +// --- fetch handler --- +self.addEventListener('fetch', e => { + const { request } = e; + const url = new URL(request.url); + + if (request.method !== 'GET') return; + + if (mode === 'transparent') { + e.respondWith(fetch_safe(e.request)); + return; + } + + e.respondWith(fromCacheOrNetwork(request)); +}); + +// --- helpers --- +async function fromCacheOrNetwork(req) { + const cache = await caches.open(CACHE_VERSION); + const hit = await cache.match(req); + if (hit) { + // log({ hit: hit.url || req.url }); + return hit; + } + const net = await fetch_safe(req); + cache.put(req, net.clone()); + log({ put: net.url }); + return net; +} + +async function serveIndex() { + const cache = await caches.open(CACHE_VERSION); + const hit = await cache.match('/kiri/index.html'); + log({ serve_index: hit, cache }); + if (hit) return hit; + return fetch_safe('/kiri/index.html'); +} + +async function broadcast(message) { + const clientsList = await clients.matchAll({ type: 'window' }); + for (const client of clientsList) { + client.postMessage(message); + } +} + +const sec_headers = { + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Cross-Origin-Embedder-Policy': 'require-corp' +}; + +// --- preload & unpack bundle --- +async function preloadBundle() { + // return; + const cache = await caches.open(CACHE_VERSION); + const res = await fetch(BUNDLE_URL, { cache: 'no-store' }); + const buf = await res.arrayBuffer(); + const files = await unpackBundle(buf); + log({ files }); + await Promise.all( + Object.entries(files).map(([path, blob]) => { + const ext = path.split('.').pop(); + const type = + ext === 'html' ? 'text/html' : + ext === 'js' ? 'application/javascript' : + ext === 'css' ? 'text/css' : + ext === 'json' ? 'application/json' : + ext === 'wasm' ? 'application/wasm' : + ext === 'svg' ? 'image/svg+xml' : + 'application/octet-stream'; + const headers = { 'Content-Type': type, ...sec_headers }; + // Object.assign(headers, sec_headers); + const resp = new Response(blob, { headers }); + return cache.put('/' + path, resp); + }) + ); +} + +// --- simple bundle format --- +// [count:uint32][entries...][file data...] +// entry: [nlen:uint16][name][offset:uint32][length:uint32] +async function unpackBundle(buf) { + const view = new DataView(buf); + let pos = 0; + const count = view.getUint32(pos, true); pos += 4; + const decoder = new TextDecoder(); + const table = []; + + for (let i = 0; i < count; i++) { + const nlen = view.getUint16(pos, true); pos += 2; + const name = decoder.decode(new Uint8Array(buf, pos, nlen)); pos += nlen; + const offset = view.getUint32(pos, true); pos += 4; + const len = view.getUint32(pos, true); pos += 4; + table.push({ name, offset, len }); + } + log({ table }); + + const files = {}; + for (const { name, offset, len } of table) + files[name] = buf.slice(offset, offset + len); + + return files; +}