mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
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:
1
changelog.d/fixes/dashboard-stale-tab-refresh.md
Normal file
1
changelog.d/fixes/dashboard-stale-tab-refresh.md
Normal 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).
|
||||
@@ -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`)
|
||||
|
||||
|
||||
46
public/sw.js
46
public/sw.js
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user