From 450f1de68a08c5d0a91d507e59f1ca3d17fff6fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sat, 12 Sep 2026 14:33:31 +0300 Subject: [PATCH] sw: never resolve respondWith to undefined on offline navigation When a navigation fetch failed and the cache had no entry for the exact URL (e.g. '/' while only 'index.html' is precached, common while the local server restarts), the fetch handler resolved respondWith() to undefined, producing 'TypeError: Failed to convert value to Response'. The navigate fallback now chains caches.match(request) -> cached index.html -> an explicit 503 offline Response. sw.js requests are network-only with the same offline Response (previously they fell into the navigate branch), and a new unit test loads sw.js in a VM sandbox covering navigate/cache/asset/api paths. SW cache v97 -> v98. --- frontend/sw.js | 22 +++++-- frontend/tests/sw.test.js | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 frontend/tests/sw.test.js diff --git a/frontend/sw.js b/frontend/sw.js index 6a5bc4b..cf808d1 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v97'; +const CACHE = 'otaman-v98'; const URLS = [ 'index.html', 'help.html', @@ -31,19 +31,33 @@ self.addEventListener('activate', e => { ); }); +const OFFLINE_RESPONSE = new Response('Offline: page not cached', { + status: 503, + statusText: 'Offline', + headers: { 'Content-Type': 'text/plain' }, +}); + self.addEventListener('fetch', e => { if (!e.request.url.startsWith('http')) return; - if (new URL(e.request.url).pathname.startsWith('/api/')) return; // live data, never cache + const path = new URL(e.request.url).pathname; + if (path.startsWith('/api/')) return; // live data, never cache if (e.request.method !== 'GET') return; - const isNavigate = e.request.mode === 'navigate' || e.request.url.endsWith('sw.js'); + const isNavigate = e.request.mode === 'navigate'; + const isSwScript = path.endsWith('/sw.js'); if (isNavigate) { e.respondWith( fetch(e.request).then(res => { const clone = res.clone(); caches.open(CACHE).then(c => c.put(e.request, clone)); return res; - }).catch(() => caches.match(e.request)) + }).catch(() => + caches.match(e.request) + .then(r => r || caches.match('index.html')) + .then(r => r || OFFLINE_RESPONSE) + ) ); + } else if (isSwScript) { + e.respondWith(fetch(e.request).catch(() => OFFLINE_RESPONSE)); } else { e.respondWith( caches.match(e.request).then(r => r || fetch(e.request).then(res => { diff --git a/frontend/tests/sw.test.js b/frontend/tests/sw.test.js new file mode 100644 index 0000000..3b8e7c5 --- /dev/null +++ b/frontend/tests/sw.test.js @@ -0,0 +1,121 @@ +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const swSource = fs.readFileSync(path.join(__dirname, '..', 'sw.js'), 'utf8'); + +class FakeResponse { + constructor(body, init) { + this.body = body; + this.status = init && init.status; + this.statusText = init && init.statusText; + } + clone() { + return new FakeResponse(this.body, { status: this.status, statusText: this.statusText }); + } +} + +function loadSW({ fetchImpl, cacheMatch }) { + const listeners = {}; + const puts = []; + const sandbox = { + self: { + addEventListener: (type, fn) => { listeners[type] = fn; }, + skipWaiting: () => {}, + }, + caches: { + open: async () => ({ + addAll: async () => {}, + put: async (req, res) => { puts.push([String(req && req.url || req), res]); }, + }), + keys: async () => [], + delete: async () => true, + match: cacheMatch, + }, + clients: { claim: () => {} }, + fetch: fetchImpl, + Response: FakeResponse, + URL, + console, + }; + vm.createContext(sandbox); + vm.runInContext(swSource, sandbox); + return { listeners, puts }; +} + +function navigateEvent(url) { + const event = { + request: { url, method: 'GET', mode: 'navigate' }, + responded: null, + }; + event.respondWith = p => { event.responded = p; }; + event.passThrough = () => { event.responded = null; }; + return event; +} + +test('offline navigation falls back to the cached index.html', async () => { + const index = new FakeResponse('html'); + const { listeners } = loadSW({ + fetchImpl: async () => { throw new Error('offline'); }, + cacheMatch: async req => (String(req && req.url || req) === 'index.html' ? index : undefined), + }); + const event = navigateEvent('http://127.0.0.1:8080/'); + listeners.fetch(event); + const res = await event.responded; + assert.strictEqual(res, index); +}); + +test('offline navigation with empty cache resolves to an offline Response', async () => { + const { listeners } = loadSW({ + fetchImpl: async () => { throw new Error('offline'); }, + cacheMatch: async () => undefined, + }); + const event = navigateEvent('http://127.0.0.1:8080/'); + listeners.fetch(event); + const res = await event.responded; + assert.ok(res instanceof FakeResponse); + assert.strictEqual(res.status, 503); +}); + +test('a successful navigation is cached and returned', async () => { + const page = new FakeResponse('html'); + const { listeners, puts } = loadSW({ + fetchImpl: async () => page, + cacheMatch: async () => undefined, + }); + const event = navigateEvent('http://127.0.0.1:8080/help.html'); + listeners.fetch(event); + const res = await event.responded; + assert.strictEqual(res, page); + await new Promise(r => setImmediate(r)); + assert.strictEqual(puts.length, 1); + assert.strictEqual(puts[0][0], 'http://127.0.0.1:8080/help.html'); +}); + +test('api requests bypass the service worker', () => { + const { listeners } = loadSW({ + fetchImpl: async () => { throw new Error('unexpected'); }, + cacheMatch: async () => undefined, + }); + const event = navigateEvent('http://127.0.0.1:8080/api/status'); + event.request.mode = 'cors'; + listeners.fetch(event); + assert.strictEqual(event.responded, null); +}); + +test('uncached asset hits the network and gets cached', async () => { + const asset = new FakeResponse('js'); + const { listeners, puts } = loadSW({ + fetchImpl: async () => asset, + cacheMatch: async () => undefined, + }); + const event = navigateEvent('http://127.0.0.1:8080/des-bundle.js'); + event.request.mode = 'cors'; + listeners.fetch(event); + const res = await event.responded; + assert.strictEqual(res, asset); + await new Promise(r => setImmediate(r)); + assert.strictEqual(puts.length, 1); +});