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.

Framework source · Markdown

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"