feat: keep the local store's engine for a start without a network

This commit is contained in:
Linus Rath
2026-11-03 05:54:36 +01:00
parent 29778da2d4
commit 062a078e1e
4 changed files with 79 additions and 5 deletions
+12 -4
View File
@@ -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<void> {
async function listFiles(dir: string, prefix: string, out: string[], wanted: RegExp = SHELL_FILE): Promise<void> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
@@ -16,8 +18,8 @@ async function listFiles(dir: string, prefix: string, out: string[]): Promise<vo
return;
}
for (const entry of entries) {
if (entry.isDirectory()) await listFiles(join(dir, entry.name), `${prefix}${entry.name}/`, out);
else if (SHELL_FILE.test(entry.name)) out.push(`${prefix}${entry.name}`);
if (entry.isDirectory()) await listFiles(join(dir, entry.name), `${prefix}${entry.name}/`, out, wanted);
else if (wanted.test(entry.name)) out.push(`${prefix}${entry.name}`);
}
}
@@ -26,12 +28,18 @@ async function listFiles(dir: string, prefix: string, out: string[]): Promise<vo
* /_next/static/, which is the same for every user and content-hashed. A
* tab with offline mode on hands the list to the service worker, which keeps
* the files in this build's cache so the app also starts without a network.
* The local store's worker and its database engine come along: without them
* the store can't start offline. The worker's address carries the build id, as
* the page asks for it (lib/store/client/api.ts).
*/
export async function GET() {
const files: string[] = [];
await listFiles(join(process.cwd(), '.next', 'static'), `${BASE_PATH}/_next/static/`, files);
await listFiles(join(process.cwd(), 'public', 'sqlite3mc'), `${BASE_PATH}/sqlite3mc/`, files, ENGINE_FILE);
const buildId = process.env.NEXT_PUBLIC_BUILD_ID ?? '';
files.push(`${BASE_PATH}/store-worker.js${buildId ? `?v=${encodeURIComponent(buildId)}` : ''}`);
return NextResponse.json(
{ buildId: process.env.NEXT_PUBLIC_BUILD_ID ?? '', files },
{ buildId, files },
{ headers: { 'Cache-Control': 'no-store' } },
);
}
+38
View File
@@ -0,0 +1,38 @@
// @vitest-environment node
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('the list of files a start without a network needs', () => {
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',
]);
});
});
+25
View File
@@ -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'));
+4 -1
View File
@@ -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. */