← back to index

03. htmx-style attributes, in 40 lines

A tiny client (~40 lines) wires data-get, data-target and data-swap to streamed partial updates. No library. Add data-get="/path" to any element and it works.

Target #out

target. swap modes: inner / append / prepend / replace.

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/03-htmx-emulation/handler.ts
import { sleep, streamingResponse } from "../../lib/streaming.ts";
import { loadShell, tryStatic } from "../../lib/files.ts";

const SHELL = loadShell(import.meta.url);

const FRAGMENTS: Record<string, string[]> = {
  news: [
    '<h2 style="margin:0 0 .4rem;">latest news</h2>',
    '<ul style="margin:0;padding-left:1.2rem;">',
    "<li>chrome 148 ships declarative partial updates</li>",
    "<li>navigation api ships in safari 26.2</li>",
    "<li>web push reaches 10 years from spec to ios</li>",
    "</ul>",
  ],
  weather: [
    '<h2 style="margin:0 0 .4rem;">weather</h2>',
    '<p style="margin:0;">liverpool, 12°c, cloudy with hopeful sun.</p>',
  ],
  quote: [
    '<h2 style="margin:0 0 .4rem;">quote</h2>',
    '<blockquote style="margin:0;border-left:3px solid var(--accent);padding-left:.8rem;color:var(--muted);">',
    "&quot;the web is being cooked, and it smells fantastic.&quot;",
    "</blockquote>",
  ],
};

function timestamped(prefix: string): string[] {
  return [
    `<p style="margin:0.5rem 0 0;color:var(--muted);font-size:.9rem;">${prefix} at ${
      new Date().toLocaleTimeString()
    }</p>`,
  ];
}

export default function handle(_req: Request, path: string): Response | Promise<Response> {
  if (path === "/" || path === "/index.html") {
    return new Response(SHELL, { headers: { "content-type": "text/html; charset=utf-8" } });
  }

  const fragMatch = path.match(/^\/fragment\/(\w+)$/);
  if (fragMatch) {
    const key = fragMatch[1];
    const tokens = key === "append"
      ? timestamped("appended")
      : key === "prepend"
      ? timestamped("prepended")
      : FRAGMENTS[key];
    if (!tokens) return new Response("<p>fragment not found</p>", { status: 404 });
    return streamingResponse(async (write) => {
      for (const t of tokens) {
        await write(t);
        await sleep(120);
      }
    });
  }

  return tryStatic(import.meta.url, path) ?? new Response("Not found", { status: 404 });
}

open on GitHub →

client.js/app/src/examples/03-htmx-emulation/client.js
// pure-DOM htmx-lite, ~40 lines
document.addEventListener("click", async (e) => {
  const el = e.target.closest("[data-get]");
  if (!el) return;
  e.preventDefault();
  const target = document.querySelector(el.dataset.target);
  if (!target) return;
  const swap = el.dataset.swap || "inner";
  const sinkFor = {
    inner: (t) => t.streamHTMLUnsafe?.(),
    append: (t) => t.streamAppendHTMLUnsafe?.(),
    prepend: (t) => t.streamPrependHTMLUnsafe?.(),
    replace: (t) => t.streamReplaceWithHTMLUnsafe?.(),
  };
  const sink = sinkFor[swap]?.(target);
  if (!sink) {
    // graceful fallback for browsers without DPU
    const html = await (await fetch(el.dataset.get)).text();
    if (swap === "inner") target.innerHTML = html;
    if (swap === "append") target.insertAdjacentHTML("beforeend", html);
    if (swap === "prepend") target.insertAdjacentHTML("afterbegin", html);
    if (swap === "replace") target.outerHTML = html;
    return;
  }
  const res = await fetch(el.dataset.get);
  await res.body
    .pipeThrough(new TextDecoderStream())
    .pipeTo(sink);
});

open on GitHub →

index.html/app/src/examples/03-htmx-emulation/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>03 · HTMX emulation</title>
  <link rel="stylesheet" href="/public/styles.css">
</head>
<body>
<main>
  <p class="crumbs"><a href="/">&larr; back to index</a></p>
  <h1>03. htmx-style attributes, in 40 lines</h1>
  <p class="lede">A tiny client (~40 lines) wires <code>data-get</code>, <code>data-target</code> and <code>data-swap</code> to streamed partial updates. No library. Add <code>data-get="/path"</code> to any element and it works.</p>

  <section style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem;">
    <button data-get="/03/fragment/news"    data-target="#out" data-swap="inner">load news</button>
    <button data-get="/03/fragment/weather" data-target="#out" data-swap="inner">load weather</button>
    <button data-get="/03/fragment/quote"   data-target="#out" data-swap="inner">load quote</button>
    <button data-get="/03/fragment/append"  data-target="#out" data-swap="append">append paragraph</button>
    <button data-get="/03/fragment/prepend" data-target="#out" data-swap="prepend">prepend paragraph</button>
    <button data-get="/03/fragment/news"    data-target="#out" data-swap="replace">replace whole &lt;section&gt;</button>
  </section>

  <h3>Target #out</h3>
  <section class="demo-area" id="out">
    <p style="color:var(--muted);margin:0;">target. swap modes: inner / append / prepend / replace.</p>
  </section>

  <script src="/03/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> <span class="note">— Background for the streaming methods used here.</span></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">— The full streamHTML / streamAppend / streamPrepend / streamReplaceWith family.</span></li>
      <li><a href="https://github.com/GoogleChromeLabs/html-setters-polyfill" target="_blank" rel="noopener">html-setters polyfill</a> <span class="note">— Same API in browsers that lack the native methods.</span></li>
      <li><a href="https://htmx.org/" target="_blank" rel="noopener">htmx.org</a> <span class="note">— The library this is paying tribute to.</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 →