7. Offline and PWA
Complete Taskboard with three observable flows: a local offline queue, connectivity changes, and a user-controlled service worker update.
Table of contents
Stage 7. Add local offline delivery and PWA updates
- Starting state
- Complete Stage 6 so the browser hydrates the Taskboard response rendered for each request with routes, FormStore and PulseStore.
- Applied change
- Keep the offline integration inactive during the initial server and browser render so both produce the same tree.
- Observable result
- The server response and first client tree share the same state and markup. After runtime.init(), Taskboard renders pending, failed, syncing, queue event, network and worker changes. Its controls enqueue tasks, create a local failure, retry or discard it, and apply an available worker update.
- Next step
- Build the bundles and start dist/server.mjs. The runtime SSR server also serves the generated manifest, icons and worker.
Run the completed Taskboard
Build and start dist/server.mjs. Each request renders Taskboard without initializing browser managers. In the browser, mountRouter hydrates that response before runtime.init() starts the offline services.
1. OfflineQueue local lifecycle
Queue a normal task and observe sync success. Create a failed task, then choose Retry failed task to add Recovered task or create another failure and choose Discard failed task.
- Input and operation
- FormStore passes a title to enqueueTask. queue.enqueue stores the local create-task operation, and sync() runs the handler while NetworkManager reports online.
- Observable state and events
- queue.state() supplies pending, failed and syncing. The code observes change, sync:success and sync:error and publishes each result to the Taskboard view.
- Failed, retry and discard
- Create failed task moves one local operation to failed. Retry failed task calls retryAll() and sync() after enabling local recovery. Discard failed task calls discardFailed().
2. NetworkManager connectivity lifecycle
- Status and events
- NetworkManager supplies getNetworkStatus().online. ONLINE, OFFLINE and CHANGE all publish the current value through PulseStore.
- User action
- Switch the browser between online and offline and observe Network. Returning ONLINE lets OfflineQueue sync pending operations automatically.
3. SwRuntimeManager update lifecycle
Change the version passed to sw(), rebuild and reload. Choose Apply update when Taskboard reports the available worker.
- Build and initialization
- icons() creates manifest and icon assets from the SVG source and sw() writes the worker. After mountRouter, runtime.init() creates SwRuntimeManager and worker.init() registers /sw.js.
- State and observed events
- worker.state supplies updateAvailable. The code observes registered, updateavailable, updated and error and publishes workerStatus.
- prompt-user action
- When updateavailable fires, Taskboard shows Apply update. The button calls applyUpdate() for the waiting worker.
build.mjs
jsimport fs from "node:fs";
import { icons, inline, sw } from "valyrian.js/node";
fs.mkdirSync("./dist", { recursive: true });
fs.mkdirSync("./public", { recursive: true });
const server = await inline("./src/server.tsx", {
esbuild: { format: "esm", packages: "external", platform: "node" },
});
const client = await inline("./src/client-entry.tsx");
fs.writeFileSync("./dist/server.mjs", server.raw);
fs.writeFileSync("./public/client-entry.js", client.raw);
await icons("./src/taskboard-icon.svg", {
iconsPath: "./public",
appName: "Taskboard",
});
sw("./public/sw.js", { version: "1.0.0", name: "Taskboard" });
src/taskboard-icon.svg
svg<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12"/><path d="M18 20h28v6H18zm0 10h28v6H18zm0 10h18v6H18z" fill="white"/></svg>
src/state.ts
tsimport { createPulseStore } from "valyrian.js/pulses";
export type Task = { id: number; title: string };
export type TaskboardState = {
tasks: Task[];
showDetails: boolean;
nextId: number;
loaded: boolean;
online: boolean;
pending: number;
failed: number;
syncing: boolean;
queueEvent: string;
workerStatus: string;
updateAvailable: boolean;
};
export function createTaskboardStore(
initial: Partial<TaskboardState> = {},
) {
return createPulseStore(
{
tasks: (initial.tasks ?? [{ id: 1, title: "Learn Valyrian.js" }]).map(
(task) => ({ ...task }),
),
showDetails: initial.showDetails ?? false,
nextId: initial.nextId ?? 2,
loaded: initial.loaded ?? false,
online: initial.online ?? true,
pending: initial.pending ?? 0,
failed: initial.failed ?? 0,
syncing: initial.syncing ?? false,
queueEvent: initial.queueEvent ?? "idle",
workerStatus: initial.workerStatus ?? "registering",
updateAvailable: initial.updateAvailable ?? false,
},
{
toggleDetails(state) {
state.showDetails = !state.showDetails;
},
addTask(state, task: Task) {
state.tasks.push(task);
state.nextId = Math.max(state.nextId, task.id + 1);
},
replaceTasks(state, tasks: Task[]) {
state.tasks = tasks;
state.loaded = true;
state.nextId = tasks.reduce(
(nextId, task) => Math.max(nextId, task.id + 1),
1,
);
},
setNetworkStatus(state, online: boolean) {
state.online = online;
},
setQueueState(
state,
queue: { pending: number; failed: number; syncing: boolean },
) {
state.pending = queue.pending;
state.failed = queue.failed;
state.syncing = queue.syncing;
},
setQueueEvent(state, queueEvent: string) {
state.queueEvent = queueEvent;
},
setWorkerState(
state,
workerStatus: string,
updateAvailable: boolean,
) {
state.workerStatus = workerStatus;
state.updateAvailable = updateAvailable;
},
},
);
}
export type TaskboardStore = ReturnType<typeof createTaskboardStore>;
src/server.tsx
tsximport { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { extname, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { render, ServerStorage } from "valyrian.js/node";
import type { Task } from "./state";
import { createTaskboardApp } from "./taskboard";
const publicRoot = fileURLToPath(new URL("../public", import.meta.url));
const contentTypes: Record<string, string> = {
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml; charset=utf-8",
".webmanifest": "application/manifest+json; charset=utf-8",
};
function TaskboardDocument({
routeHtml,
initialState,
}: {
routeHtml: string;
initialState: unknown;
}) {
return (
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Taskboard</title>
<script src="/client-entry.js" defer />
</head>
<body
data-initial-state={JSON.stringify(initialState)}
v-html={routeHtml}
/>
</html>
);
}
const server = createServer((request, response) => {
void (async () => {
try {
if (typeof request.url !== "string") {
response.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Invalid request URL");
return;
}
const requestUrl = request.url;
const pathname = new URL(requestUrl, "http://localhost").pathname;
if (extname(pathname) !== "") {
const filePath = resolve(publicRoot, "." + pathname);
if (filePath.startsWith(publicRoot + sep) === false) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
return;
}
try {
const asset = await readFile(filePath);
response.writeHead(200, {
"Content-Type":
contentTypes[extname(filePath)] ?? "application/octet-stream",
});
response.end(asset);
} catch {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found");
}
return;
}
await ServerStorage.run(async () => {
const tasks = JSON.parse(
await readFile(resolve(publicRoot, "tasks.json"), "utf8"),
) as Task[];
const app = createTaskboardApp();
app.store.replaceTasks(tasks);
const routeHtml = (await app.router.go(requestUrl)) ?? "";
const initialState = app.store.state;
const documentHtml =
"<!doctype html>" +
render(
<TaskboardDocument
routeHtml={routeHtml}
initialState={initialState}
/>,
);
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(documentHtml);
});
} catch {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Server render failed");
}
})();
});
const portValue = process.env.PORT ?? "3000";
if (/^[1-9]\d*$/.test(portValue) === false || Number(portValue) > 65535) {
throw new Error("PORT must be an integer from 1 to 65535");
}
server.listen(Number(portValue), "127.0.0.1", () => {
console.log("Open http://127.0.0.1:" + portValue);
});
src/offline.ts
tsimport { NetworkEvent, NetworkManager } from "valyrian.js/network";
import { OfflineQueue } from "valyrian.js/offline";
import { SwRuntimeManager } from "valyrian.js/sw";
import type { SubmitTask } from "./task-form";
import type { TaskboardStore } from "./state";
export function createOfflineRuntime(store: TaskboardStore) {
let initialized = false;
let queue: OfflineQueue | null = null;
let worker: SwRuntimeManager | null = null;
let recoverFailures = false;
const enqueueTask: SubmitTask = (title) => {
if (queue === null) {
return;
}
queue.enqueue({ type: "create-task", payload: title });
void queue.sync();
};
return {
enqueueTask,
init() {
if (initialized) {
return;
}
initialized = true;
const network = new NetworkManager();
queue = new OfflineQueue({
id: "taskboard",
network,
isRetryable: () => false,
handler: async (operation) => {
if (operation.type === "fail-task" && recoverFailures === false) {
throw new Error("Local task operation failed");
}
store.addTask({
id: store.state.nextId,
title:
operation.type === "fail-task"
? "Recovered task"
: String(operation.payload),
});
},
});
const publishQueue = () => store.setQueueState(queue?.state() ?? {
pending: 0,
failed: 0,
syncing: false,
});
const publishNetwork = () => {
store.setNetworkStatus(network.getNetworkStatus().online);
};
const publishSyncSuccess = () => store.setQueueEvent("sync success");
const publishSyncError = () => store.setQueueEvent("sync error");
queue.on("change", publishQueue);
queue.on("sync:success", publishSyncSuccess);
queue.on("sync:error", publishSyncError);
network.on(NetworkEvent.ONLINE, publishNetwork);
network.on(NetworkEvent.OFFLINE, publishNetwork);
network.on(NetworkEvent.CHANGE, publishNetwork);
publishQueue();
publishNetwork();
worker = new SwRuntimeManager({
swUrl: "/sw.js",
strategy: "prompt-user",
});
const publishWorkerRegistered = () => {
store.setWorkerState("registered", worker?.state.updateAvailable ?? false);
};
const publishWorkerUpdate = () => {
store.setWorkerState("update available", true);
};
const publishWorkerUpdated = () => {
store.setWorkerState("updated", false);
};
const publishWorkerError = () => {
store.setWorkerState("registration failed", false);
};
worker.on("registered", publishWorkerRegistered);
worker.on("updateavailable", publishWorkerUpdate);
worker.on("updated", publishWorkerUpdated);
worker.on("error", publishWorkerError);
void worker.init();
},
createFailure() {
if (queue === null) {
return;
}
recoverFailures = false;
queue.enqueue({ type: "fail-task" });
void queue.sync();
},
retryFailed() {
if (queue === null) {
return;
}
recoverFailures = true;
queue.retryAll();
void queue.sync();
},
discardFailed() {
queue?.discardFailed();
},
applyUpdate() {
worker?.applyUpdate();
},
};
}
export type TaskboardRuntime = ReturnType<typeof createOfflineRuntime>;
src/task-form.tsx
tsximport { FormStore } from "valyrian.js/forms";
import type { TaskboardStore } from "./state";
export type SubmitTask = (title: string) => Promise<void> | void;
export type TaskFormStore = FormStore<{ title: string }>;
export function createTaskForm(
store: TaskboardStore,
submitTask?: SubmitTask,
) {
return new FormStore({
state: { title: "" },
schema: {
type: "object",
required: ["title"],
properties: { title: { type: "string", minLength: 2 } },
},
clean: { title: (value) => String(value).trim() },
onSubmit: async ({ title }) => {
await new Promise((resolve) => setTimeout(resolve, 400));
if (title === "fail") {
throw new Error("Local FormStore submission failed");
}
if (submitTask) {
return submitTask(title);
}
store.addTask({ id: store.state.nextId, title });
},
});
}
export function TaskForm({ form }: { form: TaskFormStore }) {
return (
<form v-form={form}>
<label>
Task title
<input name="title" v-field={form} placeholder="Add a task title" />
</label>
<p v-if={form.validationErrors.title}>{form.validationErrors.title}</p>
<button type="submit" disabled={form.isInflight}>
{form.isInflight ? "Queueing task..." : "Queue task"}
</button>
<p v-if={form.submitError}>{String(form.submitError)}</p>
<p v-if={form.success}>Task queued</p>
</form>
);
}
src/app.tsx
tsximport type { TaskboardRuntime } from "./offline";
import { TaskForm, type TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
export function TaskboardApp({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
const state = store.state;
return (
<main>
<h1>Taskboard</h1>
<button onclick={() => store.toggleDetails()}>Toggle details</button>
<p v-if={state.showDetails}>Tasks stay in one shared PulseStore.</p>
<TaskForm form={form} />
<p>
Network: {state.online ? "online" : "offline"}, syncing: {state.syncing ? "yes" : "no"}
</p>
<p>Queue: pending {state.pending}, failed {state.failed}, event {state.queueEvent}</p>
<button onclick={() => runtime.createFailure()}>Create failed task</button>
<button onclick={() => runtime.retryFailed()}>Retry failed task</button>
<button onclick={() => runtime.discardFailed()}>Discard failed task</button>
<p>Worker: {state.workerStatus}</p>
<button
v-if={state.updateAvailable}
onclick={() => runtime.applyUpdate()}
>
Apply update
</button>
<ul v-for={state.tasks}>
{(task: Task) => <li key={task.id}>{task.title}</li>}
</ul>
</main>
);
}
src/tasks-screen.tsx
tsximport { request } from "valyrian.js/request";
import { Suspense } from "valyrian.js/suspense";
import { TaskboardApp } from "./app";
import type { TaskboardRuntime } from "./offline";
import type { TaskFormStore } from "./task-form";
import type { Task, TaskboardStore } from "./state";
async function LoadedTasks({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
if (store.state.loaded === false) {
const tasks = (await request.get("/tasks.json")) as Task[];
store.replaceTasks(tasks);
}
return <TaskboardApp store={store} form={form} runtime={runtime} />;
}
export function TasksScreen({
store,
form,
runtime,
}: {
store: TaskboardStore;
form: TaskFormStore;
runtime: TaskboardRuntime;
}) {
if (store.state.loaded) {
return <TaskboardApp store={store} form={form} runtime={runtime} />;
}
return (
<Suspense
suspenseKey="taskboard:tasks"
fallback={<p>Loading tasks...</p>}
error={(error) => <p>{String(error)}</p>}
>
<LoadedTasks store={store} form={form} runtime={runtime} />
</Suspense>
);
}
src/router.tsx
tsximport { Router } from "valyrian.js/router";
import type { TaskboardRuntime } from "./offline";
import type { TaskFormStore } from "./task-form";
import type { TaskboardStore } from "./state";
import { TasksScreen } from "./tasks-screen";
export function createRouter(
store: TaskboardStore,
form: TaskFormStore,
runtime: TaskboardRuntime,
) {
const router = new Router();
router.add("/", () => (
<>
<button onclick={() => router.go("/about")}>About</button>
<TasksScreen store={store} form={form} runtime={runtime} />
</>
));
router.add("/about", () => (
<main>
<h1>About Taskboard</h1>
<button onclick={() => router.go("/")}>Tasks</button>
</main>
));
router.catch(404, () => <h1>Taskboard page not found</h1>);
return router;
}
src/taskboard.ts
tsimport { createOfflineRuntime } from "./offline";
import { createRouter } from "./router";
import { createTaskForm } from "./task-form";
import {
createTaskboardStore,
type TaskboardState,
} from "./state";
export function createTaskboardApp(
initialState: Partial<TaskboardState> = {},
) {
const store = createTaskboardStore(initialState);
const runtime = createOfflineRuntime(store);
const form = createTaskForm(store, runtime.enqueueTask);
const router = createRouter(store, form, runtime);
return { store, form, router, runtime };
}
src/client-entry.tsx
tsximport { mountRouter } from "valyrian.js/router";
import { createTaskboardApp } from "./taskboard";
import type { TaskboardState } from "./state";
const encodedState = document.body.getAttribute("data-initial-state");
if (typeof encodedState !== "string") {
throw new Error("Initial state is missing");
}
const initialState = JSON.parse(encodedState) as TaskboardState;
const app = createTaskboardApp(initialState);
mountRouter("body", app.router);
app.runtime.init();