Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
16 | PWA: installation, offline fallback and updates
Goal
Turn the completed SPA into an optional installable application without caching private data.
What you are building
Enable Teloce's first-party PWA plugin through Flaxon. Mount generated icons, add a scoped status component and rebuild production assets. The browser receives a manifest and a same-origin service worker. It caches emitted JS/CSS and a generic offline page, not API or Admin responses. Offline means the app can explain a lost connection; it does not save tasks offline. Registration is disabled in development, so check a production build over localhost or HTTPS. This is an optional final lesson, after the existing deployment chapter.
Build this chapter
Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.
Step 1: CREATE - make parent folders, then create this file - scripts/create_pwa_icons.py
Create the icon generator. It writes two PNG files using the standard library. Run it before the development server; production builds invoke it automatically.
Create the parent directories, then save this complete file.
"""Generate portable course icons using only Python's standard library."""
from pathlib import Path
import struct
import zlib
ROOT = Path(__file__).resolve().parents[1]
def chunk(kind, data):
checksum = zlib.crc32(kind + data) & 0xFFFFFFFF
return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum)
def icon(size):
rows = bytearray()
for y in range(size):
rows.append(0) # PNG filter: none.
for x in range(size):
px, py = x / size, y / size
color = (23, 36, 58)
# A folder and a check mark, with generous maskable-icon padding.
if 0.20 <= px <= 0.80 and 0.34 <= py <= 0.72:
color = (219, 234, 254)
if 0.20 <= px <= 0.45 and 0.27 <= py <= 0.35:
color = (219, 234, 254)
if 0.37 <= px <= 0.51 and abs(py - (px + 0.13)) < 0.035:
color = (20, 100, 170)
if 0.49 <= px <= 0.68 and abs(py - (-px + 1.13)) < 0.035:
color = (20, 100, 170)
rows.extend(color)
header = struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(rows))) + chunk(b"IEND", b"")
def main():
public = ROOT / "public"
public.mkdir(exist_ok=True)
for size in (192, 512):
path = public / f"pwa-icon-{size}.png"
path.write_bytes(icon(size))
print(f"Created {path.relative_to(ROOT)}")
if __name__ == "__main__":
main()
Say: "Create the icon generator. It writes two PNG files using the standard library. Run it before the development server; production builds invoke it automatically."
Type: Follow the action above in scripts/create_pwa_icons.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 2: CREATE - make parent folders, then create this file - ui/components/PwaStatus.html
Create the optional PWA status component with scoped CSS. Explain online/offline events, the waiting worker and cleanup before wiring the update button.
Create the parent directories, then save this complete file.
<template>
<aside v-if="offline || updateAvailable || message" class="pwa-status" aria-label="Application status">
<p v-if="offline" role="status">You are offline. Reconnect before saving changes.</p>
<p v-if="updateAvailable" role="status">A new version is ready.</p>
<button v-if="updateAvailable" type="button" :disabled="!applyUpdate" @click="applyUpdate()">Update and reload</button>
<p v-if="message" role="status">{{ message }}</p>
</aside>
</template>
<script lang="ts">
export default {
data() {
return { offline: !navigator.onLine, updateAvailable: false, message: "", cleanup: null, applyUpdate: null };
},
mounted() {
let active = true;
let reloadListener = null;
const networkChanged = () => { this.offline = !navigator.onLine; };
const updateReady = () => { this.updateAvailable = true; };
const registrationFailed = () => { this.message = "Offline support could not start. You can still use the online app."; };
window.addEventListener("online", networkChanged);
window.addEventListener("offline", networkChanged);
document.addEventListener("teloce:pwa-update", updateReady);
document.addEventListener("teloce:pwa-error", registrationFailed);
Promise.resolve(globalThis.telocePlugins?.pwa).then(controller => {
if (!active || !controller) return;
this.updateAvailable = controller.updateAvailable;
this.applyUpdate = () => {
if (!window.confirm("Save unfinished work first. Reload the application now?")) return;
reloadListener = () => window.location.reload();
navigator.serviceWorker.addEventListener("controllerchange", reloadListener, { once: true });
controller.activateUpdate();
};
});
this.cleanup = () => {
active = false;
window.removeEventListener("online", networkChanged);
window.removeEventListener("offline", networkChanged);
document.removeEventListener("teloce:pwa-update", updateReady);
document.removeEventListener("teloce:pwa-error", registrationFailed);
if (reloadListener) navigator.serviceWorker.removeEventListener("controllerchange", reloadListener);
};
},
unmounted() {
this.cleanup?.();
},
};
</script>
<style scoped>
.pwa-status { padding: 1rem; background: #dbeafe; color: #17243a; border-bottom: 1px solid #93c5fd; }
p { margin: 0.25rem 0; }
button { margin-top: 0.5rem; padding: 0.6rem 1rem; border: 0; border-radius: 0.4rem; background: #1464aa; color: white; cursor: pointer; }
button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }
</style>
Say: "Create the optional PWA status component with scoped CSS. Explain online/offline events, the waiting worker and cleanup before wiring the update button."
Type: Follow the action above in ui/components/PwaStatus.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 3: EDIT - replace the marked blocks - app.py
The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact.
Edit 1: find this exact block
Open app.py. Keep Teloce registration before the management guard so the manual checker can discover the UI.
app.use_teloce(
project_root=ROOT,
ui_dir="ui",
title="Project Manager",
options={"minifier": "minifyjs"},
)
if app.is_management:
return app
Replace that block with
app.use_teloce(
project_root=ROOT,
ui_dir="ui",
title="Project Manager",
options={"minifier": "minifyjs"},
plugins={
"pwa": {
"name": "Flaxon Project Manager",
"short_name": "Projects",
"start_url": "/projects",
"scope": "/",
"theme_color": "#17243a",
"icons": [
{"src": "/course-assets/pwa-icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable"},
{"src": "/course-assets/pwa-icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"},
],
},
},
)
if app.is_management:
return app
app.mount_static("/course-assets", str(ROOT / "public"), cache_control="no-cache")
Leave the rest of the file unchanged.
Say: "The application factory mounts only the features already introduced. Follow the action label: a marked-block edit keeps the surrounding factory intact."
Type: Follow the action above in app.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 4: EDIT - replace the marked blocks - ui/app.html
This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings.
Edit 1: find this exact block
Open ui/app.html. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
<a href="/login" data-teloce-link>Account</a>
</nav>
</header>
<main id="router-view" data-teloce-router-view></main>
<footer>Built with Flaxon and Teloce. <a href="/admin/">Staff Admin</a></footer>
</div>
</template>
<script lang="ts">
export default {};
</script>
<style scoped>
/* Only the document reset is global: body is outside this component. */
Replace that block with
<a href="/login" data-teloce-link>Account</a>
</nav>
</header>
<PwaStatus />
<main id="router-view" data-teloce-router-view></main>
<footer>Built with Flaxon and Teloce. <a href="/admin/">Staff Admin</a></footer>
</div>
</template>
<script lang="ts">
import PwaStatus from "./components/PwaStatus.html";
export default { components: { PwaStatus } };
</script>
<style scoped>
/* Only the document reset is global: body is outside this component. */
Leave the rest of the file unchanged.
Say: "This is the SPA shell. Its internal anchors and router view cooperate with each module's page mappings."
Type: Follow the action above in ui/app.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 5: EDIT - replace the marked blocks - scripts/build_ui.py
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Edit 1: find this exact block
Open scripts/build_ui.py. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
os.environ["DATA_DIR"] = build_data
os.environ["FLAXON_DEBUG"] = "0"
os.environ.setdefault("FLAXON_SECRET_KEY", secrets.token_urlsafe(48))
from app import app
result = app.teloce.build()
Replace that block with
os.environ["DATA_DIR"] = build_data
os.environ["FLAXON_DEBUG"] = "0"
os.environ.setdefault("FLAXON_SECRET_KEY", secrets.token_urlsafe(48))
from create_pwa_icons import main as create_icons
create_icons()
from app import app
result = app.teloce.build()
Leave the rest of the file unchanged.
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in scripts/build_ui.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 6: EDIT - replace the marked blocks - scripts/browser_smoke.py
This file owns the feature named by its module or component. Read the route/form flow before continuing.
Edit 1: find this exact block
Open scripts/browser_smoke.py. Locate this block once. The unchanged surrounding lines identify its position; do not use line numbers from another revision.
def main():
with tempfile.TemporaryDirectory() as directory:
production = "--production" in sys.argv
env = {
Replace that block with
def main():
from create_pwa_icons import main as create_icons
create_icons()
with tempfile.TemporaryDirectory() as directory:
production = "--production" in sys.argv
env = {
Leave the rest of the file unchanged.
Say: "This file owns the feature named by its module or component. Read the route/form flow before continuing."
Type: Follow the action above in scripts/browser_smoke.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 7: CREATE - make parent folders, then create this file - tests/test_pwa.py
Verify the manifest, actual PNG responses, worker scope and development registration policy before recording installation.
Create the parent directories, then save this complete file.
"""Verify the course's optional PWA assets and production configuration."""
import json
import runpy
from pathlib import Path
from flaxon.testing import TestClient
ROOT = Path(__file__).resolve().parents[1]
def test_course_pwa_assets(application):
runpy.run_path(str(ROOT / "scripts/create_pwa_icons.py"), run_name="__main__")
integration = application.teloce
result = integration.build()
assert result["plugins"]["enabled"] == ["pwa"]
client = TestClient(application)
shell = client.get("/")
assert shell.status_code == 200
assert 'rel="manifest"' in shell.text
manifest = json.loads(client.get("/_flaxon/teloce-plugins/manifest.webmanifest").text)
assert manifest["start_url"] == "/projects"
assert {icon["sizes"] for icon in manifest["icons"]} == {"192x192", "512x512"}
for icon in manifest["icons"]:
response = client.get(icon["src"])
assert response.status_code == 200
assert response.content.startswith(b"\x89PNG\r\n\x1a\n")
worker = client.get("/teloce-sw.js")
assert worker.headers["service-worker-allowed"] == "/"
assert "application/javascript" in worker.headers["content-type"]
assert "/api/" not in worker.text
assert "/admin/" not in worker.text
plugin = client.get("/_flaxon/teloce-plugins/index.js").text
assert '"enabled": false' in plugin # Debug builds do not register a worker.
Say: "Verify the manifest, actual PNG responses, worker scope and development registration policy before recording installation."
Type: Follow the action above in tests/test_pwa.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 8: CREATE - make parent folders, then create this file - scripts/pwa_smoke.py
Create the disposable production browser check. It uses a temporary database, verifies cache exclusions, goes offline and reconnects.
Create the parent directories, then save this complete file.
"""Check production PWA registration and offline fallback using disposable data."""
import os
from pathlib import Path
import secrets
import subprocess
import sys
import tempfile
import time
import httpx
from playwright.sync_api import sync_playwright
from create_pwa_icons import main as create_icons
ROOT = Path(__file__).resolve().parents[1]
BASE = "http://127.0.0.1:8124"
def main():
create_icons()
with tempfile.TemporaryDirectory(prefix="course-pwa-") as directory:
environment = {
**os.environ,
"DATA_DIR": directory,
"FLAXON_DEBUG": "0",
"FLAXON_SECRET_KEY": secrets.token_urlsafe(48),
"PUBLIC_ORIGIN": BASE,
}
subprocess.run([sys.executable, "management.py", "migrate"], cwd=ROOT, env=environment, check=True)
with open(Path(directory) / "server.log", "w") as log:
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app", "--port", "8124"], cwd=ROOT, env=environment, stdout=log, stderr=log)
try:
for _ in range(150):
try:
if httpx.get(BASE + "/login", trust_env=False).status_code == 200:
break
except httpx.ConnectError:
pass
time.sleep(0.1)
else:
raise RuntimeError((Path(directory) / "server.log").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
context = browser.new_context(service_workers="allow")
page = context.new_page()
page.goto(BASE + "/login")
page.evaluate("async () => { await Promise.race([navigator.serviceWorker.ready, new Promise((_, reject) => setTimeout(() => reject(new Error('Service worker did not become ready')), 15000))]); }")
page.reload()
page.wait_for_function("navigator.serviceWorker.controller !== null")
assert page.locator('link[rel="manifest"]').count() == 1
cached = page.evaluate("async () => { const names = await caches.keys(); const urls = []; for (const name of names) { for (const request of await (await caches.open(name)).keys()) urls.push(new URL(request.url).pathname); } return urls; }")
assert cached and any(path.endswith("offline.html") for path in cached)
assert not any(path.startswith(("/api/", "/admin")) for path in cached)
# Browser offline emulation can leave worker fetches online.
# Stop the server so this checks an actual network failure.
server.terminate()
server.wait(timeout=10)
context.set_offline(True)
page.goto(BASE + "/projects")
assert page.get_by_role("heading", name="You are offline").is_visible()
context.set_offline(False)
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "app:app", "--port", "8124"], cwd=ROOT, env=environment, stdout=log, stderr=log)
for _ in range(150):
try:
if httpx.get(BASE + "/login", trust_env=False).status_code == 200:
break
except httpx.ConnectError:
pass
time.sleep(0.1)
else:
raise RuntimeError("Server did not restart after the offline check")
page.goto(BASE + "/login")
assert page.get_by_label("Email", exact=True).is_visible()
context.close()
browser.close()
print("PWA registration, private-cache exclusion, offline fallback and reconnect passed.")
finally:
server.terminate()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
if __name__ == "__main__":
main()
Say: "Create the disposable production browser check. It uses a temporary database, verifies cache exclusions, goes offline and reconnects."
Type: Follow the action above in scripts/pwa_smoke.py; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Run and check
python scripts/create_pwa_icons.py
python scripts/build_ui.py
python -m pytest -q tests/test_pwa.py
python scripts/pwa_smoke.py
Expected result
The manifest has two valid icons and starts at /projects. The production browser registers /teloce-sw.js. An offline navigation displays You are offline; reconnect restores the login page. A waiting update shows a button, and reload happens only after the user confirms.
Common errors
Use Teloce-Py 0.3.0: older builds may not implement this plugin. HTTPS or localhost is required. Generate icons before starting the app. Debug mode intentionally registers no worker. Browser installation eligibility varies. The service worker does not provide offline writes. Check DevTools Application and use Unregister/Clear storage when switching between production and development; do not clear real user data without their consent.
Short exercise
Deploy a second version to a disposable HTTPS site. Leave the first version open, request an update in DevTools, and show that the confirmation protects unfinished work.
What to say and show
Say: "Turn the completed SPA into an optional installable application without caching private data. The server remains responsible for persistence and authorization; the browser presents the result."
Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.
Save your checkpoint
git add .
git commit -m "Complete chapter 16: PWA: installation, offline fallback and updates"