{"id":24300552,"url":"https://github.com/dwyl/pwa-liveview","last_synced_at":"2026-04-17T08:04:01.379Z","repository":{"id":270142868,"uuid":"909421270","full_name":"dwyl/PWA-Liveview","owner":"dwyl","description":"PWA demo with Phoenix Liveview","archived":false,"fork":false,"pushed_at":"2025-01-15T17:04:44.000Z","size":750,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":4,"default_branch":"main","last_synced_at":"2025-01-15T19:36:00.403Z","etag":null,"topics":["elixir","leaflet","phoenix-liveview","pwa-apps","solidjs","wasm","webassembly","y-indexeddb","yjs"],"latest_commit_sha":null,"homepage":"","language":null,"has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/dwyl.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2024-12-28T16:49:09.000Z","updated_at":"2025-01-15T17:04:45.000Z","dependencies_parsed_at":"2024-12-28T19:28:13.668Z","dependency_job_id":"19f97ceb-ad22-47ac-9a19-ed10f0e0675a","html_url":"https://github.com/dwyl/PWA-Liveview","commit_stats":null,"previous_names":["dwyl/pwa-liveview"],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dwyl%2FPWA-Liveview","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dwyl%2FPWA-Liveview/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dwyl%2FPWA-Liveview/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/dwyl%2FPWA-Liveview/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/dwyl","download_url":"https://codeload.github.com/dwyl/PWA-Liveview/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":242213727,"owners_count":20090699,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elixir","leaflet","phoenix-liveview","pwa-apps","solidjs","wasm","webassembly","y-indexeddb","yjs"],"created_at":"2025-01-16T23:14:36.982Z","updated_at":"2026-04-17T08:04:01.364Z","avatar_url":"https://github.com/dwyl.png","language":null,"funding_links":[],"categories":[],"sub_categories":[],"readme":"# Offline first Phoenix LiveView PWA\n\nAn example of a real-time, collaborative multi-page web app built with `Phoenix LiveView` designed for offline-first ready; it is packaged as a [PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps).\n\nWhile the app supports full offline interaction and local persistence using CRDTs (via `Yjs` and `y-indexeddb`), the core architecture is still grounded in a server-side source of truth. The server database ultimately reconciles all updates, ensuring consistency across clients.\n\nThis design enables:\n\n✅ Full offline functionality and interactivity\n\n✅ Real-time collaboration between multiple users\n\n✅ Reconciliation with a central, trusted source of truth when back online\n\n\u003e A page won't be cached if it is not visited. This is because we don't want ot preload pages as it will capture an uotdated CSRF token.\n\n## Architecture at a glance\n\n- Client-side CRDTs (`Yjs`) manage local state changes (e.g. counter updates), even when offline\n- Server-side database (`Postgres` or `SQLite`) remains authoritative\n- When the client reconnects, local CRDT updates are synced with the server:\n\n  - In one page, via `Postgres` and `Phoenix.Sync` wit logical replication\n  - In another, via `SQLite` using a `Phoenix.Channel` message\n\n- Offline first solutions naturally offloads the reactive UI logic to JavaScript. We used `SolidJS`.\n- It uses `Vite` as the bundler. The `vite-plugin-pwa` registers a Service Worker to cache app shell and assets for offline usage.\n\n## How it works\n\n### Optimistic Updates with Centralized Reconciliation\n\nAlthough we leverage Yjs (a CRDT library) under the hood, this isn’t a fully peer-to-peer, decentralized CRDT system. Instead, in this demo we have:\n\n- No direct client-to-client replication (not pure lazy/optimistic replication).\n- No concurrent writes against the same replica—all operations are serialized through the server.\n- A _centralized authoritative server_.\n\nWrites are _serialized_ but actions are concurrent. What we do have is _asynchronous reconciliation_ with an [operation-based CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type#Counters) approach:\n\n- User actions (e.g. clicking “decrement” on the counter) are applied locally to a `Yjs` document stored in `IndexedDB`.\n- The same operation (not the full value) is sent to the server via `Phoenix` (either `Phoenix.Sync` or a `Phoenix.Channel`).\n- `Phoenix` broadcasts that op to all connected clients.\n- Upon receipt, each client applies the op to its local `Yjs` document—order doesn’t matter, making it commutative.\n- The server database (`Postgres` or `SQLite`) remains the single source of truth and persists ops in sequence.\n\n\u003e In CRDT terms: We use an operation-based CRDT (CRDT Counter) for each shared value Ops commute (order-independent) even though they pass through a central broker.\n\n### Rendering Strategy: SSR vs. Client-Side Hooks\n\nTo keep the UI interactive both online and offline, we mix `LiveView`’s server-side rendering (SSR) with a client-side reactive framework. We used `SolidJS` because it's lightweight, no virtual DOM, and has simple a simple primitives  (`render`, `createSignal`) when we want to inject such a component into the DOM.\n\n- Online (`LiveView` SSR or JS-hooks):\n\n  - The PhxSync page renders a LiveView using `streams` and the \"click\" event sends data to the client to update the local `Yjs` document.\n  - The YjsCh page renders a JS-hook which initialises a `SolidJS` component. In the JS-hook, the `SolidJS` communicates via a Channel to update the database and the local `Yjs` document.\n\n- Offline (Manual Rendering)\n  - We detect the status switch via a server polling.\n  - We retrive the HTML document from the `Cache API`.\n  - We update the current DOM with the cached HTML and inject the correct JS component.\n  - The component reads from and writes to the local `Yjs`+`IndexedDB` replica and remains fully interactive.\n\n### Service Worker \u0026 Asset Caching\n\n`vite-plugin-pwa` generates a Service Worker that:\n\n- Pre-caches the app shell (HTML, CSS, JS) on install.\n- Intercepts navigations to serve the cached app shell for offline-first startup.\n\nThis ensures the entire app loads reliably even without network connectivity.\n\n## Results\n\nDeployed on `Fly.io`: \u003chttps://liveview-pwa.fly.dev/\u003e\n\nThe standalone PWA is 2.1 MB (page weigth).\n\n## Table of Contents\n\n- [Offline first Phoenix LiveView PWA](#offline-first-phoenix-liveview-pwa)\n  - [Architecture at a glance](#architecture-at-a-glance)\n  - [How it works](#how-it-works)\n    - [Optimistic Updates with Centralized Reconciliation](#optimistic-updates-with-centralized-reconciliation)\n    - [Rendering Strategy: SSR vs. Client-Side Hooks](#rendering-strategy-ssr-vs-client-side-hooks)\n    - [Service Worker \\\u0026 Asset Caching](#service-worker--asset-caching)\n  - [Results](#results)\n  - [Table of Contents](#table-of-contents)\n  - [What?](#what)\n  - [Why?](#why)\n  - [Design goals](#design-goals)\n  - [Common pitfall of combining LiveView with CSR components](#common-pitfall-of-combining-liveview-with-csr-components)\n  - [Tech overview](#tech-overview)\n    - [Implementation highlights](#implementation-highlights)\n  - [About the Yjs-Stock page](#about-the-yjs-stock-page)\n  - [About PWA](#about-pwa)\n    - [Updates life-cycle](#updates-life-cycle)\n  - [Usage](#usage)\n  - [Details of Pages](#details-of-pages)\n    - [Yjs-Ch and PhxSync stock \"manager\"](#yjs-ch-and-phxsync-stock-manager)\n    - [Pg-Sync-Stock](#pg-sync-stock)\n    - [FlightMap](#flightmap)\n  - [Login](#login)\n  - [Navigation](#navigation)\n  - [Vite](#vite)\n    - [Package.json and `pnpm` workspace (nor not)](#packagejson-and-pnpm-workspace-nor-not)\n    - [Phoenix live\\_reload](#phoenix-live_reload)\n    - [HMR in DEV mode](#hmr-in-dev-mode)\n    - [\"env\" config](#env-config)\n    - [Root layout in :dev/:prod setup](#root-layout-in-devprod-setup)\n    - [Tailwind v4](#tailwind-v4)\n    - [Resolve assets with Vite config](#resolve-assets-with-vite-config)\n    - [Optmise CSS with `lightningCSS` in prod mode](#optmise-css-with-lightningcss-in-prod-mode)\n    - [Client Env](#client-env)\n    - [Static assets](#static-assets)\n      - [`Vite.ex` module](#viteex-module)\n      - [Static copy](#static-copy)\n      - [DEV mode](#dev-mode)\n      - [PROD mode](#prod-mode)\n    - [Performance optimisation: Dynamic CSS loading](#performance-optimisation-dynamic-css-loading)\n    - [VitePWA plugin and Workbox Caching Strategies](#vitepwa-plugin-and-workbox-caching-strategies)\n  - [Yjs](#yjs)\n  - [Misc](#misc)\n    - [Presence through Live-navigation](#presence-through-live-navigation)\n    - [Manifest](#manifest)\n    - [Page Caching](#page-caching)\n  - [Publish](#publish)\n  - [Postgres setup to use Phoenix.Sync](#postgres-setup-to-use-phoenixsync)\n  - [Fly volumes](#fly-volumes)\n  - [Documentation source](#documentation-source)\n  - [Resources](#resources)\n  - [License](#license)\n  - [Enhance](#enhance)\n\n## What?\n\n**Context**: we want to experiment PWA collaborative webapps using Phoenix LiveView.\n\nWhat are we building? A three pages webap:\n\n1. We mimic a stock manager in two versions. Every user can pick from the stock which is broadcasted and synced to the databased. The picked amounts are cumulated when offline and the database is synced and state reconciliation.\n   - PgSync-Stock page features `phoenix_sync` in _embedded_ mode streaming logical replicates of a Postgres table.\n   - Yjs-Channel page features 'Sqlite` used as a backup via a Channel.\n2. FlightMap. This page proposes an interactive map with a form with two inputs where **two** users can edit collaboratively a form to display markers on the map and then draw a great circle between the two points.\n\n## Why?\n\nTraditional Phoenix LiveView applications face several challenges in offline scenarios:\n\nLiveView's WebSocket architecture isn't naturally suited for PWAs, as it requires constant connection for functionality.\n\nIt is challenging to maintain consistent state across network interruptions between the client and the server.\n\nSince we need to setup a Service Worker to cache HTML pages and static assets to work offline, we need a different bundler from the one used by default with `LiveView`.\n\n## Design goals\n\n- **collaborative** (online): Clients sync via _pubsub updates_ when connected, ensuring real-time consistency.\n- **optimistic UI**: The function \"click on stock\" assumes success and will reconciliate later.\n- **database**:\n  - We use `SQLite` as the \"canonical\" source of truth for the Yjs-Stock counter.\n  - `Postgres` is used for the `Phoenix_sync` process for the PgSync-Stock counter.\n- **Offline-First**: The app remains functional offline (through the `Cache` API and reactive JS components), with clients converging to the correct state on reconnection.\n- **PWA**: Full PWA features, meaning it can be _installed_ as a standalone app and can be _updated_. A `Service Worker` runs in a separate thread and caches the assets. It is setup with `VitePWA`.\n\n## Common pitfall of combining LiveView with CSR components\n\nThe client-side rendered components are - when online - mounted via hooks under the tag `phx-update=\"ignore\"`.\n\nThese components have they own lifecycle. They can leak or stack duplicate components if you don't cleanup them properly.\nThe same applies to \"subscriptions/observers\" primitives from (any) the state manager. You must _unsubscribe_, otherwise you might get multiples calls and weird behaviours.\n\n⭐️ LiveView hooks comes with a handy lifecyle and the `destroyed` callback is essential.\n\n`SolidJS` makes this easy as it can return a `cleanupSolid` callback (where you take a reference to the SolidJS component in the hook).\nYou also need to clean _subscriptions_ (when using a store manager).\n\nThe same applies when you navigate offline; you have to run cleanup functions, both on the components and on the subsriptions/observers from the state manager.\n\n## Tech overview\n\n| Component                  | Role                                                                                                              |\n| -------------------------- | ----------------------------------------------------------------------------------------------------------------- |\n| Vite                       | Build and bundling framework                                                                                      |\n| SQLite                     | Embedded persistent storage of latest Yjs document                                                                |\n| Postgres                   | Supports logical replication                                                                                      |\n| Phoenix LiveView           | UI rendering, incuding hooks                                                                                      |\n| Phoenix.Sync               | Relays Postgres streams into LiveView                                                                             |\n| PubSub / Phoenix.Channel   | Broadcast/notifies other clients of updates / conveys CRDTs binaries on a separate websocket (from te LiveSocket) |\n| Yjs / Y.Map                | Holds the CRDT state client-side (shared)                                                                         |\n| y-indexeddb                | Persists state locally for offline mode                                                                           |\n| Valtio                     | Holds local ephemeral state                                                                                       |\n| Hooks                      | Injects communication primitives and controls JavaScript code                                                     |\n| Service Worker / Cache API | Enable offline UI rendering and navigation by caching HTML pages and static assets                                |\n| SolidJS                    | renders reactive UI using signals, driven by Yjs observers                                                        |\n| Leaflet                    | Map rendering                                                                                                     |\n| MapTiler                   | enable vector tiles                                                                                               |\n| WebAssembly container      |  high-performance calculations for map \"great-circle\" routes use `Zig` code compiled to `WASM`                    |\n\n### Implementation highlights\n\nWe use different approaches based on the page requirements:\n\n1. Yjs-Channel: the counter is a reactive component rendered via a hook by `SolidJS`. When offline, we render the component directly.\n2. PhxSync: the counter is rendered by LiveView and receives `Psotgres` streams. When offline, we render the exact same component directly.\n3. The source of truth is the database. Every client has a local replica (`IndexedDB`) which handles offline changes and gets updates when online.\n4. FlightMap. Local state management (`Valtio`) for the collaborative Flight Map page without server-side persistence of the state nor client-side.\n\n- **Build tool**:\n  We use Vite as the build tool to bundle and optimize the application and enable PWA features seamlessly.\n  The Service Worker to cache HTML pages and static assets.\n\n- **reactive JS components**:\n  Every reactive component works in the following way. Local changes fomr within the component mutate YDoc and an `yjs`-listener will update the component state to render. Any received remote change mutates the `YDoc`, thus triggers the component rendering.\n\n- **FlightMap page**:\n  We use a local state manager (`Valtio` using proxies).\n  The inputs (selected airports) are saved to a local state.\n  Local UI changes mutate the state and are sent to the server. The server broadcasts the data.\n  We have state observers which update the UI if the origin is not remote.\n\n- **Component Rendering Strategy**:\n  - online: use LiveView hooks\n  - offline: hydrate the HTML with cached documents and run reactive JavaScript components\n\n## About the Yjs-Stock page\n\n  ```mermaid\n  ---\n  title: \"SQLite \u0026 Channel \u0026 YDoc Implementation\"\n  ---\n  flowchart\n      YDoc(YDoc \u003cbr\u003eIndexedDB)\n      Channel[Phoenix Channel]\n      SQLite[(SQLite DB)]\n      Client[Client]\n\n      Client --\u003e|Local update| YDoc\n      YDoc --\u003e|Send ops| Channel\n      Channel --\u003e|Update counter| SQLite\n      SQLite --\u003e|Return new value| Channel\n      Channel --\u003e|Broadcast| Client\n      Client --\u003e|Remote Update| YDoc\n\n      YDoc -.-\u003e|Reconnect \u003cbr\u003e send stored ops| Channel\n\n\n  style YDoc fill:#e1f5fe\n  style Channel fill:#fff3e0\n  ```\n\n\u003cbr/\u003e\n\n```mermaid\n---\ntitle: \"Postgres \u0026 Phoenix_Sync \u0026 YDoc Implementation\"\n---\nflowchart\n    YDoc[YDoc \u003cbr\u003e IndexedDB]\n    PG[(Postgres DB)]\n    PhoenixSync[Phoenix_Sync\u003cbr/\u003eLogical Replication]\n    Client[Client]\n\n    Client --\u003e|update local| YDoc\n    YDoc --\u003e|Send ops| PG\n    PG --\u003e|Logical replication| PhoenixSync\n    PhoenixSync --\u003e|Stream changes| Client\n    Client --\u003e|Remote Update| YDoc\n\n    YDoc -.-\u003e|Reconnect \u003cbr\u003e send stored ops| PG\n\nstyle YDoc fill:#e1f5fe\nstyle PhoenixSync fill:#fff3e0\nstyle PG fill:#f3e5f5\n\n```\n\n## About PWA\n\nA Progressive Web App (PWA) is a type of web application that provides an app-like experience directly in the browser.\n\nIt has:\n\n- offline support\n- is \"installable\":\n\n\u003cimg width=\"135\" alt=\"Screenshot 2025-05-08 at 22 02 40\" src=\"https://github.com/user-attachments/assets/dddaaac7-9255-419b-a5ad-44a2a891e93a\" /\u003e\n\u003cbr/\u003e\n\nThe core components are setup using `Vite` in the _vite.config.js_ file.\n\n- **Service Worker**:\n  A background script - separate thread - that acts as a proxy: intercepts network requests and enables offline caching and background sync.\n  We use the `VitePWA` plugin to enable the Service Worker life-cycle (manage updates)\n\n- Web App **Manifest** (manifest.webmanifest)\n  A JSON file that defines the app’s name, icons, theme color, start URL, etc., used to install the webapp.\n  We produce the Manifest with `Vite` via in the \"vite.\n\n- HTTPS (or localhost):\n  Required for secure context: it enables Service Workers and trust.\n\n`Vite` builds the SW for us via the `VitePWA` plugin by declarations in \"vite.config.js\". Check [Vite](#vite)\n\nThe SW is started by the main script, early, and must preload all the build static assets as the main file starts before the SW runtime caching is active.\n\nSince we want offline navigation, we precache the rendered HTML as well.\n\n### Updates life-cycle\n\nA Service Worker (SW) runs in a _separate thread_ from the main JS and has a unique lifecycle made of 3 key phases: install / activate / fetch\n\nIn action:\n\n1. Make a change in the client code, git push/fly deploy:\n   -\u003e a button appears and the dev console shows a push and waiting stage:\n\n\u003cimg width=\"1413\" alt=\"Screenshot 2025-05-08 at 09 40 28\" src=\"https://github.com/user-attachments/assets/a4086fe3-4952-48de-818c-b12fe1819823\" /\u003e\n\u003cbr/\u003e\n\n2. Click the \"refresh needed\"\n   -\u003e the Service Worker and client claims are updated seamlessly, and the button is in the hidden \"normal\" state.\n\n\u003cimg width=\"1414\" alt=\"Screenshot 2025-05-08 at 09 41 55\" src=\"https://github.com/user-attachments/assets/7687fd61-f5b8-4298-ab96-144cdb297e6e\" /\u003e\n\u003c/br\u003e\n\nService Workers don't automatically update unless:\n\n- The sw.js file has changed (based on byte comparison).\n\n- The browser checks periodically (usually every 24 hours).\n\n- When a new SW is detected:\n\n  - New SW enters installing state.\n\n  - It waits until no existing clients are using the old SW.\n\n  - Then it activates.\n\n```mermaid\nsequenceDiagram\n  participant User\n  participant Browser\n  participant App\n  participant OldSW as Old Service Worker\n  participant NewSW as New Service Worker\n\n  Browser-\u003e\u003eOldSW: Control App\n  App-\u003e\u003eBrowser: registerSW()\n\n  App-\u003e\u003eApp: code changes\n  Browser-\u003e\u003eNewSW: Downloads New SW\n  NewSW-\u003e\u003eBrowser: waiting phase\n  NewSW--\u003e\u003eApp: message: onNeedRefresh()\n  App-\u003e\u003eUser: Show \u003cbutton\u003e onNeedRefresh()\n  User-\u003e\u003eApp: Clicks Update Button\n  App-\u003e\u003eNewSW: skipWaiting()\n  NewSW-\u003e\u003eBrowser: Activates\n  NewSW-\u003e\u003eApp: Takes control (via clients.claim())\n```\n\n## Usage\n\n```elixir\n# mix.exs\n{:exqlite, \"0.30.1\"},\n{:req, \"~\u003e 0.5.8\"},\n{:nimble_csv, \"~\u003e 1.2\"},\n{:postgrex, \"~\u003e 0.20.0\"},\n{:electric, \"~\u003e 1.0.13\"},\n{:phoenix_sync, \"~\u003e 0.4.3\"},\n```\n\nClient package are setup with `pnpm`: check [▶️ package.json](https://github.com/dwyl/PWA-Liveview/blob/main/assets/package.json)\n\n1/ **dev** setup with _IEX_ session\n\n```sh\n# install all dependencies including Vite\nmix deps.get\nmix ecto.create \u0026\u0026 mix ecto.migrate\npnpm install --prefix assets\n# start Phoenix server, it will also compile the JS\niex -S mix phx.server\n```\n\n2/ Run a local Docker container in **mode=prod**\n\nFirstly setup Postgres in _logical replication_ mode.\n\n```yml\nservices:\n  pg:\n    image: postgres:17\n    container_name: pg17\n    environment:\n      # PostgreSQL environment variables are in the form POSTGRES_*\n      POSTGRES_PASSWORD: 1234\n      POSTGRES_USER: postgres\n      POSTGRES_DB: elec_prod\n    healthcheck:\n      test: [\"CMD-SHELL\", \"pg_isready -U postgres\"] # \u003c- !! admin user is \"postgres\"\n      interval: 5s\n      timeout: 5s\n      retries: 10\n    volumes:\n      - pgdata:/var/lib/postgresql/data\n    ports:\n      - \"5000:5432\"\n\n    command: # \u003c- set variables via a command\n      - -c\n      - listen_addresses=*\n      - -c\n      - wal_level=logical\n      - -c\n      - max_wal_senders=10\n```\n\n\u003cbr/\u003e\n\nThis also opens the port 5000 for inspection via the `psql` client.\n\nIn another terminal, you can do:\n\n```sh\n\u003e PGPASSWORD=1234 psql -h localhost -U postgres -d elec_prod -p 5000\n\n# psql (17.5 (Postgres.app))\n# Type \"help\" for help.\n\nelec_prod=#\n```\n\nand paste the command to check:\n\n```sh\nelec_prod=# select name, setting from pg_settings where name in ('wal_level','max_worker_processes','max_replication_slots','max_wal_senders','shared_preload_libraries');\n           name           | setting\n--------------------------+---------\n max_replication_slots    | 10\n max_wal_senders          | 10\n max_worker_processes     | 8\n shared_preload_libraries |\n wal_level                | logical\n(5 rows)\n```\n\nYou can run safely (meaning the migrations will run or not, and complete):\n\n[▶️ Dockerfile](https://github.com/dwyl/PWA-Liveview/blob/main/Dockerfile)\n\n[▶️ docker-compose.yml](https://github.com/dwyl/PWA-Liveview/blob/main/docker-compose.yml)\n\n```sh\ndocker compose up --build\n```\n\n\u003e You can take a look at the build artifacts by running into another terminal\n\n```sh\n\u003e docker compose exec -it web cat  lib/solidyjs-0.1.0/priv/static/.vite/manifest.json\n```\n\n## Details of Pages\n\n### Yjs-Ch and PhxSync stock \"manager\"\n\nYou click on a counter and it goes down..! The counter is broacasted and handled by a CRDT backed into a SQLite table.\nA user can click offline, and on reconnection, all clients will get updated with the lowest value (business rule).\n\n\u003cimg width=\"1404\" alt=\"Screenshot 2025-05-08 at 22 05 15\" src=\"https://github.com/user-attachments/assets/ba8373b5-defc-40f9-b497-d0086eb10ccc\" /\u003e\n\u003cbr/\u003e\n\n### Pg-Sync-Stock\n\nAvailable at \"/elec\"\n\n### FlightMap\n\nAvailable at `/map`.\n\n\u003e ! It uses a free tier of Maptiler, so might not available!\n\nIt displays an _interactive_ and _collaborative_ (two-user input) route planning with vector tiles.\nThe UI displays a form with two inputs, which are pushed to Phoenix and broadcasted via Phoenix PubSub. A marker is drawn by `Leaflet` to display the choosen airport on a vector-tiled map using `MapTiler`.\n\n\u003cimg width=\"1398\" alt=\"Screenshot 2025-05-08 at 22 06 29\" src=\"https://github.com/user-attachments/assets/1c5a82b2-8302-44a4-93dd-87ac215105e3\" /\u003e\n\u003cbr/\u003e\n\nKey features:\n\n- collaborative input\n- `Valtio`-based _local_ (browser only) ephemeral state management (no complex conflict resolution needed)\n- WebAssembly-powered great circle calculations: CPU-intensive calculations works offline\n- Efficient map rendering with MapTiler and _vector tiles_ with smaller cache size (vector data vs. raster image files)\n\n\u003e [**Great circle computation**] It uses a WASM module. `Zig` is used to compute a \"great circle\" between two points, as a list of `[lat, long]` spaced by 100km. The `Zig` code is compiled to WASM and available for the client JavaScript to run it. Once the list of successive coordinates are in JavaScript, `Leaflet` can use it to produce a polyline and draw it into a canvas. We added a WASM module to implement great circle route calculation as a showcase of WASM integration. A JAvascript alternative would be to use [turf.js](https://turfjs.org/docs/api/greatCircle).\n\u003e check the folder \"/zig-wasm\"\n\n\u003e [**Airport dataset**] We use a dataset from \u003chttps://ourairports.com/\u003e. We stream download a CSV file, parse it (`NimbleCSV`) and bulk insert into an SQLite table. When a user mounts, we read from the database and pass the data asynchronously to the client via the liveSocket on the first mount. We persist the data in `localStorage` for client-side search. The socket \"airports\" assign is then pruned to free the server's socket.\n\n▶️ [Airports](\u003c(https://github.com/dwyl/PWA-Liveview/blob/main/lib/LiveviewPwa/db/Airports.ex)\u003e), [LiveMap](https://github.com/dwyl/PWA-Liveview/blob/main/lib/LiveviewPwaweb/live/live_map.ex)\n\n\u003e The Websocket is configured with `compress: true` (cf \u003chttps://hexdocs.pm/phoenix/Phoenix.Endpoint.html#socket/3-websocket-configuration\u003e) to enable compression of the 1.1MB airport dataset through the LiveSocket.\n\nBelow a diagram showing the flow between the database, the server and the client.\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant LiveView\n    participant Database\n\n    Client-\u003e\u003eLiveView: mount (connected)\n    Client-\u003e\u003eClient: check localStorage/Valtio\n    alt cached data exists and valid\n        Client-\u003e\u003eLiveView: \"cache-checked\" (cached: true)\n        LiveView-\u003e\u003eClient: verify hash\n    else no valid cache\n        Client-\u003e\u003eLiveView: \"cache-checked\" (cached: false)\n        LiveView-\u003e\u003eDatabase: fetch_airports()\n        Database--\u003e\u003eLiveView: airports data\n        LiveView-\u003e\u003eClient: \"airports\" event with data\n        Client-\u003e\u003eClient: update localStorage + Valtio\n    end\n```\n\n## Login\n\nThe flow is:\n\n- Live login page =\u003e POST controller =\u003e redirect to a logged-in LiveView =\u003e authorized to \"live_navigate\"\n\nIt displays a dummy login, just to assign a (auto-incremented) user_id and an \"access\\_\\_token\".\n\nThe _access token_ is passed into the session, thus avialable in the LiveView.\n\nWe use the _csrsf token_ to build the custom \"userSocket\".\nWith this, you get the session via the `connect_info` in the `connect/3` of \"UserSocket\".\nYou can now check if the token is expired or not, and against the database.\n\n## Navigation\n\nThe user use \"live navigation\" when online between two pages which use the same _live_session_, with no full page reload.\n\nWhen the user goes offline, we have the same smooth navigation thanks to navigation hijack an the HTML and assets caching, as well as the usage of `y-indexeddb`.\n\nWhen the user reconnects, we have a full-page reload.\n\nThis is implemented in [navigate.js](https://github.com/dwyl/PWA-Liveview/blob/main/assets/js/utilities/navigate.js).\n\n**Lifecycle**:\n\n- Initial Load: App start the LiveSocket and attaches the hooks. It will then trigger a continous server check.\n- Going Offline: Triggers component initialization and navigation setup\n- Navigating Offline: cleans up components, _fetch_ the cached pages (request proxied by the SW and the page are cached iva the `additionalManifestEntries`), parse ahd hydrate the DOM to renders components\n- Going Online: when the polling detects a transistion off-\u003eon, the user expects a page refresh and Phoenix LiveView reinitializes.\n\n**Key point**:\n\n- ⚠️ **memory leaks**:\n  With this offline navigation, we never refresh the page. As said before, reactive components and subscriptions need to be cleaned before disposal. We store the cleanup functions and the subscriptions.\n\n## Vite\n\nSource: \u003c https://vite.dev/guide/backend-integration.html\u003e\n\nAll the client code is managed by `Vite` and done in the file [vite.config.js](https://github.com/dwyl/PWA-Liveview/blob/main/assets/vite.config.js).\n\n### Package.json and `pnpm` workspace (nor not)\n\nYou can use workspace.\nFrom the root folder, add a file \"pnpm-workpsace.yaml\" (not \"yml\" !).\nYou reference the \"assets\" folder and the \"deps\" folder.\n\n```yaml\npackages:\n  - assets\n  - deps/phoenix\n  - deps/phoenix_html\n  - deps/phoenix_live_view\n```\n\nThen go to the \"assets\"folder, and:\n\n- run `pnpm init`, \n- add the packages you want, eg `pnpm add -D taildwindcss @tailwindcss/vite`.\n\n```json\n{\n  \"dependencies\": {\n    \"phoenix\": \"workspace:*\",\n    \"phoenix_html\": \"workspace:*\",\n    \"phoenix_live_view\": \"workspace:*\", \n    \"topbar\": \"^3.0.0\"\n    };\n  \"devDependencies\": {\n    \"vite\": \"npm:rolldown-vite@^6.3.21\",\n    \"@tailwindcss/vite\": \"^4.1.11\",\n    \"tailwindcss\": \"^4.1.11\",\n    \"daisyui\": \"^5.0.43\",\n    [...]\n  }\n}\n```\n\nThen, return to the root folder and run `pnpm i`.\n\nAlternatively, you may _not use workspace_ and set directly reference `phoenix` with:\n\n```json\n{\n  \"dependencies\": {\n    \"phoenix\": \"file:../deps/phoenix\",\n    \"phoenix_html\": \"file:../deps/phoenix_html\",\n    \"phoenix_live_view\": \"file:../deps/phoenix_live_view\",\n  },\n  \"devDependencies\": {\n    [...]\n  }\n}\n```\n\nFrom the folder \"assets\", you can run `pnpm i` (and `pnpm add ...`).\n\n### Phoenix live_reload\n\nIn \"dev.exs\", use the following in \"config :liveview_pwa, LiveviewPwaWeb.Endpoint,\" to let `Phoenix` code reload listen to only `.ex |.heex` file changes (_no_ static asset):\n\n```elixir\nlive_reload: [\n    web_console_logger: true,\n    patterns: [\n      ~r\"lib/liveview_pwa_web/(controllers|live|components|router|channels)/.*\\.(ex|heex)$\",\n      ~r\"lib/liveview_pwa_web/.*/.*\\.heex$\"\n    ]\n  ],\n```\n\n### HMR in DEV mode\n\nBesides the `live_reload`, there is a watcher configured in \"config/dev.exs\" which replaces, thus removes, `esbuild` and `tailwindCSS` (which are also removed from the mix deps).\n\nThe watcher below runs the `Vite` dev server on port 5173. It will listen _only_ to static assets changes (`.js`, `.svg`, ...)\n\n```elixir\nwatchers: [\n    pnpm: [\n      \"vite\",\n      \"serve\",\n      \"--mode\",\n      \"development\",\n      \"--config\",\n      \"vite.config.js\",\n      cd: Path.expand(\"../assets\", __DIR__)\n    ]\n  ]\n```\n\n### \"env\" config\n\nAdd an assign \"env\":\n\n```elixir\n# config.exs\nconfig :liveview_pwa, env: config_env()\n```\n\nand assign it in a live component and/or controller to pass the value:\n\n```elixir\n#xx_live.ex\n\nsocket |\u003e assign(:env, Application.fetch_env!(:liveview_pwa, :env))\n```\n\n### Root layout in :dev/:prod setup\n\nModify the layout \"root.html.heex\" to:\n\n- use the `Vite.ex` module to set the correct paths for the files\n- bring in the `Vite` WebSocket _only in dev mode_ via the assign `@env`.\n\n```elixir\n\u003clink\n  :if={@env === :prod}\n  rel=\"stylesheet\"\n  href={Vite.path(\"css/app.css\")}\n/\u003e\n\n\u003cscript\n  :if={@env === :dev}\n  type=\"module\"\n  nonce={assigns[:main_nonce]}\n  src=\"http://localhost:5173/@vite/client\"\n\u003e\n\u003c/script\u003e\n\n\u003cscript\n  defer\n  nonce={assigns[:main_nonce]}\n  type=\"module\"\n  src={Vite.path(\"js/main.js\")}\n\u003e\n\u003c/script\u003e\n```\n\n### Tailwind v4\n\nTailwind is used as a plugin. You add `tailwindcss` and `@tailwindcss/vite` to your dev dependencies.\n\nIn the `Vite` config, it is set with the declaration:\n\n```js\nimport tailwindcss from \"@tailwindcss/vite\";\n[...]\n\n// in `defineConfig`, add:\ndefineConfig({\n    plugins: [\n      tailwindcss(), \n      ...\n    ],\n  },\n),\n```\n\nThen, in \"css/app.css\", you import tailwindcss and add the `@source` where you use Tailwind classes: HEEX and JS.\n\n```css\n@import tailwindcss source(none);\n@source \"../css\";\n@source \"../**/.*{js, jsx}\";\n@source \"../../lib/liveview_pwa_web/\";\n@plugin \"daisyui\";\n@plugin \"../vendor/heroicons.js\";\n```\n\nwhere \"heroicons.js\" is set as (cf `phoenix 1.8`):\n\n\u003cdetails\u003e\n\u003csummary\u003e--- heroicons.js ---\u003c/summary\u003e\n\n```js\nconst plugin = require(\"tailwindcss/plugin\");\nconst fs = require(\"fs\");\nconst path = require(\"path\");\n\nmodule.exports = plugin(function ({ matchComponents, theme }) {\n  const iconsDir = path.join(__dirname, \"../../deps/heroicons/optimized\");\n  const values = {};\n  const icons = [\n    [\"\", \"/24/outline\"],\n    [\"-solid\", \"/24/solid\"],\n    [\"-mini\", \"/20/solid\"],\n    [\"-micro\", \"/16/solid\"],\n  ];\n  icons.forEach(([suffix, dir]) =\u003e {\n    fs.readdirSync(path.join(iconsDir, dir)).forEach((file) =\u003e {\n      const name = path.basename(file, \".svg\") + suffix;\n      values[name] = { name, fullPath: path.join(iconsDir, dir, file) };\n    });\n  });\n  matchComponents(\n    {\n      hero: ({ name, fullPath }) =\u003e {\n        let content = fs\n          .readFileSync(fullPath)\n          .toString()\n          .replace(/\\r?\\n|\\r/g, \"\");\n        content = encodeURIComponent(content);\n        let size = theme(\"spacing.6\");\n        if (name.endsWith(\"-mini\")) {\n          size = theme(\"spacing.5\");\n        } else if (name.endsWith(\"-micro\")) {\n          size = theme(\"spacing.4\");\n        }\n        return {\n          [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,\n          \"-webkit-mask\": `var(--hero-${name})`,\n          mask: `var(--hero-${name})`,\n          \"mask-repeat\": \"no-repeat\",\n          \"background-color\": \"currentColor\",\n          \"vertical-align\": \"middle\",\n          display: \"inline-block\",\n          width: size,\n          height: size,\n        };\n      },\n    },\n    { values }\n  );\n});\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\n### Resolve assets with Vite config\n\nYou will benefit from using the `resolve` config by using alias.\nFor example:\n\n```js\nimport img from \"@assets/images/img.web\";\n\n// or dynamic at runtime inside a function\n\nconst img = new URL(\"@assets/images/img.web`\", import.meta.url).href;\n```\n\n### Optmise CSS with `lightningCSS` in prod mode\n\nWe use `lightningCSS` in the rollup options to further optimze the CSS. There is no need to bring in `autoprefixer` since it is built in (eg  \"-webkit\" for flex/grid or \"-moz\" for transitions are needed).\n\n### Client Env\n\nThe env arguments are loaded with `loadEnv`.\n\n1. Runtime access: `import.meta.env`\n   The client env vars are set in the \".env\" placed, placed in the \"/assets\" folder (origin client code) next to \"vite.config.js\".\n   They need to be prefixed with `VITE_`.\n   They is injected by `Vite` at _runtime_ when you use `import.meta.env`.\n   In particular, we use `VITE_API_KEY` for `Maptiler` to render the vector tiles.\n\n2. Compile access: `define`\n   it is used at _compile time_ .\n   The directive `define` is used to get _compile time_ global constant replacement. This is valuable for dead code elimination.\n   For example:\n\n   ```js\n   define: {\n     __API_ENDPOINT__: JSON.stringify(\n       process.env.NODE_ENV === \"production\"\n         ? \"https://example.com\"\n         : \"http://localhost:4000\"\n     );\n   }\n   [...]\n   // NODE_ENV=\"prodution\"\n   // file.js\n   if (__API__ENDPOINT__ !== \"https://example.com\") {\n    // =\u003e dead code eliminated\n   }\n   ```\n\n3. Docker:\n   In the Docker build stage, you copy the \"assets\" folder.\n   You therefor copy the \".env\" file so the env vars variables are accessible at runtime.\n   When you deploy, we need to set an env variable `VITE_API_KEY` which will be used to build the image.\n\n### Static assets\n\nWe have two types of static assets:\n\n- fingerprinted by Rolldown as shown below\n- and not, such as icons, iamges related to the webmanifest, SEO files such as sitemap.xml or robotx.txt, and the service worker file \"sw.js\".\n\nThe \"non-fingerprinted\" asets are served by `Phoenix` directly by listing them in the `LiveviewPwaWeb.static_path()` function.\n\nWe modify \"endpoint.ex\" to accept these encodings:\n\n```elixir\nplug Plug.Static,\n  encodings: [{\"zstd\", \".zstd\"}],\n  brotli: true,\n  gzip: true,\n  at: \"/\",\n  from: :liveview_pwa,\n  only: LiveviewPwaWeb.static_paths(),\n  headers: %{\n    \"cache-control\" =\u003e \"public, max-age=31536000\"\n  }\n  [...]\n```\n\nwhere:\n\n```elixir\ndef static_paths,\n  do: ~w(assets icons robots.txt sw.js manifest.webmanifest sitemap.xml)\n```\n\nWe can them reference in the HEEX components as usual:\n\n```elixir\n\u003clink rel=\"icon\" href=\"icons/favicon-192.png\" type=\"image/png\" sizes=\"192x192\" /\u003e\n\u003clink rel=\"manifest\" href=\"/manifest.webmanifest\" /\u003e\n```\n\n#### `Vite.ex` module\n\nAn `Elixir` file path resolving module.\nThis is needed to resolve the file path in dev or in prod mode.\n\n\u003cdetails\u003e\n\u003csummary\u003e --- Vite.ex module --- \u003c/summary\u003e\n\n```elixir\nif Application.compile_env!(:liveview_pwa, :env) == :prod do\n  defmodule Vite do\n    @moduledoc \"\"\"\n    A helper module to manage Vite file discovery.\n\n    It appends \"http://localhost:5173\" in DEV mode.\n\n    It finds the fingerprinted name in PROD mode from the .vite/manifest.json file.\n    \"\"\"\n    require Logger\n\n    # Ensure the manifest is loaded at compile time in production\n    def path(asset) do\n      app_ name = :liveview_pwa\n      manifest = get_manifest(app_name)\n\n      case Path.extname(asset) do\n        \".css\" -\u003e\n          get_main_css_in(manifest)\n\n        _ -\u003e\n          get_name_in(manifest, asset)\n      end\n    end\n\n    defp get_manifest(app_name) do\n      manifest_path = Path.join(:code.priv_dir(app_name), \"static/.vite/manifest.json\")\n\n      with {:ok, content} \u003c- File.read(manifest_path),\n           {:ok, decoded} \u003c- Jason.decode(content) do\n        decoded\n      else\n        _ -\u003e raise \"Could not read or decode Vite manifest at #{manifest_path}\"\n      end\n    end\n\n    def get_main_css_in(manifest) do\n      manifest\n      |\u003e Enum.flat_map(fn {_key, entry} -\u003e\n        Map.get(entry, \"css\", [])\n      end)\n      |\u003e Enum.filter(fn file -\u003e String.contains?(file, \"main\") end)\n      |\u003e List.first()\n    end\n\n    def get_name_in(manifest, asset) do\n      case manifest[asset] do\n        %{\"file\" =\u003e file} -\u003e \"/#{file}\"\n        _ -\u003e raise \"Asset #{asset} not found in manifest\"\n      end\n    end\n  end\nelse\n  defmodule Vite do\n    def path(asset) do\n      \"http://localhost:5173/#{asset}\"\n    end\n  end\nend\n```\n\n\u003c/details\u003e\n\u003cbr/\u003e\n\n#### Static copy\n\nWe use the plugin `vite-plugin-static-copy` to let Vite copy the selected ones (eg the folder \"assets/seo/{robots.txt, sitemap.xml}\" or \"/assets/icons\") into the folder \"/priv/static\".\n\nWhen the asset reference is versioned, we use the `.vite/manifest` dictionary to find the new name.\nWe used a helper [ViteHelper](https://github.com/dwyl/PWA-Liveview/blob/main/lib/soldiyjsweb/vite_helper.ex) to map the original name to the versioned one (the one in \"priv/static/assets\") \n\n#### DEV mode\n\nIn DEV mode, the helper will preprend the file name with `http://localhost:5173` because they are served by Vite DEV server.\n\n#### PROD mode\n\nVite will build the assets. They are fingerprint (and compressed). This is set in `rollupOptions.output`:\n\n```js\nrollupOptions.output: mod === 'production' \u0026\u0026 {\n  assetFileNames: 'assets/[name]-[hash][extname]',\n  chunkFileNames: 'assets/[name]-[hash].js',\n  entryFileNames: 'assets/[name]-[hash].js',\n},\n```\n\nWe do this because we want the SW to be able to detect client code changes and update the app. The Phoenix work would interfer.\n\nTherefor, we do not use the step `mix phx.digest` and removed from the `Dockerfile`.\n\nWe also compress files to _ZSTD_ known for its compression performance and deflating speed. We use the plugin `vite-plugin-compression2` and use `@mongodb-js/zstd`.\n\n### Performance optimisation: Dynamic CSS loading\n\n`Leaflet` needs his own CSS file to render properly. Instead of loading all CSS upfront, the app dynamically loads stylesheets only when nd where needed. This will improve Lighthouse metrics.\n\nWe used a CSS-in-JS Pattern for Conditional Styles.\nCheck \u003chttps://github.com/dwyl/PWA-Liveview/blob/main/assets/js/components/initMap.js\u003e\n\n### VitePWA plugin and Workbox Caching Strategies\n\nWe use the [VitePWA](https://vite-pwa-org.netlify.app/guide/) plugin to generate the SW and the manifest.\n\nThe client code is loaded in a `\u003cscript\u003e`. It will load the SW registration when the event DOMContentLoaded fires.\nAll of the hooks are loaded and attached to the LiveSocket, like an SPA.\nIf we don't _preload_ the JS files in the SW, most of the js files will never be cached, thus the app won't work offline.\n\nFor this, we define that we want to preload all static assets in the directive `globPattern`.\n\nOnce the SW activated, you should see (in dev mode):\n\n\u003cimg width=\"548\" alt=\"Screenshot 2025-02-26 at 16 56 40\" src=\"https://github.com/user-attachments/assets/932c587c-908f-4e47-936a-7a191a35c892\" /\u003e\n\u003cbr/\u003e\n\nWe also cache the rendered HTML pages as we inject them when offline, via `additionalManifestEntries`.\n\n```js\nPWAConfig = {\n  // Don't inject \u003cscript\u003e to register SW (handled manually)\n  // and there no client generated \"index.html\" by Phoenix\n  injectRegister: false, // no client generated \"index.html\" by Phoenix\n\n  // Let Workbox auto-generate the service worker from config\n  strategies: \"generateSW\",\n\n  // App manually prompts user to update SW when available\n  registerType: \"prompt\",\n\n  // SW lifecycle ---\n  // Claim control over all uncontrolled pages as soon as the SW is activated\n  clientsClaim: true,\n\n  // Let app decide when to update; user must confirm or app logic must apply update\n  skipWaiting: false,\n\n  workbox: {...}\n}\n```\n\n❗️ It is important _not to split_ the \"sw.js\" file because `Vite` produces a fingerprint from the splitted files. However, Phoenix serves hardcoded nmes and can't know the name in advance.\n\n❗️ You don't want to cache pages with Wrrokbox but instead trigger a \"manual\" cache. This is because you will get a CSRF mismatch as the token will be outdated. However, you want to pre-cache static assets so you cache the first page \"Login\" here.\n\n```js\nworkbox: {\n  // Disable to avoid interference with Phoenix LiveView WebSocket negotiation\n  navigationPreload: false\n\n  // ❗️ no fallback to \"index.html\" as it does not exist\n  navigateFallback: null\n\n  // ‼️ tell Workbox not to split te SW as the other is fingerprinted, thus unknown to Phoenix.\n  inlineWorkboxRuntime: true,\n\n  // preload all the built static assets\n  globPatterns: [\"assets/**/*.*\"],\n\n  // this will preload static assets during the LoginLive mount.\n  // You can do this because the login will redirect to a controller.\n  additionalManifestEntries: [\n    { url: \"/\", revision: `${Date.now()}` }, // Manually precache root route\n  ],\n\n}\n```\n\nFor the Service Worker lifecycle, set:\n\n```js\ndefineConfig = {\n  // Disable default public dir (using Phoenix's)\n  publicDir: false,\n};\n```\n\n## Yjs\n\n[TODO something smart...?]\n\n## Misc\n\n### Presence through Live-navigation\n\nIt is implemented using a `Channel` and a `JavaScript` snippet used in the main script.\n\nThe reason is that if we implement it with `streams`, it will wash away the current stream\nused by `Phoenix.Sync`.\n\nIt also allows to minimise rendering when navigating to the different Liveviews.\n\nThe relevant module is: `setPresenceChannel.js`. It uses a reactive JS component (`SolidJS`) to render updates.\nIt returns a \"dispose\" and an \"update\" function.\n\nThis snippet runs in \"main.js\".\n\nThe key points are:\n\n- a Channel with `Presence.track` and a `push` of the `Presence.list`,\n- use the `presence.onSync` listener to get a `Presence` list up-to-date and render the UI with this list\n- a `phx:page-loading-stop` listener to udpate the UI when navigating between Liveviews because we target DOM elements to render the reactive component.\n\n### Manifest\n\nThe \"manifest.webmanifest\" file will be generated from \"vite.config.js\".\n\nSource: check [PWABuilder](https://www.pwabuilder.com)\n\n```json\n{\n  \"name\": \"LivePWA\",\n  \"short_name\": \"LivePWA\",\n  \"start_url\": \"/\",\n  \"display\": \"standalone\",\n  \"background_color\": \"#ffffff\",\n  \"lang\": \"en\",\n  \"scope\": \"/\",\n  \"description\": \"A Phoenix LiveView PWA demo app\",\n  \"theme_color\": \"#ffffff\",\n  \"icons\": [\n    { \"src\": \"/images/icon-192.png\", \"sizes\": \"192x192\", \"type\": \"image/png\" },\n    ...\n  ]\n}\n```\n\n✅ Insert the links to the icons in the (root layout) HTML:\n\n```html\n\u003c!-- root.html.heex --\u003e\n\u003chead\u003e\n  [...] \n  \u003clink rel=\"icon-192\" href={~p\"icons/icon-192.png\"}/\u003e\n  \u003clink rel=\"icon-512\" href={~p\"icons/icon-512.png\"}/\u003e\n  \u003clink rel=\"manifest\" href={~p\"/manifest.webmanifest\"}/\u003e \n\u003c/head\u003e\n```\n\n### Page Caching\n\nWe want to cache HTML documents to render offline pages.\n\nIf we cache pages via Workbox, we will save an outdated CSRF token and the LiveViews will fail.\n\nWe need to cache the visited pages. We use `phx:navigate` to trigger the caching:\n\n\u003e It is important part is to calculate the \"Content-Length\" to be able to cache it.\n\n```javascript\n// Cache current page if it's in the configured routes\nasync function addCurrentPageToCache(path) {\n  await navigator.serviceWorker.ready;\n  await new Promise((resolve) =\u003e setTimeout(resolve, 100));\n  // const url = new URL(path, window.location.origin).pathname;\n\n  const htmlContent = document.documentElement.outerHTML;\n  const contentLength = new TextEncoder().encode(htmlContent).length;\n\n  const response = new Response(htmlContent, {\n    headers: {\n      \"Content-Type\": \"text/html; charset=utf-8\",\n      \"Content-Length\": contentLength,\n    },\n    status: 200,\n  });\n\n  const cache = await caches.open(\"page-shells\");\n  return cache.put(path, response.clone());\n}\n```\n\n## Publish\n\nThe site \u003chttps://docs.pwabuilder.com/#/builder/android\u003e helps to publish PWAs on Google Play, Ios and other plateforms.\n\n## Postgres setup to use Phoenix.Sync\n\nYou need:\n\n- set `wal-level logical`\n- assign a role `WITH REPLICATION`\n\nThe first point is done by configuring your Postgres machine (check a local example in the \"docker-compsoe.yml\").\n\nThe second point is done via a migration (`ALTER ROLE postgres WITH REPLICATION`).\n\nCheck \u003chttps://github.com/dwyl/PWA-Liveview/issues/35\u003e for the Fly launch sequence.\n\n## Fly volumes\n\nIn the \"fly.toml\", the settings for the volume are:\n\n```toml\n[env]\nDATABASE_PATH = '/data/db/main.db'\n\n\n[[mounts]]\nsource = 'db'\ndestination = '/data'\n```\n\nThis volume is made persistent through build with `source = 'name'`.\nWe set the Fly secret: `DATABASE_PATH=mnt/db/main.db`.\n\n## Documentation source\n\n- Update API: \u003chttps://docs.yjs.dev/api/document-updates#update-api\u003e\n- Event handler \"on\": \u003chttps://docs.yjs.dev/api/y.doc#event-handler\u003e\n- local persistence with IndexedDB: \u003chttps://docs.yjs.dev/getting-started/allowing-offline-editing\u003e\n- Transactions: \u003chttps://docs.yjs.dev/getting-started/working-with-shared-types#transactions\u003e\n- Map shared type: \u003chttps://docs.yjs.dev/api/shared-types/y.map\u003e\n- observer on shared type: \u003chttps://docs.yjs.dev/api/shared-types/y.map#api\u003e\n\n## Resources\n\nBesides Phoenix LiveView:\n\n- [Vite backend integration](https://vite.dev/guide/backend-integration.html)\n- [Yjs Documentation](https://docs.yjs.dev/)\n- [Vite PWA Plugin Guide](https://vite-pwa-org.netlify.app/guide/)\n- [MDN PWA](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Best_practices)\n- [PWA builder](https://www.pwabuilder.com/reportcard?site=https://solidyjs-lively-pine-4375.fly.dev/)\n- [Favicon Generator](https://favicon.inbrowser.app/tools/favicon-generator) and \u003chttps://vite-pwa-org.netlify.app/assets-generator/#pwa-minimal-icons-requirements\u003e\n- [CSP Evaluator](https://csp-evaluator.withgoogle.com/)\n- [Haversine formula](https://en.wikipedia.org/wiki/Haversine_formula)\n\n## License\n\n[MIT License](LICENSE)\n\n## Enhance\n\nTo enhance this project, you may want to use the library `y_ex`, the `Elixir` port of `y-crdt`.\n\nCredit to: [Satoren](https://github.com/satoren) for [Yex](https://github.com/satoren/y_ex)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdwyl%2Fpwa-liveview","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdwyl%2Fpwa-liveview","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdwyl%2Fpwa-liveview/lists"}