mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
feat: keep the local store's engine for a start without a network
This commit is contained in:
@@ -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' } },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user