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:
@@ -377,8 +377,33 @@ self.addEventListener("fetch", (event) => {
|
|||||||
if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req));
|
if (STATIC_RE.test(url.pathname)) event.respondWith(staticFile(req));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-loading from the page (lib/offlinePrefetch.ts): fetch one URL and save it,
|
||||||
|
* answering on the message port with { status, body } — status 0 when the
|
||||||
|
* server could not be reached. By message rather than by the page's own fetch,
|
||||||
|
* so it works for a page this worker does not control (a hard reload, or the
|
||||||
|
* page that just turned offline access on).
|
||||||
|
*/
|
||||||
|
async function fetchForPage(url, port) {
|
||||||
|
const req = new Request(new URL(url, self.location.origin).href, { credentials: "same-origin" });
|
||||||
|
const cls = classify(new URL(req.url));
|
||||||
|
try {
|
||||||
|
const res = await network(req, API_TIMEOUT);
|
||||||
|
offline = false;
|
||||||
|
if (res.status === 401) await purge();
|
||||||
|
else if (cls && res.status === 200) await save(req, res.clone(), cls);
|
||||||
|
port.postMessage({ status: res.status, body: await res.text() });
|
||||||
|
} catch {
|
||||||
|
offline = true;
|
||||||
|
port.postMessage({ status: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener("message", (event) => {
|
self.addEventListener("message", (event) => {
|
||||||
if (event.data?.type === "purge") event.waitUntil(purge());
|
if (event.data?.type === "purge") event.waitUntil(purge());
|
||||||
|
if (event.data?.type === "fetch" && event.ports[0]) {
|
||||||
|
event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
|
|||||||
+19
-12
@@ -96,6 +96,23 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
return data as T;
|
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 = {
|
export const api = {
|
||||||
adminSettings: () => apiFetch<AdminSettings>("/admin/settings"),
|
adminSettings: () => apiFetch<AdminSettings>("/admin/settings"),
|
||||||
// PUT merges: send only the fields you are changing.
|
// PUT merges: send only the fields you are changing.
|
||||||
@@ -158,19 +175,9 @@ export const api = {
|
|||||||
uiManifest: () => apiFetch<UiManifest>("/plugins/ui-manifest"),
|
uiManifest: () => apiFetch<UiManifest>("/plugins/ui-manifest"),
|
||||||
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
syncNow: () => apiFetch<{ status: string }>("/accounts/sync", { method: "POST" }),
|
||||||
|
|
||||||
threads: (
|
threads: (view = "unified", opts?: ThreadsOpts) => apiFetch<ThreadListOut>(threadsPath(view, opts)),
|
||||||
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()}`);
|
|
||||||
},
|
|
||||||
thread: (id: string, sort: "asc" | "desc" = "asc", view = "unified") =>
|
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) => {
|
search: (q: string, cursor?: string) => {
|
||||||
const params = new URLSearchParams({ q });
|
const params = new URLSearchParams({ q });
|
||||||
|
|||||||
+10
-15
@@ -142,22 +142,14 @@ export async function startOffline(): Promise<void> {
|
|||||||
if (regs.length) await disableOffline();
|
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> {
|
export async function offlineReady(): Promise<boolean> {
|
||||||
if (!("serviceWorker" in navigator) || !offlineEnabled()) return false;
|
if (!("serviceWorker" in navigator) || !offlineEnabled()) return false;
|
||||||
await navigator.serviceWorker.ready;
|
// Not `controller`: a page loaded with a hard reload is never controlled,
|
||||||
if (navigator.serviceWorker.controller) return true;
|
// yet the worker is there and can fetch and save on its behalf.
|
||||||
return new Promise((resolve) => {
|
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||||
const timer = window.setTimeout(() => resolve(false), 5000);
|
const reg = await Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||||
navigator.serviceWorker.addEventListener(
|
return Boolean(reg?.active);
|
||||||
"controllerchange",
|
|
||||||
() => {
|
|
||||||
window.clearTimeout(timer);
|
|
||||||
resolve(true);
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enableOffline(): Promise<void> {
|
export async function enableOffline(): Promise<void> {
|
||||||
@@ -190,7 +182,10 @@ export async function clearOfflineData(): Promise<void> {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
emitSetting();
|
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
|
// 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.
|
// saved in memory, which must be reset along with the saved copies.
|
||||||
if (sw) sw.postMessage({ type: "purge" });
|
if (sw) sw.postMessage({ type: "purge" });
|
||||||
|
|||||||
@@ -1,19 +1,66 @@
|
|||||||
// Pre-loading the newest Inbox conversations for offline reading. Everything is
|
// Pre-loading the newest Inbox conversations for offline reading.
|
||||||
// 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
|
// Every request goes through the service worker by message ("fetch this and
|
||||||
// images are fetched once; the conversation itself is fetched every time,
|
// 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.
|
// since new replies, flags and moves change it.
|
||||||
|
|
||||||
import { api } from "./api";
|
import { threadPath, threadsPath } from "./api";
|
||||||
import { isOffline, isSaved, offlineReady, setLastSync } from "./offline";
|
import { isOffline, isSaved, offlineReady, reportOffline, setLastSync } from "./offline";
|
||||||
|
import type { MessageEnvelope, ThreadDetail, ThreadListOut, UserSettings } from "./types";
|
||||||
|
|
||||||
const INBOX = "folder:inbox";
|
const INBOX = "folder:inbox";
|
||||||
const COUNT = 50; // the list's page size, so the first page is saved as shown
|
const COUNT = 50; // the list's page size, so the first page is saved as shown
|
||||||
const PARALLEL = 3;
|
const PARALLEL = 3;
|
||||||
|
const TIMEOUT_MS = 30_000;
|
||||||
const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g;
|
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;
|
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> {
|
async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>): Promise<void> {
|
||||||
let next = 0;
|
let next = 0;
|
||||||
const worker = async () => {
|
const worker = async () => {
|
||||||
@@ -21,7 +68,8 @@ async function inPool<T>(items: T[], n: number, fn: (item: T) => Promise<void>):
|
|||||||
const item = items[next++];
|
const item = items[next++];
|
||||||
try {
|
try {
|
||||||
await fn(item);
|
await fn(item);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (err instanceof Unreachable) return;
|
||||||
/* one conversation failing must not stop the rest */
|
/* 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));
|
await Promise.all(Array.from({ length: n }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOnce(url: string): Promise<string | null> {
|
async function saveMessage(m: MessageEnvelope): Promise<void> {
|
||||||
if (await isSaved(url)) return null;
|
const htmlUrl = `/api/messages/${m.id}/html${m.images_allowed ? "?images=1" : ""}`;
|
||||||
const resp = await fetch(url, { credentials: "include" });
|
if (!(await isSaved(htmlUrl))) {
|
||||||
return resp.ok ? resp.text() : null;
|
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> {
|
async function run(): Promise<void> {
|
||||||
if (!(await offlineReady()) || isOffline()) return;
|
if (!(await offlineReady()) || isOffline()) return;
|
||||||
// What the app asks for when it starts (who is signed in, accounts, folders…).
|
for (const path of STARTUP) await viaWorker(`/api${path}`);
|
||||||
// The page fetched these once on load — possibly before the service worker
|
const settings = await getJson<UserSettings>("/settings");
|
||||||
// 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();
|
|
||||||
const order = settings.thread_order ?? "asc";
|
const order = settings.thread_order ?? "asc";
|
||||||
// The list as the app shows it (newest first is the default), plus the other
|
// 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.
|
// 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") {
|
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) => {
|
await inPool(list.threads, PARALLEL, async (summary) => {
|
||||||
const detail = await api.thread(summary.id, order, INBOX);
|
const detail = await getJson<ThreadDetail>(threadPath(summary.id, order, INBOX));
|
||||||
for (const m of detail.messages) {
|
for (const m of detail.messages) await saveMessage(m);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!isOffline()) setLastSync(Date.now());
|
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. */
|
/** Save the newest Inbox conversations; a no-op unless offline access is on. */
|
||||||
export function prefetchRecent(): Promise<void> {
|
export function prefetchRecent(): Promise<void> {
|
||||||
running ??= run()
|
running ??= run()
|
||||||
.catch(() => {})
|
.catch((err) => console.warn("offline access: saving recent mail failed", err))
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
running = null;
|
running = null;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user