0003: PWA Support
Use NetworkFirst for HTML/API and CacheFirst for assets instead of full HTML precache.
Partially Implemented
Source: docs/decisions/0003
<h1>PWA Support for LessJS SSG</h1>
<h2>Status</h2>
<p><strong>PARTIALLY IMPLEMENTED</strong> - available through <code>less({ pwa })</code> metadata and the official <code>deno task build</code> flow. The service worker strategy has changed from the original full-precache design.</p>
<h2>Context</h2>
<p>LessJS generates pure static HTML with Declarative Shadow DOM. This is the ideal substrate for a Progressive Web App:</p>
<ul><li>All pages are pre-rendered HTML (no server needed)</li><li>Assets are versioned hashes (perfect cache keys)</li><li>API routes can live separately on serverless platforms</li><li>Service worker should avoid stale HTML by using NetworkFirst for HTML/API and</li></ul>
<p>CacheFirst for hashed assets</p>
<h2>Proposal</h2>
<p>Add a <code>pwa</code> option to the <code>less()</code> plugin that automatically generates:</p>
<ol><li><code>manifest.json</code> - Web App Manifest (name, icons, display, theme_color)</li><li><code>sw.js</code> - Service Worker with NetworkFirst (HTML/API) + CacheFirst (assets)</li><li>HTML <code><head></code> injection - <code><link rel="manifest"></code>, `<meta</li></ol>
<p>name="theme-color"><code>, </code><link rel="service-worker">`</p>
<h3>API</h3>
<pre data-language="ts"><code>// vite.config.ts
export default defineConfig({
plugins: [less({
pwa: {
name: 'My LessJS App',
shortName: 'LessJS',
themeColor: '#000000',
backgroundColor: '#ffffff',
icons: [{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }],
// Current built-in strategy: NetworkFirst HTML/API, CacheFirst assets.
},
})],
});</code></pre>
<h3>Service Worker Strategy</h3>
<pre data-language="js"><code>// Generated sw.js (~40 lines)
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
const isAsset = /\.[a-z0-9]+$/i.test(url.pathname) && !url.pathname.includes('/api/');
e.respondWith(isAsset ? cacheFirst(e.request) : networkFirst(e.request));
});</code></pre>
<h3>SSG Integration</h3>
<p>Inside the SSG phase, after pages are rendered:</p>
<pre data-language="ts"><code>if (options.pwa) {
writeFileSync(join(outputDir, 'manifest.json'), generateManifest(options.pwa));
writeFileSync(join(outputDir, 'sw.js'), generateSwScript(options.pwa));
// inject manifest link + sw registration into all HTML files
injectIntoHtml(outputDir, {
head: `<link rel="manifest" href="/manifest.json">`,
body: `<script>navigator.serviceWorker?.register('/sw.js')</script>`,
});
}</code></pre>
<h2>Consequences</h2>
<ul><li><strong>Positive</strong>: Offline access, instant repeat visits, installable on mobile</li><li><strong>Positive</strong>: Minimal code (~100 lines total across plugin + generator + sw</li></ul>
<p>script)</p>
<ul><li><strong>Neutral</strong>: Service worker scope limited to site root (no cross-site impact)</li><li><strong>Negative</strong>: Offline-first HTML is intentionally not provided by default;</li></ul>
<p>stale static pages are a worse default</p>
<ul><li><strong>Negative</strong>: Cache invalidation needs a generated cache name; current</li></ul>
<p>implementation uses a build-time timestamp</p>