import { createHash } from 'node:crypto' import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import path from 'node:path' const edition = process.argv[2] if (edition !== 'classic' && edition !== 'atproto') throw new Error('Expected classic or atproto edition') // Run only after Vite has successfully written all bundles. A closeBundle hook // also runs when chunk generation fails, masking that error with missing assets. const output = process.env.REAMS_BUILD_OUT_DIR ? path.resolve(process.env.REAMS_BUILD_OUT_DIR) : path.resolve('dist', edition) const serviceWorkerPath = path.join(output, 'service-worker.js') const indexPath = path.join(output, 'index.html') const serviceWorker = await readFile(serviceWorkerPath, 'utf8') const html = await readFile(indexPath, 'utf8') const assets = ['index.html', 'manifest.json', 'icon-192.png', 'icon-512.png', 'icon.svg', 'icon-maskable.svg'] if (edition === 'atproto') assets.push('oauth-login-scope.json') async function visit(relative) { for (const entry of await readdir(path.join(output, relative), { withFileTypes: true })) { const child = `${relative}/${entry.name}` if (entry.isDirectory()) await visit(child) else if (entry.isFile()) assets.push(child) } } await visit('assets') await visit('fonts') assets.sort() // Digest the ORIGINAL completed html (before the cache-identity meta is // injected) so the release identity never depends on itself — no circular hash. const digest = createHash('sha256') digest.update(serviceWorker) for (const asset of assets) digest.update(asset).update(await readFile(path.join(output, asset))) const cacheName = `reams-${edition}-${digest.digest('hex').slice(0, 16)}` // Inject the running page's cache identity into the finalized html. The page // reads this meta to compare its own release against worker-reported releases. const meta = `` if (html.split('').length !== 2) throw new Error(`Expected one in ${indexPath}`) const finalHtml = html.replace('', `${meta}`) // Per-asset integrity is computed from the FINAL files — index.html includes // the injected meta — so the worker's expected digests match the bytes the // browser actually fetches and caches at install time. const integrity = {} for (const asset of assets) { const content = asset === 'index.html' ? finalHtml : await readFile(path.join(output, asset)) integrity[`/${asset}`] = createHash('sha256').update(content).digest('base64') } const replacements = { '%REAMS_CACHE_NAME%': cacheName, '%REAMS_ASSETS%': JSON.stringify(assets.map(asset => `/${asset}`)), '%REAMS_INTEGRITY%': JSON.stringify(integrity), '%REAMS_EDITION%': edition, } let generated = serviceWorker for (const [token, value] of Object.entries(replacements)) { if (!generated.includes(token)) throw new Error(`Expected ${token} in ${serviceWorkerPath}`) generated = generated.replaceAll(token, value) } await writeFile(serviceWorkerPath, generated) await writeFile(indexPath, finalHtml) if (edition === 'classic') await rm(path.join(output, 'oauth-callback.html'), { force: true })