diff --git a/app/api/offline/shell/route.ts b/app/api/offline/shell/route.ts index 43a88d3ec..ac0fc757a 100644 --- a/app/api/offline/shell/route.ts +++ b/app/api/offline/shell/route.ts @@ -7,8 +7,10 @@ export const dynamic = 'force-dynamic'; const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? '').replace(/\/+$/, ''); /** Files a page can load: scripts, styles, fonts and the images they name. */ const SHELL_FILE = /\.(js|css|woff2?|svg|png|webp)$/; +/** The local store's engine in public/. */ +const ENGINE_FILE = /\.(mjs|wasm)$/; -async function listFiles(dir: string, prefix: string, out: string[]): Promise { +async function listFiles(dir: string, prefix: string, out: string[], wanted: RegExp = SHELL_FILE): Promise { let entries; try { entries = await readdir(dir, { withFileTypes: true }); @@ -16,8 +18,8 @@ async function listFiles(dir: string, prefix: string, out: string[]): Promise { + let dir = ''; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('names the store worker with its build id, and the database engine with its .mjs and .wasm', async () => { + dir = mkdtempSync(join(os.tmpdir(), 'shell-')); + mkdirSync(join(dir, '.next', 'static', 'chunks'), { recursive: true }); + writeFileSync(join(dir, '.next', 'static', 'chunks', 'app.js'), ''); + writeFileSync(join(dir, '.next', 'static', 'chunks', 'app.js.map'), ''); + mkdirSync(join(dir, 'public', 'sqlite3mc', '1.2'), { recursive: true }); + writeFileSync(join(dir, 'public', 'sqlite3mc', '1.2', 'sqlite3.mjs'), ''); + writeFileSync(join(dir, 'public', 'sqlite3mc', '1.2', 'sqlite3.wasm'), ''); + writeFileSync(join(dir, 'public', 'sqlite3mc', '1.2', 'LICENSE'), ''); + vi.spyOn(process, 'cwd').mockReturnValue(dir); + vi.stubEnv('NEXT_PUBLIC_BUILD_ID', 'build a'); + + const { GET } = await import('@/app/api/offline/shell/route'); + const { files, buildId } = await (await GET()).json() as { files: string[]; buildId: string }; + expect(buildId).toBe('build a'); + expect(files.sort()).toEqual([ + '/_next/static/chunks/app.js', + '/sqlite3mc/1.2/sqlite3.mjs', + '/sqlite3mc/1.2/sqlite3.wasm', + '/store-worker.js?v=build%20a', + ]); + }); +}); diff --git a/lib/__tests__/sw-offline-shell.test.ts b/lib/__tests__/sw-offline-shell.test.ts index 094fd3e5e..193067bc1 100644 --- a/lib/__tests__/sw-offline-shell.test.ts +++ b/lib/__tests__/sw-offline-shell.test.ts @@ -126,6 +126,31 @@ describe('service worker with offline mode on', () => { expect(await worker.request({ url: `${ORIGIN}/mail`, mode: 'navigate' })).toBeNull(); }); + it('keeps the store\'s worker and database engine, so the store starts offline', async () => { + const caches = new FakeCaches(); + await shellOn(caches, true); + let online = true; + const fetchMock = vi.fn(async (url: unknown) => { + if (!online) throw new TypeError('Failed to fetch'); + return sameOrigin(`file ${String(url)}`, 'text/javascript'); + }); + const worker = loadWorker({ buildId: 'a', caches, clients: [], fetch: fetchMock }); + await settle(); + const urls = ['/store-worker.js?v=a', '/sqlite3mc/1.2/sqlite3.mjs', '/sqlite3mc/1.2/sqlite3.wasm']; + await worker.dispatch('message', { data: { type: 'offline-shell', on: true, urls, buildId: 'a' } }); + const shell = await caches.open('bulwark-shell-a'); + await vi.waitFor(async () => { + for (const url of urls) expect(await shell.match(`${ORIGIN}${url}`)).toBeDefined(); + }); + online = false; + for (const url of urls) { + const answer = await worker.request({ url: `${ORIGIN}${url}` }); + expect(await answer!.text()).toContain(url); + } + // Only those: another root file still goes to the network. + expect(await worker.request({ url: `${ORIGIN}/other.js` })).toBeNull(); + }); + it('keeps a newer tab’s shell in that tab’s build cache, not its own', async () => { const caches = new FakeCaches(); const fetchMock = vi.fn(async () => sameOrigin('x', 'text/javascript')); diff --git a/public/sw.js b/public/sw.js index 3dc7ff7b8..21601b2f0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -126,7 +126,10 @@ function appPagePath(url) { function staticPath(url) { if (url.origin !== self.location.origin) return false; - return url.pathname.startsWith(BASE_PATH + "/_next/static/"); + if (url.pathname.startsWith(BASE_PATH + "/_next/static/")) return true; + // The local store's engine: named by build (?v=) and by version, so it can be + // kept like a hashed file, and an offline start needs it. + return url.pathname === BASE_PATH + "/store-worker.js" || url.pathname.startsWith(BASE_PATH + "/sqlite3mc/"); } /** Where a page of the app is kept: its address without the query. */