← back to index

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.

home

this content is streamed from /04/page/home. The URL in your address bar updated via navigation.intercept(), no full reload happened.

Try the back button. It also goes through the navigate event.

learn more

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 });
}

open on GitHub →

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.");
}

open on GitHub →

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>&lt;template for&gt;</code> let the server stream patches into a long-lived response.</p>",
        "<p><a href=\"/04/posts\">&larr; 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\">&larr; 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\">&larr; back to posts</a></p>"
      ]
    }
  }
}

open on GitHub →

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 · home</title>
  <link rel="stylesheet" href="/public/styles.css">
</head>
<body>
<main>
  <p class="crumbs"><a href="/">&larr; 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">

home

this content is streamed from /04/page/home. The URL in your address bar updated via navigation.intercept(), no full reload happened.

Try the back button. It also goes through the navigate event.

</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>

open on GitHub →

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));
}

open on GitHub →

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;
  }
}

open on GitHub →