/** * @license MIT * @author Ayo Ayco */ import { readFile, writeFile, readdir, unlink } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { randomUUID } from "node:crypto"; import path from 'pathe'; import { build } from 'esbuild'; const ASTROSW = '@ayco/astro-sw'; /** * @typedef {import('astro').AstroIntegration} AstroIntegration * @typedef {import('esbuild').BuildOptions} BuildOptions */ /** * Accepts configuration options with service worker path * and injects needed variables such as `__assets` generated by Astro * @param {{ * path: string, * assetCachePrefix?: string, * assetCacheVersionID?: string, * customRoutes?: string[], * excludeRoutes?: string[], * logAssets?: true, * esbuild?: BuildOptions * }} options * @returns {AstroIntegration} */ export default function serviceWorker(options) { let { assetCachePrefix = ASTROSW, assetCacheVersionID = randomUUID(), path: serviceWorkerPath = undefined, customRoutes = [], excludeRoutes = [], logAssets = false, esbuild = {} } = options || {}; /** * @type {Array} */ let assets = []; const registrationScript = `const registerSW = async () => { if ("serviceWorker" in navigator) { try { const registration = await navigator.serviceWorker.register("/sw.js", { scope: "/", }); if (registration.installing) { // installingFn(); console.log('[${ASTROSW}] Installing...') } else if (registration.waiting) { // installedFn(); console.log('[${ASTROSW}] Installed...') } else if (registration.active) { // activeFn(); console.log('[${ASTROSW}] Active...') } } catch (error) { // onError(error); console.error('[${ASTROSW}] ERR', error) } } else { // onUnsupported(); console.log('[${ASTROSW}] Browser does not support Service Worker') } } registerSW();` let output = 'static'; const __dirname = path.resolve(path.dirname('.')); return { 'name': ASTROSW, 'hooks': { 'astro:config:setup': async ({ injectScript, config: _config, command, logger }) => { if (!serviceWorkerPath || serviceWorkerPath === '') { // REQUIRED OPTION IS MISSING logger.error('Missing required path to service worker script'); } output = _config.output; if (command === 'build') { injectScript('page', registrationScript); } }, 'astro:config:done': async ({injectTypes, logger}) => { let injectedTypes = ` declare const __assets: string[]; declare const __version: string; declare const __prefix: string;` injectTypes({filename: 'caching.d.ts', content: injectedTypes}) }, 'astro:build:ssr': ({ manifest }) => { const files = manifest.routes.map(route => route.file.replaceAll('/', '')); const assetsMinusFiles = manifest.assets.filter(ass => !files.includes(ass.replaceAll('/', ''))); assets = output === 'static' ? assetsMinusFiles : manifest.assets.filter(ass => !ass.includes('sw.js')); }, 'astro:build:done': async ({ dir, routes, pages, logger }) => { const outfile = fileURLToPath(new URL('./sw.js', dir)); const swPath = (serviceWorkerPath && serviceWorkerPath !== '') ? path.join(__dirname, serviceWorkerPath) : undefined; let originalScript; const _publicFiles = (await readdir(dir, { withFileTypes: true }) ?? []) .filter(dirent => dirent.isFile()) .map(dirent => `/${dirent.name}`); const _routes = routes .filter(({ isIndex }) => isIndex) .map(({ pathname }) => pathname) .filter(pathname => pathname !== '') ?? []; const _pages = pages .filter(({ pathname }) => pathname !== '') .map(({ pathname }) => `/${pathname}`) ?? []; const _pagesWithoutEndSlash = pages .filter(({ pathname }) => pathname !== '') .map(({ pathname }) => { const lastChar = pathname.slice(-1); const len = pathname.length; return lastChar === '/' ? `/${pathname.slice(0, len - 1)}` : `/${pathname}`; }) .filter(pathname => pathname !== '') ?? []; const _excludeRoutes = [ ...excludeRoutes, ...excludeRoutes.map(route => `${route}/`) ]; assets = [...new Set([ ...assets, ..._routes, ..._pages, ..._pagesWithoutEndSlash, ...customRoutes, ..._publicFiles ])].filter(asset => !!asset && asset !== '' && !asset.includes('404') && !asset.includes('index.html') && !_excludeRoutes.includes(asset) ); if (logAssets) { logger.info(`${assets.length} assets for caching: \n ▶ ${assets.toString().replaceAll(',', '\n ▶ ')}\n`); } else { logger.info(`${assets.length} assets for caching.`); } try { logger.info(`Using service worker in path: ${swPath}`); originalScript = await readFile(swPath); } catch { logger.error(`Service worker script not found! ${swPath}`) if (!swPath) { logger.error(` [${ASTROSW}] ERR: The 'path' option is required! [${ASTROSW}] INFO: Please see service worker options in https://ayco.io/gh/astro-sw#readme `); } } const assetsDeclaration = `const __assets = ${JSON.stringify(assets)};\n`; const versionDeclaration = `const __version = ${JSON.stringify(assetCacheVersionID)};\n`; const prefixDeclaration = `const __prefix = ${JSON.stringify(assetCachePrefix)};\n`; /** * TODO: allow importing in dev's sw.js by resolving imports */ const tempFile = `${swPath}.tmp.ts`; try { await writeFile( tempFile, assetsDeclaration + versionDeclaration + prefixDeclaration + originalScript, { flag: 'w+' } ); } catch (err) { logger.error(err.toString()) } try { await build({ bundle: true, ...esbuild, outfile, platform: 'browser', entryPoints: [tempFile], }) } catch (err) { logger.error(err.toString()) } // remove temp file try { await unlink(tempFile); } catch (err) { logger.error(err.toString()) } } } } };