# Recipe: optional PWA/offline adapter boundary This recipe describes how a hemx app can add a cached shell or offline queue without turning core hemx into a client app framework. Offline/PWA support is opt-in adapter territory: reuse generated targets and server-canonical effects, but keep service workers, queues, conflict policy, and local storage outside `hemx`, `hemx-core`, `hemx-build`, `hemx-derive`, `hemx-axum`, and the tiny runtime. req: canonical_authoring/008 req: canonical_authoring/018 req: canonical_authoring/019 req: runtime/003 req: runtime/004 Use this only after the normal server-first path works. A hemx app is allowed to fail interactions while offline and recover with a full page once the network is back. ## Boundary rule Keep these concerns outside hemx core: - service worker registration and cache policy - local persistence stores such as IndexedDB - offline mutation queues - background sync, retry, and conflict resolution - CRDTs or collaborative sync engines - analytics for offline queue health Keep these concerns in app/integration code: - deciding which pages/assets are safe to cache - deciding which mutations may be queued - serializing a domain command for later replay - reconciling queued commands with server-canonical effect responses - showing generated UI feedback such as "offline", "queued", "synced", or "conflict" The normal path remains server-first typed handlers and generated effects. ## Cached shell A PWA shell may cache page HTML, CSS, the matching `runtime_js_path()` asset, and explicit island scripts for one release. It must obey the same release-unit policy as `docs/recipes/deploy-versioning.md`: cached server HTML and cached runtime assets must be compatible with the server that receives later interactions. req: abi/002 req: abi/004 Recommended behavior: - cache only content-addressed or release-scoped assets - evict cached shells on release/fingerprint mismatch - fall back to a full page GET when unsure - do not patch cached DOM with selector retargeting The service worker is app code. hemx core should not register or own it. ## Offline mutation queue If a mutation is safe to queue, store an app-domain command, not a raw DOM patch or runtime opcode: ```rust #[derive(serde::Serialize, serde::Deserialize)] pub enum OfflineCommand { CreateProject { csrf: CsrfToken, name: ProjectName }, } ``` When the browser is offline, the adapter can add the command to an IndexedDB queue and show generated UI feedback from the app shell: ```rust pub fn queued_project_notice() -> impl IntoEffect { ( dashboard::flash.set("Project will be created when you are back online"), dashboard::live_status.set("Offline: 1 change queued"), ) } ``` When the network returns, replay the command to the normal server endpoint. The server still runs auth/session, CSRF, validation, persistence, and returns the canonical generated effects. req: auth/002 req: auth/004 req: failure/004 Do not store `EffectBatch` as the source of truth for later replay. Effects are UI outcomes for a server decision; queued commands are user intent that the server must validate again. ## Reconciliation The server is authoritative. A replay may succeed, fail validation, fail auth, fail CSRF, or conflict with newer state. The adapter should apply the returned server effects when compatible and otherwise navigate/reload to server-rendered truth. Suggested outcomes: - **success:** apply generated append/replace/remove/summary effects from the server response - **validation failure:** apply generated form error/focus effects - **auth or CSRF failure:** discard or pause the queue and navigate to sign-in or refresh the page - **conflict:** ask the server for the current page/partial and replace a generated target, or show a generated conflict notice - **fingerprint mismatch:** reload/navigate instead of applying queued effects This keeps conflict policy in the app and keeps core runtime selectorless. req: failure/005 ## Optional sync crate shape A future `hemx-sync` or app-local adapter may provide helpers around this model, but it should remain optional and explicit: ```rust pub trait OfflineQueue { async fn push(&self, command: OfflineCommand) -> Result<(), QueueError>; async fn drain(&self, session: CurrentSession) -> Result<(), QueueError>; } ``` Such an adapter may reuse generated slots, forms, and keyed resources, but it must not make every app value a client-side atom or introduce a mandatory local state graph. req: sync/001 req: sync/007 ## Tests Keep tests at the adapter boundary: - offline command is stored as a domain command, not a raw effect - queued command replays through the same handler route as an online submit - server validation and CSRF checks still run during replay - fingerprint/runtime mismatch causes reload/navigation instead of partial apply - conflict response uses generated UI feedback or full page refresh - no selector targeting or client app store is required for normal forms/lists For the current v1 tutorial, `examples/saas` remains the server-first canonical path. Offline/PWA is an optional recipe, not required app scaffolding. req: examples/001 req: test/001