Offline access: save through the service worker, not the page's fetch
Pre-loading relied on the page being controlled by the service worker, so its requests would pass through it and be saved. A page loaded with a hard reload (Ctrl+Shift+R) never is: pre-loading then waited, gave up silently, and nothing new was saved — the app still could not start offline, and Settings kept showing the old sync time. The page now asks the worker by message to fetch and save each URL, which works however the page was loaded. Sign-out reaches the worker through its registration for the same reason. Thread URLs come from shared helpers in api.ts, so pre-loading saves exactly what the app asks for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+19
-12
@@ -96,6 +96,23 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
||||
return data as T;
|
||||
}
|
||||
|
||||
type ThreadsOpts = { offset?: number; limit?: number; filter?: string; order?: "asc" | "desc" };
|
||||
|
||||
// Thread URLs, shared with offline pre-loading: it must save the very URLs the
|
||||
// app asks for later.
|
||||
export function threadsPath(view = "unified", opts?: ThreadsOpts): string {
|
||||
const params = new URLSearchParams({ view });
|
||||
if (opts?.offset) params.set("offset", String(opts.offset));
|
||||
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||
if (opts?.filter && opts.filter !== "all") params.set("filter", opts.filter);
|
||||
if (opts?.order) params.set("order", opts.order);
|
||||
return `/threads?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function threadPath(id: string, sort: "asc" | "desc" = "asc", view = "unified"): string {
|
||||
return `/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
adminSettings: () => apiFetch<AdminSettings>("/admin/settings"),
|
||||
// PUT merges: send only the fields you are changing.
|
||||
@@ -158,19 +175,9 @@ export const api = {
|
||||
uiManifest: () => apiFetch<UiManifest>("/plugins/ui-manifest"),
|
||||
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
||||
|
||||
threads: (
|
||||
view = "unified",
|
||||
opts?: { offset?: number; limit?: number; filter?: string; order?: "asc" | "desc" },
|
||||
) => {
|
||||
const params = new URLSearchParams({ view });
|
||||
if (opts?.offset) params.set("offset", String(opts.offset));
|
||||
if (opts?.limit) params.set("limit", String(opts.limit));
|
||||
if (opts?.filter && opts.filter !== "all") params.set("filter", opts.filter);
|
||||
if (opts?.order) params.set("order", opts.order);
|
||||
return apiFetch<ThreadListOut>(`/threads?${params.toString()}`);
|
||||
},
|
||||
threads: (view = "unified", opts?: ThreadsOpts) => apiFetch<ThreadListOut>(threadsPath(view, opts)),
|
||||
thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") =>
|
||||
apiFetch<ThreadDetail>(`/threads/${id}?sort=${sort}&view=${encodeURIComponent(view)}`),
|
||||
apiFetch<ThreadDetail>(threadPath(id, sort, view)),
|
||||
|
||||
search: (q: string, cursor?: string) => {
|
||||
const params = new URLSearchParams({ q });
|
||||
|
||||
+10
-15
@@ -142,22 +142,14 @@ export async function startOffline(): Promise<void> {
|
||||
if (regs.length) await disableOffline();
|
||||
}
|
||||
|
||||
/** Resolves once the service worker controls this page (it saves what we fetch). */
|
||||
/** Whether the service worker is installed and running (it does the saving). */
|
||||
export async function offlineReady(): Promise<boolean> {
|
||||
if (!("serviceWorker" in navigator) || !offlineEnabled()) return false;
|
||||
await navigator.serviceWorker.ready;
|
||||
if (navigator.serviceWorker.controller) return true;
|
||||
return new Promise((resolve) => {
|
||||
const timer = window.setTimeout(() => resolve(false), 5000);
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"controllerchange",
|
||||
() => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(true);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
// Not `controller`: a page loaded with a hard reload is never controlled,
|
||||
// yet the worker is there and can fetch and save on its behalf.
|
||||
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||
return Boolean(reg?.active);
|
||||
}
|
||||
|
||||
export async function enableOffline(): Promise<void> {
|
||||
@@ -190,7 +182,10 @@ export async function clearOfflineData(): Promise<void> {
|
||||
/* ignore */
|
||||
}
|
||||
emitSetting();
|
||||
const sw = "serviceWorker" in navigator ? navigator.serviceWorker.controller : null;
|
||||
// The registration's worker, not `controller`: after a hard reload the page
|
||||
// is not controlled, but the worker (and its saved mail) is still there.
|
||||
const reg = "serviceWorker" in navigator ? await navigator.serviceWorker.getRegistration() : undefined;
|
||||
const sw = reg?.active ?? null;
|
||||
// Through the service worker when there is one: it keeps an index of what is
|
||||
// saved in memory, which must be reset along with the saved copies.
|
||||
if (sw) sw.postMessage({ type: "purge" });
|
||||
|
||||
@@ -1,19 +1,66 @@
|
||||
// Pre-loading the newest Inbox conversations for offline reading. Everything is
|
||||
// fetched exactly as the app would fetch it when opened — same URLs — and the
|
||||
// service worker saves the answers as they pass (public/sw.js). Bodies and
|
||||
// images are fetched once; the conversation itself is fetched every time,
|
||||
// Pre-loading the newest Inbox conversations for offline reading.
|
||||
//
|
||||
// Every request goes through the service worker by message ("fetch this and
|
||||
// save it"), not through the page's own fetch: a page loaded with a hard
|
||||
// reload (Ctrl+Shift+R), or the one that just turned offline access on, is not
|
||||
// controlled by the service worker, and its requests would pass by it unsaved.
|
||||
// The URLs are exactly the ones the app asks for when opened offline.
|
||||
//
|
||||
// Bodies and images are fetched once; the conversation itself every time,
|
||||
// since new replies, flags and moves change it.
|
||||
|
||||
import { api } from "./api";
|
||||
import { isOffline, isSaved, offlineReady, setLastSync } from "./offline";
|
||||
import { threadPath, threadsPath } from "./api";
|
||||
import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline";
|
||||
import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types";
|
||||
|
||||
const INBOX = "folder:inbox";
|
||||
const COUNT = 50; // the list's page size, so the first page is saved as shown
|
||||
const PARALLEL = 3;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g;
|
||||
|
||||
// What the app asks for when it starts: without these it cannot open offline.
|
||||
const STARTUP = [
|
||||
"/auth/me",
|
||||
"/accounts",
|
||||
"/mailboxes",
|
||||
"/mailboxes/counts",
|
||||
"/settings/quota",
|
||||
"/saved-searches",
|
||||
"/plugins/ui-manifest",
|
||||
];
|
||||
|
||||
let running: Promise<void> | null = null;
|
||||
|
||||
class Unreachable extends Error {}
|
||||
|
||||
/** Fetch through the service worker, which saves the answer; returns its text. */
|
||||
async function viaWorker(url: string): Promise<string | null> {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
const worker = reg.active;
|
||||
if (!worker) throw new Unreachable("no service worker");
|
||||
const reply = await new Promise<{ status: number; body?: string }>((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timer = window.setTimeout(() => reject(new Unreachable("timeout")), TIMEOUT_MS);
|
||||
channel.port1.onmessage = (e) => {
|
||||
window.clearTimeout(timer);
|
||||
resolve(e.data);
|
||||
};
|
||||
worker.postMessage({ type: "fetch", url }, [channel.port2]);
|
||||
});
|
||||
if (reply.status === 0) {
|
||||
reportOffline();
|
||||
throw new Unreachable("offline");
|
||||
}
|
||||
return reply.status === 200 ? (reply.body ?? "") : null;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const body = await viaWorker(`/api${path}`);
|
||||
if (body === null) throw new Error(`could not load ${path}`);
|
||||
return JSON.parse(body) as T;
|
||||
}
|
||||
|
||||
async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
@@ -21,7 +68,8 @@ async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>):
|
||||
const item = items[next++];
|
||||
try {
|
||||
await fn(item);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
if (err instanceof Unreachable) return;
|
||||
/* one conversation failing must not stop the rest */
|
||||
}
|
||||
}
|
||||
@@ -29,47 +77,33 @@ async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>):
|
||||
await Promise.all(Array.from({ length: n }, worker));
|
||||
}
|
||||
|
||||
async function fetchOnce(url: string): Promise<string | null> {
|
||||
if (await isSaved(url)) return null;
|
||||
const resp = await fetch(url, { credentials: "include" });
|
||||
return resp.ok ? resp.text() : null;
|
||||
async function saveMessage(m: MessageEnvelope): Promise<void> {
|
||||
const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`;
|
||||
if (!(await isSaved(htmlUrl))) {
|
||||
const html = await viaWorker(htmlUrl);
|
||||
for (const cid of new Set(html?.match(CID_RE) ?? [])) {
|
||||
if (!(await isSaved(cid))) await viaWorker(cid);
|
||||
}
|
||||
}
|
||||
const attUrl = `/api/messages/${m.id}/attachments`;
|
||||
if (m.has_attachments && !(await isSaved(attUrl))) await viaWorker(attUrl);
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
if (!(await offlineReady()) || isOffline()) return;
|
||||
// What the app asks for when it starts (who is signed in, accounts, folders…).
|
||||
// The page fetched these once on load — possibly before the service worker
|
||||
// was there to save them — so fetch them again now, or the app could not
|
||||
// start offline at all.
|
||||
await Promise.allSettled([
|
||||
api.me(),
|
||||
api.accounts(),
|
||||
api.mailboxes(),
|
||||
api.quota(),
|
||||
api.savedSearches(),
|
||||
api.uiManifest(),
|
||||
]);
|
||||
const settings = await api.settings();
|
||||
for (const path of STARTUP) await viaWorker(`/api${path}`);
|
||||
const settings = await getJson<UserSettings>("/settings");
|
||||
const order = settings.thread_order ?? "asc";
|
||||
// The list as the app shows it (newest first is the default), plus the other
|
||||
// order when that is what this device uses, so the list itself opens offline.
|
||||
const list = await api.threads(INBOX, { limit: COUNT, order: "desc" });
|
||||
const list = await getJson<ThreadListOut>(threadsPath(INBOX, { limit: COUNT, order: "desc" }));
|
||||
if (localStorage.getItem("mail.listOrder") === "oldest") {
|
||||
await api.threads(INBOX, { limit: COUNT, order: "asc" });
|
||||
await viaWorker(`/api${threadsPath(INBOX, { limit: COUNT, order: "asc" })}`);
|
||||
}
|
||||
await api.folderCounts();
|
||||
|
||||
await inPool(list.threads, PARALLEL, async (summary) => {
|
||||
const detail = await api.thread(summary.id, order, INBOX);
|
||||
for (const m of detail.messages) {
|
||||
const html = await fetchOnce(`/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`);
|
||||
if (html) {
|
||||
for (const cid of new Set(html.match(CID_RE) ?? [])) await fetchOnce(cid);
|
||||
}
|
||||
if (m.has_attachments && !(await isSaved(`/api/messages/${m.id}/attachments`))) {
|
||||
await api.messageAttachments(m.id);
|
||||
}
|
||||
}
|
||||
const detail = await getJson<ThreadDetail>(threadPath(summary.id, order, INBOX));
|
||||
for (const m of detail.messages) await saveMessage(m);
|
||||
});
|
||||
if (!isOffline()) setLastSync(Date.now());
|
||||
}
|
||||
@@ -77,7 +111,7 @@ async function run(): Promise<void> {
|
||||
/** Save the newest Inbox conversations; a no-op unless offline access is on. */
|
||||
export function prefetchRecent(): Promise<void> {
|
||||
running ??= run()
|
||||
.catch(() => {})
|
||||
.catch((err) => console.warn("offline access: saving recent mail failed", err))
|
||||
.finally(() => {
|
||||
running = null;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user