Skip to content

Commit 45c7ed1

Browse files
committed
Document why the CWA API must not be SW-cached (#258)
Investigation for #258. The issue proposed SWR runtime caching of the CWA endpoints "with auth/draft responses excluded". Verified against source, that exclusion is not implementable: - Draft and published responses share an identical URL — the primary fetch requests /_/routes/{path} and /_/resource_manifest/{path} with no ?published= marker; the API picks draft-vs-published from the auth cookie. - The API sends no Vary: Cookie, so the Cache API cannot partition anon from authed entries. - Every request is credentials: 'include' and cross-origin to apiUrlBrowser — a readable 200 that Workbox caches happily (it ignores Cache-Control: no-store). - Workbox urlPattern match callbacks are synchronous, so they cannot read auth state (no document.cookie in a SW; cookieStore is async + Chromium-only). So an admin's cached draft could be replayed to the next anonymous visitor on the same device, and a URL denylist cannot fix it — there is no distinguishing URL. Recommendation inverts to app-shell-only + page-side IndexedDB (#257/#259), where auth state is readable. Comments only — no behaviour change. The playground config was already correct (vite-pwa is a playground devDependency, navigateFallback: null, no runtimeCaching); this records the rule so API caching is not added later.
1 parent 60ffec1 commit 45c7ed1

2 files changed

Lines changed: 48 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,8 +413,27 @@ Two phases: (1) reduce the bare `<Spinner>` flicker/layout-shift in `ResourceLoa
413413

414414
**Test-touch log (all approved / additive):** manager-spec mocks gained `routeCache: new Map()` (empty → LRU early-returns; assertions unchanged); `cwa.spec` construction assertion gained the 5th `routeCacheLimit` arg (`undefined` in test). **Guardrail: do not change existing fetcher behaviour tests without asking.**
415415

416-
**[#258](https://github.com/components-web-app/cwa-nuxt-module/issues/258) — DX/Docs: recommended opt-in PWA setup (`@vite-pwa/nuxt`) for offline** (raised 2026-07-09)
417-
**Not module-shipped** (same "no forced transitive deps" principle as #236) — a vetted **opt-in** config demonstrated in the **playground** + **components-web-app** template and documented. `@vite-pwa/nuxt` + Workbox: app-shell precache; SWR/NetworkFirst runtime caching of CWA API endpoints (`/_/routes/`, `/_/resource_manifest/`, resource GETs) — the SW cache is the persistence/offline tier to #257's in-memory hot tier. **⚠️ Critical: do NOT cache authenticated/draft responses** (auth cookies proxied server-side) — public GETs only / vary by cookie / bypass when signed in. Mercure degrades gracefully offline + revalidate on reconnect; prompt-style update UX. IndexedDB store-persistence noted as a lighter no-SW middle ground.
416+
**[#258](https://github.com/components-web-app/cwa-nuxt-module/issues/258) — DX/Docs: recommended opt-in PWA setup (`@vite-pwa/nuxt`) for offline** (raised 2026-07-09) — 🔍 **Investigated 2026-07-17; conclusion INVERTS the original issue. Implementation stays in the template + docs.**
417+
418+
**Not module-shipped** (same "no forced transitive deps" principle as #236). The issue proposed SWR runtime caching of the CWA endpoints "with auth/draft responses excluded". **Verified against source: that exclusion is not implementable, so the recommendation is app-shell-only + IndexedDB.**
419+
420+
**⛔ The CWA API must NOT be runtime-cached in a service worker.** Four verified reasons:
421+
1. **Draft and published responses share an identical URL.** The primary fetch requests `/_/routes/{path}` + `/_/resource_manifest/{path}` (`api/fetcher/fetcher.ts:100-105`) with **no `?published=` marker** — that only appears on nested `publishedResource` props (`fetcher.ts:260`), the model/file/upload endpoints (`composables/cwa-resource-endpoint.ts:27`) and PATCH/POST (`resources/resources-manager.ts:153`). The API picks draft-vs-published from **the auth cookie alone** (cf. the existing comment at `fetcher.ts:259`: *"otherwise client-side auth will get the draft instead"*).
422+
2. **The API sends no `Vary: Cookie`** — only `Vary: path` on ComponentPosition (`ComponentPositionEventListener.php:59`); `max_age`/`shared_max_age: 0` covers only User resources + auth actions, not content. So the Cache API cannot partition anon from authed entries.
423+
3. **Every request is `credentials: 'include'`** (`api/fetcher/cwa-fetch.ts:44`) and cross-origin to `apiUrlBrowser` (`cwa.ts:62`) ⇒ a readable 200 that **Workbox caches happily; Workbox ignores `Cache-Control: no-store`.**
424+
4. **Workbox `urlPattern` match callbacks are synchronous** ⇒ cannot read auth state. A SW has no `document.cookie`; `cookieStore` is async + Chromium-only. `cwa_auth` (`cwa.ts:93`) is page-readable only.
425+
426+
⇒ Admin browses drafts → SW caches them under the public URL → next anonymous visitor on that device/profile gets the **draft**. **A URL denylist cannot fix this — there is no distinguishing URL.** A broad API-origin `urlPattern` would also match the **Mercure SSE stream** and break real-time updates.
427+
428+
**Recommended tiers:** (a) **SW = app shell only** (`globPatterns`, no API `runtimeCaching`); (b) **offline data = page-side IndexedDB persistence of #257's `routeCache`** — already `markRaw`, route-path-keyed, bounded by `routeCacheLimit` (default 50), i.e. already serialisable; the page *can* read auth state, so it can persist only when signed out and purge on sign-in/out (cross-ref #259). **The issue's "IndexedDB = lighter middle ground" framing is backwards — it's the correct tier for CWA data.** (c) SW API caching only becomes safe if the API first sends `Vary: Cookie` + `Cache-Control: private, no-store` — worth raising on the bundle regardless, since **without `Vary: Cookie` any shared HTTP cache/CDN in front of the API has the same leak today**.
429+
430+
**⚠️ `navigateFallback` gotcha:** `@vite-pwa/nuxt` checks `if (!('navigateFallback' in options.workbox))` and defaults it to `'/'`**omitting the key silently serves the `/` shell for every SSR navigation**. *Presence* of the key (`navigateFallback: null`) is what disables it. The playground already does this correctly.
431+
432+
**Mercure offline (reality, not the issue's assumption):** `api/mercure.ts:85-86` assigns **only `onmessage`** — there is **no `onerror`, no reconnect handler, no `online`/`offline` listener** anywhere (grep-verified). So no module-level error spam (it never observes failures; console noise is the browser's native reconnect), but **"revalidate on reconnect" does not exist**. Recovery depends entirely on native EventSource reconnect replaying via the `Last-Event-ID` **header**, which only backfills if the hub runs an event store — otherwise events missed offline are **lost silently and the store stays stale**. (`lastEventId`, line 175, is only a `hubUrl` query param, used solely on an explicit `init(forceRestart)` — sign-in/sign-out.) Minor: the `init()` guard requires `readyState === 1`, so a non-forced `init()` mid-reconnect (`readyState === 0`) tears down the EventSource and discards the browser's internal last-event-id. **Worth its own follow-up issue** — a real gap independent of PWA work and a prerequisite for offline.
433+
434+
**Versions (verified live 2026-07-17):** `@vite-pwa/nuxt@1.1.1`. Nuxt 4 works but is **undeclared** (README/npm still say "Zero-config PWA for Nuxt 3"); real support since v0.9.0 via `compatibility: { nuxt: '>=3.6.5' }`. Update API is **`usePWA()`** (`usedPWAState`/`usePWAState` do not exist); `$pwa` is optional/client-only and `UnwrapNestedRefs`, so **`needRefresh` is a plain boolean, not a ref**. `registerType: 'prompt'` is right for CWA (admins edit inline) but **needs UI** — none exists yet, so the playground stays `autoUpdate`.
435+
436+
**Playground:** already correct — `@vite-pwa/nuxt@^1.1.1` is a **playground devDependency** (never a module dep), with app-shell `globPatterns`, `navigateFallback: null` and **no `runtimeCaching`**. Only explanatory comments were added recording the security rule so nobody "helpfully" adds API caching later.
418437

419438
**[#259](https://github.com/components-web-app/cwa-nuxt-module/issues/259) — Feature: non-reactive cold cache tier for the route cache** (raised 2026-07-09) — deferred follow-up to #257. The route cache keeps cached resources in the **reactive** Pinia `byId`; reactive proxies (≈2–4× raw JSON heap) are wasted work for cached-but-not-displayed resources. **Negligible at the 50-route cap** (~10–25 MB effective). Only worth building when the cache is grown to **hundreds** of pages (offline #258, huge sites) or a mobile heap snapshot shows reactive `byId` > ~50–100 MB. Shape: two-tier resources store — small reactive hot set + non-reactive cold store, rehydrate on revisit. **Measure, then decide.**
420439

playground/nuxt.config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,14 @@ export default defineNuxtConfig({
128128
// Disabling it in tests registers no-op mock imports instead, making mockNuxtImport work.
129129
enabled: !process.env.VITEST,
130130
},
131+
// PWA is opt-in and app-level — @vite-pwa/nuxt is a devDependency of the PLAYGROUND, never of
132+
// the module itself (a module dep would force a service worker on every consuming app — the
133+
// same transitive-dep anti-pattern rejected in #236). See #258 for the full vetted write-up.
131134
pwa: {
132135
disable: import.meta.test,
136+
// NOTE: 'autoUpdate' is a playground convenience. Real apps should use 'prompt' (#258): CWA
137+
// admins edit inline, and an auto-updating SW can swap assets mid-edit. 'prompt' needs UI —
138+
// usePWA() -> $pwa?.needRefresh -> $pwa.updateServiceWorker(true) — gated on $cwa.admin.isEditing.
133139
registerType: 'autoUpdate',
134140
manifest: {
135141
name: 'CWA',
@@ -155,7 +161,28 @@ export default defineNuxtConfig({
155161
],
156162
},
157163
workbox: {
164+
// MUST be written explicitly, even as null. @vite-pwa/nuxt checks
165+
// `if (!('navigateFallback' in options.workbox))` and defaults it to '/', which would serve
166+
// the '/' app shell for every SSR navigation. Presence of the key is what disables it.
158167
navigateFallback: null,
168+
// App-shell precache ONLY. There is deliberately NO `runtimeCaching` for the CWA API.
169+
//
170+
// ⚠️ SECURITY (#258) — do not add one without reading the issue. Draft and published
171+
// responses share an IDENTICAL URL: the primary fetch requests `/_/routes/{path}` and
172+
// `/_/resource_manifest/{path}` (api/fetcher/fetcher.ts) with no `?published=` marker, and
173+
// the API decides draft-vs-published from the auth cookie alone (every request is
174+
// `credentials: 'include'`, api/fetcher/cwa-fetch.ts). The API does not send `Vary: Cookie`,
175+
// so the Cache API cannot partition anon from authed entries, and a Workbox `urlPattern`
176+
// match callback must be SYNCHRONOUS — so it cannot read auth state either (a service
177+
// worker has no `document.cookie`; `cookieStore` is async and Chromium-only).
178+
//
179+
// Net: a URL-keyed SW cache cannot tell an admin's draft from a public response. Caching
180+
// these would let an admin's draft be replayed to the next anonymous visitor on the same
181+
// device/profile. A URL denylist cannot fix this — there is no distinguishing URL.
182+
//
183+
// Also note a broad API-origin urlPattern would match the Mercure SSE stream and break
184+
// real-time updates. Offline data belongs in the page-side IndexedDB tier (#257/#259),
185+
// where auth state IS readable, not in the service worker.
159186
globPatterns: ['**/*.{js,mjs,ts,json,css,html,png,svg,ico,jpg,jpeg,webp}'],
160187
},
161188
client: {

0 commit comments

Comments
 (0)