[WIP]: 🎉 Did it! Need to rewrite all below as it is a bit more complicated than expected when you include navigation between already visited pages .....
An Elixir-LiveView demo webapp to demonstrate how to setup a real-time collaborative app with offline support (PWA) using CRDT with navigation.
Some applications may have some interactivity that you may want ot preserve when off-line, or you simply don't want the app to break down in case the user goes off-line.
If you want some interactivity, you need to use a reactive JavaScript framework which will be mirrored by the server.
If not, you can still display some degradated cached pages.
TLTR
- use
navigator.addEventListener("navigate")
to cache "by hand" the whole HTML (includes csrf), - use
strategies: "generateSW"
, - use carefully ordered routes to
workbox.runtimeCaching
, - set
workbox.inlineWorkboxRuntime: true
to have a unique "sw.js" file, - set the pages URL in
workbox.additionalManifestEntries
- set
workbox.navigateFallback: null
- for
SolidJS
, set the correect file extensions:resolve: { extensions: [".mjs", ".js", ".json", "wasm"]}
, - Implement a pooling heartbeat as
navigator.onLine
is unreliable when navigating.
As an application, a two pages collaborative real-time webapp where you can navigate off-line between them, given that you visited these pages before).
- a collaborative stock manager. A user clicks and visualizes the stock level in an animated read-only
<input type=range/>
. It is broadcasted to every user. You need a CRDT strategy.
* a collaborative flight animation. Two users can enter their geolocation and share it. Once ready, a great circle joining these two points is computed using a `WebAssembly` compute container - coded and compiled with `zig` - and drawn into a canvas rendered in a `Leafletjs` map.
The data is saved and sent to Phoenix
which in turn saves into the backend database.
The fgreat circle computation works offline as we use a WebAssembly
WASM module (CPU intensive task).
The filght animation is a simple JavaScript loop.
With an aggresive cache and code splitting, you get an FCP (first content loading time) of 0.4s and a full render of 1.0s for the page with the map and WASM.
5Yjs for persistence and CRDT strategy
Add WebAssembly and a collaborative map
We integrated a few languages and libraries in the demo.
Phoenix LiveView
to orchestrate the app,Yjs
&y-indexeddb
for local in-browser persistence and sync,Vite
andWorkbox
for JS bundling and PWA setup,SolidJS
to produce reactive UI,Zig
for the great cirlce calculations, compiled toWASM
, natively read byJavascript
in the browser,Leaflet
to power a map.SQlite
backend database (optional)
package.json file
{
"name": "assets",
"version": "1.0.0",
"description": "",
"main": "app.js",
"type": "module",
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
"phoenix_live_view": "file:../deps/phoenix_live_view",
"solid": "link:virtual:pwa-register/solid",
"solid-js": "^1.9.3",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0",
"y-indexeddb": "^9.0.12",
"yjs": "^13.6.21",
"leaflet": "^1.9.4",
"workbox-window": "^7.3.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.5",
"vite-plugin-pwa": "^0.21.1",
"vite-plugin-solid": "^2.11.0"
},
It is a Web Worker
with two superpowers:
- it can read/write to the
Cache API
with a request/reponse - proxy HTTP GET requests
This means: whenever the frontend sends an HTTP request to the Phoenix backend, the SW can proxy it (GET
and POST
modulo CORS
) and respond with its cache for GET
. For POST
, you need IndexedDB
.
It works over HTTPS.
For developing the app, Esbuild
is comfortable and perfect.
Your `build.js` looks like this
import { context, build } from "esbuild";
import { solidPlugin } from "esbuild-plugin-solid";
const args = process.argv.slice(2);
const watch = args.includes("--watch");
const deploy = args.includes("--deploy");
console.log(args);
let opts = {
entryPoints: [
"./js/app.js",
"./js/bins",
"./js/counter",
"./js/SolidComp",
"./js/initYJS",
"./js/onlineStatus",
"./js/solHook",
"./wasm/great_circle.wasm",
],
bundle: true,
logLevel: "info",
target: "esnext",
outdir: "../priv/static/assets",
external: ["*.css", "fonts/*", "images/*"],
loader: {
".js": "jsx",
".svg": "file",
".png": "file",
".jpg": "file",
".wasm": "file",
},
plugins: [solidPlugin()],
nodePaths: ["../deps"],
format: "esm",
};
if (deploy) {
opts = {
...opts,
minify: true,
splitting: true,
metafile: true,
};
await build(opts);
// fs.writeFileSync("meta.json", JSON.stringify(result.metafile, null, 2));
process.exit(0);
}
if (watch) {
context(opts)
.then(async (ctx) => {
await ctx.watch();
process.stdin.on("close", () => {
process.exit(0);
});
process.stdin.resume();
})
.catch((error) => {
console.log(`Build error: ${error}`);
process.exit(1);
});
}
with a watcher run as a command in Elixir
:
# config.dev.exs
wtachers: {
node: ["build.js", "--watch", cd: Path.expand("../assets", __DIR__)]
]
When you wnat to bring in offline capabilities, you will want to use Workbox
.
Unless you are very experienced with Workbox
,
it is safer to opt in for Vite
when you want to use Workbox
.
It will generate the "sw.js" from your "vite.config.js" file for you.
This second point is valid for any LiveView
webapp.
You can use_ dynamic imports for code splitting_.
Vite
can use Rollup
to build so you can take advantage
of its code splitting performance.
This means that instead of loading a big chunk of 100kB or more, you end up with loading several JS files.
For example, in this code, the biggest is Phoenix
(30kB gzip).
This is important for the first rendering, and since you are doing SSR, you want to keep the first rendering performance.
The idea is to keep the LiveView
skeleton with all reactive components replaced with a reactive JavaScript framework.
You indeed obviously need a reactive Javascript framework to have an offline responsive UI.
It should have a minimal impact on the code.
❗️The choice is a question of oponion.
Among the frameworks, you most probably want to a very light one, that does not use a virtual DOM. You have
Svelte
andSolidJS
. In fact, bothSvelte
andSolidJS
are comparable in terms of performance and bundle size. SinceSvelte
is far from Vanila Javascript, I opted forSolidJS
with is very close toReact
likeLiveView
is, whilst not at all forSvelte
. The main rule withSolidJS
is: don't destructure the props and you are good to go. If you go through the code, you will notice that the impact of usingSolidJS
is minimal on the code and very lightweight.
Vite PWA: https://vite-pwa-org.netlify.app/guide/
- how vite-plugin-pwa builds the service worker: https://vite-pwa-org.netlify.app/guide/cookbook.html
It will generate two files that are served by Phoenix
, in the root folder "priv/static":
sw.js
and the manifest.webmanifest
files.
You need to modify the static_path
accordingly (see the paragraph "Vite config").
Make your life easier with pnpm
or bun
.
-
pnpm: Save space, gain speed and confidence with symlinked libraries https://pnpm.io/installation
-
Vite: Check the package.json
pnpm add -D vite
Since we use a reactive JavaScript framework (SolidJS
here) for the offline reactivity), we need to bring a plugin: solidPlugin.
For the PWA, we need to use VitePWA.
❗️ For the solidPlugin
, it is important to pass the JSX extensions to resolver.extension
to be able to compile
JSX extension files. This is because SolidJS
identifies these files as components to parse the JSX.
resolve: {
extensions: [".mjs", ".js", ".ts", ".jsx", ".tsx", ".json"],
},`
VitePWA
configuration, it is important to set the key workbox.inlineWorkboxRuntime
to true
in order to produce a unique "sw.js" file.
Otherwise Vite/Workbox
will produce some timestamped saved into the directory "priv/static",
which is impossible since Phoenix
will serve thme.
This way, only "sw.js" will be placed there, which Phoenix
can serve.
workbox: {
inlineWorkboxRuntime: true,
...
}
You tell that Phoenix Liveview as no "index.html" file to display:
navigateFallback: null,
✅ modify the static paths: the files "sw.js" and "manifest.webmanifest" are generated for you by Vite
from the "vite.config.js" file.
def static_paths,
do: ~w(assets fonts images favicon.ico robots.txt sw.js manifest.webmanifest)
The available strategies and use cases:
-
CacheFirst
is best for static assets: fonts, images. It checks cahce first. Only makes network request if resource isn't in cache -
NetworkFirst
is best for API calls, dynamic content. It tries network request first, and falls back to cached content if network fails/times out. It prioritizes fresh content with offline fallback -
StaleWhileRevalidate
. It serves cached version immediately (if available). It updates cache in background for next time. It is best for: News feeds, social media content, frequently updated content Balances speed with content freshness -
NetworkOnly
. It never uses cache, always fetches from network. You need real-time data accuracy -
CacheOnly
. Only serves from cache, never makes network requests
Each strategy can be configured with additional options:
cacheName
: Identify different cachesexpiration
: Control cache size and agenetworkTimeoutSeconds
: Timeout for network requestsmatchOptions
: Fine-tune cache matchingplugins
: Add custom caching behaviors
We also pass the routes and strategies to the Service Worker in workbox.runtimeCaching
.
Each url pattern has a corresponding handler
with a strategy.
Workbox routes strategy
const LVLongPoll = {
urlPattern: ({ url }) => url.pathname.startsWith("/live/longpoll"),
handler: "NetworkOnly",
};
const LVTestOnline = {
urlPattern: ({ url }) => url.pathname.startsWith("/test"),
handler: "NetworkOnly",
};
const LVWebSocket = {
urlPattern: ({ url }) => url.pathname.startsWith("/live/websocket"),
handler: "NetworkOnly", // Websockets must always go to network
};
const StaticAssets = {
urlPattern: ({ url }) => {
const staticPaths = ["/assets/", "/images/"];
return staticPaths.some((path) => url.pathname.startsWith(path));
},
handler: "CacheFirst",
options: {
cacheName: "static",
expiration: {
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 year
maxEntries: 200,
},
matchOptions: {
ignoreVary: true, // Important for Phoenix static asset handling
},
},
};
const Fonts = {
urlPattern: ({ url }) => {
const externalPatterns = ["https://fonts.googleapis.com"];
return externalPatterns.some((pattern) => url.href.startsWith(pattern));
},
handler: "CacheFirst",
options: {
cacheName: "external",
expiration: {
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 hours
maxEntries: 500,
},
matchOptions: {
ignoreVary: true, // Important for some external resources
},
},
};
const Scripts = {
urlPattern: ({ request }) => request.destination === "script",
handler: "CacheFirst",
options: {
cacheName: "scripts",
expiration: {
maxAgeSeconds: 60 * 60 * 24 * 7, // 7 days
maxEntries: 50,
},
},
};
const Tiles = {
urlPattern: ({ url }) => url.origin === "https://tile.openstreetmap.org",
handler: "StaleWhileRevalidate",
options: {
cacheName: "tiles",
expiration: {
maxEntries: 1000, // Adjust based on your needs
maxAgeSeconds: 60 * 60, // 1 hours
},
plugins: [
{
fetchDidFail: async ({ request }) => {
console.warn("Tile request failed:", request.url);
},
},
],
},
};
const LiveReload = {
urlPattern: ({ url }) => url.pathname.startsWith("/phoenix"),
handler: "NetworkOnly",
};
const Pages = {
urlPattern: ({ url }) =>
url.pathname.startsWith("/map") || url.pathname.startsWith("/"),
handler: "NetworkFirst",
options: {
plugins: [
{
fetchDidFail: async ({ request }) => {
console.warn("Online status request failed:", request.url);
},
},
],
},
};
Workbox
uses pattern matching:
workbox: {
runtimeCaching: [
runtimeCaching: [
Tiles,
StaticAssets,
Scripts,
Fonts,
LVLongPoll,
LVWebSocket,
LVTestOnline,
LiveReload,
Pages,
,
}
workbox: {
additionalManifestEntries: [
{ url: "/", revision: null },
{ url: "/map", revision: null },
],
}
❗️ As per Vite-PWA
documentation, set:
injectManifest: {
injectionPoint: undefined,
},
❗️ Note how topbar
needs its own treatment. We also need to rename the extensoin to cjs
and set the rollup
option:
commonjsOptions: {
exclude: [],
include: ["vendor/topbar.cjs"],
},
Set:
clientsClaim: true,
skipWaiting: true,
With clientsClaim: true
, you take control of all open pages as soon as the service worker activates.
With skipWaiting: true
, new service worker versions activate immediately.
Without these settings, you might have some pages using old service worker versions while others use new ones, which could lead to inconsistent behavior in your offline capabilities.
The "manifest.webmanifest" file will be generated from "vite.config.js".
{
"name": "ExLivePWA",
"short_name": "ExLivePWA",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"lang": "en",
"scope": "/",
"description": "A Phoenix LiveView PWA demo webapp",
"theme_color": "#ffffff",
"icons": [
{ "src": "/images/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/images/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
✅ Insert the links to the icons in the (root layout) HTML:
<!-- root.html.heex -->
<head>
[...]
<link rel="icon-192" href={~p"/images/icon-192.png"} />
<link rel="icon-512" href={~p"/images/icon-512.png"} />
<link rel="icon" href="/favicon.ico" sizes="48x48" />
<link rel="manifest" href="/manifest.webmanifest" />
[...]
</head>
https://vite-pwa-org.netlify.app/assets-generator/#pwa-minimal-icons-requirements
You will need is to have at least two very low resolution icons of size 192 and 512, one extra of 180 for OSX and one 62 for Microsoft, all placed in "/priv/static/images".
A generator: https://favicon.inbrowser.app/tools/favicon-generator
YJS's IndexedDB persistence handles offline support automatically.
When offline:
- Users can still modify the stock locally
- Changes are stored in IndexedDB
- When back online, YJS will sync changes
In Vite's GitHub repo, https://github.com/vite-pwa/vite-plugin-pwa/tree/main/docs/frameworks,
you pick your favorite framework to setup needRefresh
and offlineReady
.
You need workbox-window
as a dev dependency.
You can use the built-in Vite virtual module virtual:pwa-register/solid
for SolidJS
.
It will return createSignal stateful values (createSignal<boolean>
) for offlineReady and needRefresh
.
- User A changes stock → YJS update → Hook observes → LiveView broadcast
- LiveView broadcasts to all users → Hook receives "new_stock" → YJS update
- YJS update → All components observe change → UI updates
- Chrome browser menu:
- the manifest file,
- the Service Worker,
- the Cache storage,
- the IndexedDB database:
- Memory used for the Service Worker
Cache
,IndexedDB
withy-indexedb
- The "proof" of the installed PWA (TODO: change the named of this repo).
- Screenshot of the demo app
A WASM module is a static asset so it will be cached when called. Javascript can run it.
We used Zig
to cod and produce it. Since the great circle is CPU intensive, it makes sense to use calcuation box like WASM.
Furthermore, this (simple) module is lighting fast.
We add the Vite
plugin vite-plugin-wasm
to bring in WASM files,
so the list of our plugins is:
plugins: [wasm(), solidPlugin(), VitePWA(PWAOpts)]
.
We compile the Zig
into WASM format for the browser with .ReleaseSmall
.
This brings down the size from 450kB down to 13kB.
This module computes the lat/long every 1 degree along the great circle joining two points. It uses the Haversine formulas.
It is displayed as a polygone with Leaflet
.
Note the size of the WASM module when compiled to
.ReleaseSmall
: 13kB whilstLeaflet
is 43kB andPhoenix_live_view.js
is 30kB.
Weight of files
../priv/static/manifest.webmanifest 0.36 kB
../priv/static/assets/great_circle.wasm 13.16 kB
../priv/static/assets/mapHook.css 15.04 kB │ gzip: 6.38 kB
../priv/static/assets/onlineStatus.js 0.37 kB │ gzip: 0.25 kB
../priv/static/assets/bins.js 0.43 kB │ gzip: 0.26 kB
../priv/static/assets/initYJS.js 0.48 kB │ gzip: 0.30 kB
../priv/static/assets/SolidComp.js 0.73 kB │ gzip: 0.45 kB
../priv/static/assets/refreshSW.js 0.85 kB │ gzip: 0.50 kB
../priv/static/assets/great_circle.js 0.85 kB │ gzip: 0.50 kB
../priv/static/assets/solHook.js 0.88 kB │ gzip: 0.43 kB
../priv/static/assets/preload-helper.js 0.99 kB │ gzip: 0.60 kB
../priv/static/assets/counter.js 1.40 kB │ gzip: 0.73 kB
../priv/static/assets/mapHook.js 1.42 kB │ gzip: 0.85 kB
../priv/static/assets/y-indexeddb.js 2.82 kB │ gzip: 1.21 kB
../priv/static/assets/topbar.js 3.05 kB │ gzip: 1.52 kB
../priv/static/assets/app.js 3.86 kB │ gzip: 1.53 kB
../priv/static/assets/workbox-window.prod.es5.js 5.72 kB │ gzip: 2.35 kB
../priv/static/assets/web.js 18.10 kB │ gzip: 7.28 kB
../priv/static/assets/phoenix.js 20.26 kB │ gzip: 6.16 kB
../priv/static/assets/yjs.js 85.58 kB │ gzip: 26.28 kB
../priv/static/assets/phoenix_live_view.esm.js 96.62 kB │ gzip: 30.31 kB
../priv/static/assets/leaflet-src.js 149.70 kB │ gzip: 43.40 kB
It does note really make sense to use a WASM module for this as JavaScript is probably fast enough to compute this as well. It is more to demonstrate what can be done.
Zig code to produce an array of Great Circle points between two coordinates
// extern "env" fn consoleLog(ptr: [*]const u8, len: usize) void;
const std = @import("std");
const math = std.math;
const toRadian = math.degreesToRadians;
// Constants for geometric calculations
const RADIANS_PER_DEGREE = math.pi / 180.0;
const DEGREES_PER_RADIAN = 180.0 / math.pi;
const EARTH_RADIUS_KM = 6371.0; // Earth's mean radius in kilometers
// One degree of latitude or longitude in kilometers
const ONE_DEGREE_KM = 111.0;
// Configuration
const DEFAULT_ANGLE_INCREMENT_DEGREES: f64 = 1.0; // Spacing between points in degrees
const ANGLE_INC_RAD = DEFAULT_ANGLE_INCREMENT_DEGREES * RADIANS_PER_DEGREE;
/// Represents a geographic point with latitude and longitude in degrees.
const Point = struct { lat: f64, lon: f64 };
var num_points: usize = 0;
var wasm_allocator = std.heap.wasm_allocator;
/// Converts Cartesian coordinates to geographic latitude and longitude.
fn cartesianToGeographic(x: f64, y: f64, z: f64) Point {
return Point{
.lat = std.math.atan2(z, std.math.sqrt(x * x + y * y)) * DEGREES_PER_RADIAN,
.lon = std.math.atan2(y, x) * DEGREES_PER_RADIAN,
};
}
/// Calculate the angular distance between two points on the surface of a sphere
export fn calculateHaversine(
start_lat_deg: f64,
start_lon_deg: f64,
end_lat_deg: f64,
end_lon_deg: f64,
) f64 {
const start_lat_rad = toRadian(start_lat_deg);
const start_lon_rad = toRadian(start_lon_deg);
const end_lat_rad = toRadian(end_lat_deg);
const end_lon_rad = toRadian(end_lon_deg);
const haversine =
math.pow(f64, math.sin((end_lat_rad - start_lat_rad) / 2), 2) +
math.cos(start_lat_rad) * math.cos(end_lat_rad) *
math.pow(f64, math.sin((end_lon_rad - start_lon_rad) / 2), 2);
return 2 * math.atan2(math.sqrt(haversine), math.sqrt(1 - haversine));
}
/// Calculate the great-circle distance between two geographic coordinates.
fn getGreatCircleDistance(
start_lat_deg: f64,
start_lon_deg: f64,
end_lat_deg: f64,
end_lon_deg: f64,
) f64 {
const angular_distance = calculateHaversine(
start_lat_deg,
start_lon_deg,
end_lat_deg,
end_lon_deg,
);
return angular_distance * EARTH_RADIUS_KM;
}
/// Calculate the number of points for interpolation based on distance.
fn calculateNumPoints(angular_distance: f64, angle_increment_rad: f64) usize {
return @as(usize, @intFromFloat(@ceil(angular_distance / angle_increment_rad))) + 1;
}
fn computeNumPoints(angular_distance: f64) usize {
if (angular_distance * DEGREES_PER_RADIAN < 1.0) {
return 2;
}
return calculateNumPoints(angular_distance, ANGLE_INC_RAD);
}
export fn getBufferSize() usize {
return num_points * 2 * 8;
}
export fn memfree(ptr: [*]u8, len: usize) void {
const slice = ptr[0..len];
wasm_allocator.free(slice);
}
/// Computes great-circle points between two geographic coordinates.
export fn computeGreatCirclePoints(
start_lat_deg: f64,
start_lon_deg: f64,
end_lat_deg: f64,
end_lon_deg: f64,
) [*]f64 {
// log("Computing great circle points\n");
// Convert coordinates to radians
const start_lat_rad = toRadian(start_lat_deg);
const start_lon_rad = toRadian(start_lon_deg);
const end_lat_rad = toRadian(end_lat_deg);
const end_lon_rad = toRadian(end_lon_deg);
const angular_distance = calculateHaversine(
start_lat_deg,
start_lon_deg,
end_lat_deg,
end_lon_deg,
);
num_points = computeNumPoints(angular_distance);
// If the points are very close, return just the two points
if (num_points == 2) {
const buffer = wasm_allocator.alloc(f64, 4) catch unreachable;
buffer[0] = start_lat_deg;
buffer[1] = start_lon_deg;
buffer[2] = end_lat_deg;
buffer[3] = end_lon_deg;
return buffer.ptr;
}
const buffer: []f64 = wasm_allocator.alloc(f64, num_points * 2) catch unreachable;
const sin_angular_distance = math.sin(angular_distance);
var i: usize = 0;
while (i < num_points) : (i += 1) {
const fraction = @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(num_points - 1));
// Calculate interpolation coefficients
const coeff_start =
if (sin_angular_distance == 0)
1 - fraction
else
math.sin((1 - fraction) * angular_distance) / sin_angular_distance;
const coeff_end =
if (sin_angular_distance == 0)
fraction
else
math.sin(fraction * angular_distance) / sin_angular_distance;
// Calculate 3D cartesian coordinates
const x =
coeff_start * math.cos(start_lat_rad) * math.cos(start_lon_rad) +
coeff_end * math.cos(end_lat_rad) * math.cos(end_lon_rad);
const y =
coeff_start * math.cos(start_lat_rad) * math.sin(start_lon_rad) +
coeff_end * math.cos(end_lat_rad) * math.sin(end_lon_rad);
const z =
coeff_start * math.sin(start_lat_rad) +
coeff_end * math.sin(end_lat_rad);
// Convert back to geographic coordinates
const point = cartesianToGeographic(x, y, z);
buffer[i * 2] = point.lat;
buffer[i * 2 + 1] = point.lon;
}
return buffer.ptr;
}
// fn log(msg: []const u8) void {
// consoleLog(msg.ptr, msg.len);
// }
Then you can run the module in the browser. The MapHook
implements this.
async function loadWasm() {
const { instance } = await WebAssembly.instantiateStreaming(
fetch("./assets/zig_gc.wasm")
);
return instance.exports;
In Liveview, we use navigate
as all the routes are under the same live_sessions
. Make sure to use replace
for the url.
use Phoenix.Component
use ExLivePWAWeb, :verified_routes
<.link navigate={~p"/"} replace><span>Home</span></.link>
When the user is off line, the navigation is a full page reload.
The online
and offline
events alone cannot always determine connectivity, especially when the server is unreachable.
You can rn a server reachability check (via HEAD requests) to ensure accurate detection of reconnection, independent of browser state.
The route and corresponding controller to:
async function checkServerReachability() {
try {
const response = await fetch("/connectivity", { method: "HEAD" });
return response.ok;
} catch (error) {
console.error("Error checking server reachability:", error);
return false;
}
}
is:
live_session :default do
scope "/", MyAppWeb do
pipe_through :browser
get "/connectivity", ConnectivityController, :check
live "/", CounterLive, :index
live "/map", MapLive, :index
end
end
with:
defmoduel MyAppWeb.ConnectivityController do
use SolidyjsWeb, :controller
def check(conn, _params) do
send_resp(conn, 200, "online")
end
end
This entry file runs an IIFE and uses dynamic imports (thanks to Vite
, but Esbuild
can do it as well to esnext
).
There is a different initialization paths for online/offline:
- Online: Full LiveSocket setup with hooks
- Offline: Direct component rendering (Map/Stock)
We check if the client is online, and if positive, we bring in the hook and connect the LiveSocket. Otherwise, we don't try to connect the LiveSocket and just bring in the corresponding Javascript to the current page. Here, we just defined two pages, "/" and "/map" and the corresponding Javascript to execute. This could be further improved.
(async () => {
appState.isOnline = await checkServerReachability();
await initApp(appState.isOnline);
if ("serviceWorker" in navigator && appState.isOnline) {
await addCurrentPageToCache({
current: window.location.href,
routes: CONFIG.ROUTES,
});
}
})();
async function initApp(lineStatus) {
try {
const { default: initYdoc } = await import("./initYJS.js");
const ydoc = await initYdoc();
window.ydoc = ydoc; // Set this early so it's available for offline use
const { solHook } = await import("./solHook.jsx");
const { MapHook } = await import("./mapHook.jsx");
if (lineStatus) {
const SolHook = solHook(ydoc); // setup SolidJS component
return initLiveSocket({ SolHook, MapHook });
}
const path = window.location.pathname;
if (path === "/map") {
return displayMap();
} else if (path === "/") {
solHook(ydoc); // setup SolidJS component
return displayStock();
}
} catch (error) {
console.error("Init failed:", error);
}
}
async function initLiveSocket({ SolHook, MapHook }) {
const { LiveSocket } = await import("phoenix_live_view");
const { Socket } = await import("phoenix");
const csrfToken = document
.querySelector("meta[name='csrf-token']")
.getAttribute("content");
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 100,
params: { _csrf_token: csrfToken },
hooks: { SolHook, MapHook },
});
liveSocket.connect();
window.liveSocket = liveSocket;
liveSocket.getSocket().onOpen(() => {
console.log("Socket connected", liveSocket?.isConnected());
});
return true;
}
To cache the current page other than "/" (which was cached when the app is lanucned), we use a listener on the "navigate" event and ensure to cache the page only once.
const appState = {
paths: new Set(),
isOnline: false,
};
navigation.addEventListener("navigate", async ({ destination: { url } }) => {
return addCurrentPageToCache({ current: url, routes: CONFIG.ROUTES });
});
Because Javascript uses UTF-16, you need to encode
the text to compute the length.
async function addCurrentPageToCache({ current, routes }) {
await navigator.serviceWorker.ready;
const newPath = new URL(current).pathname;
if (!routes.includes(newPath)) return;
// we cache the two pages "/"" and "/map" only once.
if (appState.paths.has(newPath)) return;
if (newPath === window.location.pathname) {
console.log("addCurrentPageToCache", newPath);
appState.paths.add(newPath);
const htmlContent = document.documentElement.outerHTML;
const contentLength = new TextEncoder().encode(htmlContent).length;
const headers = new Headers({
"Content-Type": "text/html",
"Content-Length": contentLength,
});
const response = new Response(htmlContent, {
headers: headers,
status: 200,
statusText: "OK",
});
const cache = await caches.open("CONFIG.CACHE_NAME");
return cache.put(current, response);
} else return;
}
Besides the Progress bar during navigation, there is an Online status visual indicator.
Indeed, you may want to display when the client if on-line or not.
You cannot rely on banvigator.onLine
especially when the client is off-line and recovers signal
Indeed, navigation off-line is full page load, and by default the navigaor is onLine = true
.
This means that the event navigator.ononLine
will never be triggered.
We need to do more work for this. One solution is to periodically pool the server.
The updateOnlineStatusUI
function updates the status indicator (online-status) dynamically.
The reload logic is inside the polling function (startPolling
) and only executes when the app transitions from offline to online.
When offline, the polling continues until reconnection.
function updateOnlineStatusUI(online) {
const statusElement = document.getElementById("online-status");
if (statusElement) {
statusElement.style.backgroundColor = online ? "lavender" : "tomato";
statusElement.style.opacity = online ? "0.8" : "1";
statusElement.textContent = online ? "Online" : "Offline";
}
}
function startPolling(interval = CONFIG.POLL_INTERVAL) {
setInterval(async () => {
const wasOnline = appState.isOnline;
appState.isOnline = await checkServerReachability();
if (appState.isOnline !== wasOnline) {
// updateOnlineStatusUI(appState.isOnline);
window.location.reload();
}
}, interval);
console.log("Started polling...");
}
document.addEventListener("DOMContentLoaded", async () => {
console.log("Initializing status monitoring...");
appState.isOnline = await checkServerReachability();
updateOnlineStatusUI(appState.isOnline);
// Start polling only if offline
if (!appState.isOnline) {
startPolling();
}
// Monitor online and offline events
window.addEventListener("online", async () => window.location.reload());
window.addEventListener("offline", () => {
console.log("Browser offline event fired");
appState.isOnline = false;
updateOnlineStatusUI(appState.isOnline);
startPolling(); // Start polling when offline
});
});