04. Navigation API + DPU
Links inside this demo are intercepted by navigation.addEventListener('navigate'), fetched as fragments, and streamed into #content via streamHTMLUnsafe(). URL and history both behave correctly. Without the API, full reloads work too because each route renders standalone.
learn more
- Chrome blog: Declarative Partial Updates
- Chrome docs: Navigation API — Single
navigateevent,event.intercept({ handler }), signals, history. - MDN: Navigation API
- WICG explainer: dynamic markup revamped — Why
streamHTMLUnsafe()works as a sink for piped fetches.
source
Everything the server runs for this example. Each file links to GitHub so you can copy or fork it.
handler.ts/app/src/examples/04-navigation-api/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, readSibling, tryStatic } from "../../lib/files.ts";
interface Page {
title: string;
body: string[];
}
const CONTENT: { pages: Record<string, Page>; posts: Record<string, Page> } = JSON.parse(
readSibling(import.meta.url, "content.json"),
);
const TABS: [string, string][] = [
["home", "/04/home"],
["posts", "/04/posts"],
["about", "/04/about"],
];
function renderTabs(currentPath: string): string {
return TABS.map(([label, href]) => {
const aria = currentPath === href ? ` aria-current="page"` : "";
return `<a href="${href}"${aria}>${label}</a>`;
}).join("");
}
function pageFor(subPath: string): Page | null {
const m1 = subPath.match(/^\/(home|about|posts)$/);
if (m1) return CONTENT.pages[m1[1]];
const m2 = subPath.match(/^\/post\/([\w-]+)$/);
if (m2) return CONTENT.posts[m2[1]] ?? null;
return null;
}
function renderShell(currentPath: string, page: Page): string {
return loadShell(import.meta.url, "shell.html", {
title: `04 · ${page.title}`,
tabs: renderTabs(currentPath),
body: page.body.join(""),
});
}
export default function handle(_req: Request, path: string): Response | Promise<Response> {
if (path === "/" || path === "/index.html") {
return new Response(renderShell("/04/home", CONTENT.pages.home), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
const ssrMatch = path.match(/^\/(home|about|posts|post\/[\w-]+)$/);
if (ssrMatch) {
const page = pageFor(path);
if (page) {
return new Response(renderShell(`/04${path}`, page), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
}
const fragMatch = path.match(/^\/fragment\/(home|about|posts|post\/[\w-]+)$/);
if (fragMatch) {
const page = pageFor("/" + fragMatch[1]);
if (!page) return new Response("<p>not found</p>", { status: 404 });
return streamingResponse(async (write) => {
for (const chunk of page.body) {
await write(chunk);
await sleep(80);
}
});
}
return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}
client.js/app/src/examples/04-navigation-api/client.js
// SPA shell using Navigation API + streamHTMLUnsafe
const content = document.querySelector("#content");
function setActiveTab(pathname) {
document.querySelectorAll("nav.tabs a").forEach((a) => {
const isCurrent = new URL(a.href).pathname === pathname;
if (isCurrent) a.setAttribute("aria-current", "page");
else a.removeAttribute("aria-current");
});
}
if ("navigation" in window) {
navigation.addEventListener("navigate", (event) => {
if (!event.canIntercept || event.hashChange || event.downloadRequest) return;
const url = new URL(event.destination.url);
if (!url.pathname.startsWith("/04/")) return;
// let the shell load normally on first nav
if (url.pathname === "/04/" || url.pathname === "/04") return;
event.intercept({
async handler() {
setActiveTab(url.pathname);
content.replaceChildren();
content.innerHTML =
'<div class="skeleton lg"></div><div class="skeleton"></div><div class="skeleton sm"></div>';
const fragPath = url.pathname.replace("/04/", "/04/fragment/");
const res = await fetch(fragPath, { signal: event.signal });
if (!("streamHTMLUnsafe" in content)) {
content.innerHTML = await res.text();
return;
}
content.replaceChildren();
await res.body
.pipeThrough(new TextDecoderStream())
.pipeTo(content.streamHTMLUnsafe());
},
});
});
setActiveTab(location.pathname);
} else {
console.info("Navigation API not available — falling back to full reloads.");
}
content.json/app/src/examples/04-navigation-api/content.json
{
"pages": {
"home": {
"title": "home",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">home</h2>",
"<p>this content is streamed from <code>/04/page/home</code>. The URL in your address bar updated via <code>navigation.intercept()</code>, no full reload happened.</p>",
"<p>Try the back button. It also goes through the navigate event.</p>"
]
},
"about": {
"title": "about",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">about</h2>",
"<p>a tiny SPA built on the Navigation API + Declarative Partial Updates. The whole client is ~25 lines.</p>",
"<p>If the API is unavailable, links fall back to normal full-page navigations and everything still works because the server can render every page standalone.</p>"
]
},
"posts": {
"title": "posts",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">posts</h2>",
"<ul style=\"margin:0;padding-left:1.2rem;\">",
"<li><a href=\"/04/post/declarative-partial-updates\">declarative partial updates</a></li>",
"<li><a href=\"/04/post/navigation-api\">the navigation api</a></li>",
"<li><a href=\"/04/post/streaming-html\">streaming html</a></li>",
"</ul>"
]
}
},
"posts": {
"declarative-partial-updates": {
"title": "declarative partial updates",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">declarative partial updates</h2>",
"<p>processing instructions plus <code><template for></code> let the server stream patches into a long-lived response.</p>",
"<p><a href=\"/04/posts\">← back to posts</a></p>"
]
},
"navigation-api": {
"title": "navigation api",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">the navigation api</h2>",
"<p>one event (<code>navigate</code>) covers link clicks, form posts, back/forward, programmatic navigation. <code>event.intercept({ handler })</code> takes over.</p>",
"<p><a href=\"/04/posts\">← back to posts</a></p>"
]
},
"streaming-html": {
"title": "streaming html",
"body": [
"<h2 style=\"margin:0 0 .5rem;\">streaming html</h2>",
"<p>HTML has always streamed. DPU just gives us declarative tools so the late parts can flow into specific holes punched into the early parts.</p>",
"<p><a href=\"/04/posts\">← back to posts</a></p>"
]
}
}
}
shell.html/app/src/examples/04-navigation-api/shell.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>04 · posts</title>
<link rel="stylesheet" href="/public/styles.css">
</head>
<body>
<main>
<p class="crumbs"><a href="/">← back to index</a></p>
<h1>04. Navigation API + DPU</h1>
<p class="lede">Links inside this demo are intercepted by <code>navigation.addEventListener('navigate')</code>, fetched as fragments, and streamed into <code>#content</code> via <code>streamHTMLUnsafe()</code>. URL and history both behave correctly. Without the API, full reloads work too because each route renders standalone.</p>
<nav class="tabs">homepostsabout</nav>
<section class="demo-area" id="content">posts
</section>
<script src="/04/client.js"></script>
<section class="refs">
<h3>learn more</h3>
<ul>
<li><a href="https://developer.chrome.com/blog/declarative-partial-updates?hl=en" target="_blank" rel="noopener">Chrome blog: Declarative Partial Updates</a></li>
<li><a href="https://developer.chrome.com/docs/web-platform/navigation-api" target="_blank" rel="noopener">Chrome docs: Navigation API</a> <span class="note">— Single <code>navigate</code> event, <code>event.intercept({ handler })</code>, signals, history.</span></li>
<li><a href="https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API" target="_blank" rel="noopener">MDN: Navigation API</a></li>
<li><a href="https://github.com/WICG/declarative-partial-updates/blob/main/dynamic-markup-revamped-explainer.md" target="_blank" rel="noopener">WICG explainer: dynamic markup revamped</a> <span class="note">— Why <code>streamHTMLUnsafe()</code> works as a sink for piped fetches.</span></li>
</ul>
</section>
<!-- source-viewer goes here -->
<footer class="byline">made by <a href="https://paul.kinlan.me/" target="_blank" rel="noopener">Paul Kinlan</a></footer>
</main>
</body>
</html>
lib/streaming.ts/app/src/lib/streaming.ts
export class StreamAborted extends Error {
constructor() {
super("stream aborted by client");
this.name = "StreamAborted";
}
}
export type ChunkSource = (write: (chunk: string) => Promise<void>) => Promise<void>;
const encoder = new TextEncoder();
export function streamingResponse(
source: ChunkSource,
contentType = "text/html; charset=utf-8",
): Response {
let aborted = false;
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const write = async (chunk: string) => {
if (aborted) throw new StreamAborted();
try {
controller.enqueue(encoder.encode(chunk));
} catch {
aborted = true;
throw new StreamAborted();
}
};
try {
await source(write);
} catch (err) {
if (!(err instanceof StreamAborted)) console.error("stream source error", err);
} finally {
if (!aborted) {
try {
controller.close();
} catch {
// already closed
}
}
}
},
cancel() {
aborted = true;
},
});
return new Response(body, {
headers: {
"content-type": contentType,
"cache-control": "no-store",
"x-content-type-options": "nosniff",
},
});
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
lib/files.ts/app/src/lib/files.ts
// File helpers for examples: read sibling files, render the page shell with the
// source viewer injected, and serve sibling static assets (CSS/JS/HTML) on demand.
import { sourceBlock } from "./source.ts";
const MIME: Record<string, string> = {
css: "text/css; charset=utf-8",
js: "application/javascript; charset=utf-8",
html: "text/html; charset=utf-8",
svg: "image/svg+xml",
json: "application/json; charset=utf-8",
};
function dirFor(handlerUrl: string): string {
return new URL(".", handlerUrl).pathname;
}
export function readSibling(handlerUrl: string, name: string): string {
return Deno.readTextFileSync(dirFor(handlerUrl) + name);
}
// Reads a sibling HTML file. The marker `<!-- source-viewer goes here -->` is replaced
// with the source-viewer block. Extra named placeholders ({{name}}) can be supplied via `vars`.
const SOURCE_MARKER = "<!-- source-viewer goes here -->";
export function loadShell(
handlerUrl: string,
name = "index.html",
vars: Record<string, string> = {},
): string {
let raw = readSibling(handlerUrl, name);
raw = raw.replace(SOURCE_MARKER, sourceBlock(handlerUrl));
for (const [k, v] of Object.entries(vars)) {
raw = raw.replaceAll(`{{${k}}}`, v);
}
return raw;
}
// Try to serve a sibling static file from the example folder. Returns null when the
// file is missing, the path escapes the folder, or the path is empty.
export function tryStatic(handlerUrl: string, requestPath: string): Response | null {
const safe = requestPath.replace(/^\/+/, "");
if (!safe || safe.includes("..")) return null;
// Never let the browser fetch handler.ts or README.md through this fallback.
if (safe === "handler.ts" || safe === "README.md") return null;
try {
const content = Deno.readFileSync(dirFor(handlerUrl) + safe);
const ext = safe.split(".").pop() ?? "";
return new Response(content, {
headers: {
"content-type": MIME[ext] ?? "application/octet-stream",
"cache-control": "no-store",
},
});
} catch {
return null;
}
}