fix(pwa): do not intercept navigations so Chrome can retry HTTP/2 (#12767)

Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

A separação entre o que é do service worker e o que é do Caddy está certa e é o que torna a PR mergeável: o `respondWith` em navegação é defeito nosso, o `Alt-Svc` mentindo h3 é config de proxy reverso e não tem o que fazer aqui.

O `/dashboardfoo` casando com `startsWith("/dashboard")` é um achado à parte, e o bump de cache v2→v3 é o que faz o worker antigo sair do ar nos clientes que já estão presos.
This commit is contained in:
Bob.Hou
2026-09-07 07:57:15 -04:00
committed by GitHub
parent 600abe68d0
commit 6d6b6027c5
5 changed files with 91 additions and 60 deletions

View File

@@ -0,0 +1 @@
- **fix(pwa):** do not intercept dashboard navigations so Chrome can fall back from a stale HTTP/3 Alt-Svc advertisement (UDP :20128 is unpublished; F5 on a long-lived tab hung until a new tab opened a fresh TCP connection).

View File

@@ -58,8 +58,8 @@ OmniRoute includes a service worker (`sw.js`) that provides intelligent caching:
| **App Shell** | Cache-first | `/`, `/offline`, manifest, and icons are pre-cached on install |
| **Static assets** (CSS, JS, images, fonts) | Network-first with cache fallback | Fetches fresh from the network; falls back to cache if offline |
| **Next.js bundles** (`/_next/`) | Network-first with cache update | Fetches from network and updates cache; serves cached version if offline |
| **Navigation requests** | Network-only with offline fallback | Always fetches from network; shows `/offline` page if network is unavailable |
| **API routes** (`/api/`, `/a2a`, `/dashboard/endpoint`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
| **Navigation requests** | Bypass (never intercepted) | Browser owns HTTP/3→HTTP/2 fallback; a dead QUIC socket must not become `Response.error()` |
| **API / dashboard routes** (`/api/`, `/a2a`, `/dashboard`) | Bypass (never cached) | Always goes directly to the server — never intercepted by the service worker |
### Offline Page
@@ -118,7 +118,7 @@ A vanilla service worker (no framework dependencies) with:
- **Install phase**: Pre-caches the app shell (root, offline page, manifest, icons)
- **Activate phase**: Cleans up old cache versions and claims all clients
- **Fetch phase**: Intelligent routing based on request type (navigation, static asset, API)
- **Cache versioning**: `omniroute-pwa-v2` — bump this to force a fresh cache on update
- **Cache versioning**: `omniroute-pwa-v3` — bump this to force a fresh cache on update
### Layout Metadata (`src/app/layout.tsx`)

View File

@@ -1,11 +1,18 @@
const CACHE_NAME = "omniroute-pwa-v2";
const CACHE_NAME = "omniroute-pwa-v3";
const APP_SHELL = [
"/",
"/manifest.webmanifest",
"/icon-512.png",
"/apple-touch-icon.png",
];
const EXCLUDED_PATH_PREFIXES = ["/api/", "/a2a", "/dashboard/endpoint"];
const EXCLUDED_PATH_PREFIXES = ["/api/", "/a2a", "/dashboard"];
function pathIsExcluded(pathname) {
return EXCLUDED_PATH_PREFIXES.some((prefix) => {
const base = prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
return pathname === base || pathname.startsWith(`${base}/`);
});
}
self.addEventListener("install", (event) => {
event.waitUntil(
@@ -37,34 +44,22 @@ self.addEventListener("fetch", (event) => {
const requestUrl = new URL(event.request.url);
const isSameOrigin = requestUrl.origin === self.location.origin;
const isExcludedPath = EXCLUDED_PATH_PREFIXES.some((prefix) =>
requestUrl.pathname.startsWith(prefix)
);
const excluded = pathIsExcluded(requestUrl.pathname);
const isNextAsset = requestUrl.pathname.startsWith("/_next/");
const destination = event.request.destination;
const isStaticAsset = ["style", "script", "image", "font"].includes(destination);
const isNavigateRequest = event.request.mode === "navigate";
// Never intercept navigations. Chrome owns HTTP/3→HTTP/2 fallback after a
// stale Alt-Svc advertisement; respondWith(Response.error()) on a dead QUIC
// socket made F5 hang until a new tab opened a fresh connection.
// Never cache API/dashboard traffic with potentially auth-sensitive content.
if (!isSameOrigin || isExcludedPath) {
if (!isSameOrigin || excluded || isNavigateRequest) {
return;
}
event.respondWith(
(async () => {
if (isNavigateRequest) {
try {
const networkResponse = await fetch(event.request);
if (networkResponse && networkResponse.status === 200) {
const responseClone = networkResponse.clone();
void caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseClone));
}
return networkResponse;
} catch {
return (await navigationFallback(event.request)) || Response.error();
}
}
if (!isStaticAsset) {
return fetch(event.request);
}
@@ -97,19 +92,6 @@ self.addEventListener("fetch", (event) => {
);
});
// Navigations are network-first on purpose: the dashboard is an online
// tool. When the network fails, serving a cached navigation response is
// worse than surfacing the failure -- the cached shell references
// /_next/static/<old-build-id>/ chunks that no longer exist after a
// deploy, so the page loads and then breaks on chunk 404s, and the
// broken state sticks until the cache happens to clear. An honest
// network error lets the browser show its own offline state and
// recover on the next reload. (The /offline page is still precached
// for the APP_SHELL list; it just is no longer used as a decoy.)
async function navigationFallback() {
return Response.error();
}
// ── Push Notifications ───────────────────────────────────────────────────────
self.addEventListener("push", (event) => {

View File

@@ -586,8 +586,8 @@ function stampServiceWorkerBuildId(resolvedOutDir) {
process.env.OMNIROUTE_SW_BUILD_ID || process.env.SOURCE_VERSION || String(Date.now());
let sw = fsSync.readFileSync(swDest, "utf8");
sw = sw.replace(
/^const CACHE_NAME = "omniroute-pwa-v2";$/m,
`const CACHE_NAME = "omniroute-pwa-v2-${buildId}"; // build ${buildId}`
/^const CACHE_NAME = "omniroute-pwa-v3";$/m,
`const CACHE_NAME = "omniroute-pwa-v3-${buildId}"; // build ${buildId}`
);
fsSync.writeFileSync(swDest, sw);
}

View File

@@ -39,7 +39,7 @@ function createServiceWorkerHarness() {
const caches = {
delete: async () => true,
keys: async () => ["omniroute-pwa-v2"],
keys: async () => ["omniroute-pwa-v3"],
match: async (request: Request | string) =>
cacheEntries.get(typeof request === "string" ? request : request.url),
open: async () => cache,
@@ -77,8 +77,7 @@ function createServiceWorkerHarness() {
},
};
listener(event);
assert.ok(responsePromise, "navigate request must call respondWith");
return responsePromise;
return { intercepted: responsePromise !== undefined, response: responsePromise };
},
setFetch: (nextFetch: (request: Request) => Promise<Response>) => {
fetchImpl = nextFetch;
@@ -86,44 +85,93 @@ function createServiceWorkerHarness() {
};
}
test("#11779: network failure on navigation surfaces an error, not a stale shell", async () => {
test("#11779: dashboard navigations are not intercepted so the browser can retry HTTP/2", async () => {
const harness = createServiceWorkerHarness();
const request = {
url: "https://app.example/dashboard",
url: "https://app.example/dashboard/quota",
method: "GET",
mode: "navigate",
destination: "document",
};
// A cached navigation response from a PREVIOUS deploy exists; serving it
// would replay a shell whose /_next/static/<old-build>/ chunks are gone.
// A cached navigation response from a PREVIOUS deploy exists. The worker
// must not intercept at all — Chrome then owns HTTP/3→HTTP/2 fallback
// after a stale Alt-Svc advertisement. Intercepting and returning
// Response.error() is what made F5 hang until a new tab opened.
harness.cacheEntries.set(request.url, new Response("cached dashboard", { status: 200 }));
const response = await harness.dispatchFetch(request);
assert.ok(response.type === "error", "navigation fallback must surface the network failure");
const result = await harness.dispatchFetch(request);
assert.equal(result.intercepted, false, "dashboard navigation must fall through to the browser");
});
test("#5165: successful navigations are cached for later transient failures", async () => {
test("#11779: API and Next assets still go through the worker", async () => {
const harness = createServiceWorkerHarness();
const request = {
harness.setFetch(async () => new Response("ok", { status: 200 }));
const api = await harness.dispatchFetch({
url: "https://app.example/api/health/ping",
method: "GET",
mode: "cors",
destination: "",
});
assert.equal(api.intercepted, false, "/api/ is already excluded");
const asset = await harness.dispatchFetch({
url: "https://app.example/_next/static/chunk.js",
method: "GET",
mode: "cors",
destination: "script",
});
assert.equal(asset.intercepted, true);
assert.equal(await (await asset.response!).text(), "ok");
});
test("#11779: /dashboardfoo is not treated as a dashboard path", async () => {
const harness = createServiceWorkerHarness();
harness.setFetch(async () => new Response("ok", { status: 200 }));
const result = await harness.dispatchFetch({
url: "https://app.example/dashboardfoo",
method: "GET",
mode: "cors",
destination: "script",
});
assert.equal(result.intercepted, true, "prefix match must not swallow /dashboardfoo");
const dash = await harness.dispatchFetch({
url: "https://app.example/dashboard",
method: "GET",
mode: "navigate",
destination: "document",
mode: "cors",
destination: "script",
});
assert.equal(dash.intercepted, false, "/dashboard exact must stay excluded");
const nested = await harness.dispatchFetch({
url: "https://app.example/dashboard/quota",
method: "GET",
mode: "cors",
destination: "script",
});
assert.equal(nested.intercepted, false, "/dashboard/quota must stay excluded");
});
test("#5165: static assets stay cache-first; navigations do not", async () => {
const harness = createServiceWorkerHarness();
const icon = {
url: "https://app.example/icon-512.png",
method: "GET",
mode: "cors",
destination: "image",
};
harness.setFetch(async () => new Response("fresh dashboard", { status: 200 }));
assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard");
harness.setFetch(async () => new Response("fresh icon", { status: 200 }));
const first = await harness.dispatchFetch(icon);
assert.equal(first.intercepted, true);
assert.equal(await (await first.response!).text(), "fresh icon");
harness.setFetch(async () => {
throw new Error("transient network failure");
});
// #11779: the transient failure surfaces as an error response. The freshly
// cached entry stays in the cache (a future navigations-are-cached design
// may use it), but it must NOT be replayed as a navigation response -- the
// shell's chunk references belong to a specific build.
const failed = await harness.dispatchFetch(request);
assert.ok(failed.type === "error", "transient failure must surface, not replay cache");
const second = await harness.dispatchFetch(icon);
assert.equal(second.intercepted, true);
assert.equal(await (await second.response!).text(), "fresh icon");
});