From 1f247814f538068acb43708a9be0c7840c92c4b3 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:29:31 -0400 Subject: [PATCH 1/7] feat: add the streaming swap bootstrap Co-Authored-By: Claude Fable 5 --- packages/bones/src/server/bootstrap.ts | 31 +++++ packages/bones/tests/server-bootstrap.test.ts | 112 ++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 packages/bones/src/server/bootstrap.ts create mode 100644 packages/bones/tests/server-bootstrap.test.ts diff --git a/packages/bones/src/server/bootstrap.ts b/packages/bones/src/server/bootstrap.ts new file mode 100644 index 0000000..1cb3454 --- /dev/null +++ b/packages/bones/src/server/bootstrap.ts @@ -0,0 +1,31 @@ +// --------------------------------------------------------------------------- +// The inline swap runtime, sent once per streamed document. +// +// Readable form (the tests execute the shipped string against both branches): +// +// function __bonesSwap(id, err) { +// var t = document.querySelector('template[data-bones-chunk="' + id + '"]'); +// var b = document.querySelector('[data-bones-slot="' + id + '"]'); +// if (!b) { if (t) t.remove(); return; } +// if (t) { b.replaceChildren(t.content); t.remove(); } +// if (err) b.setAttribute("data-bones-error", ""); +// if (typeof b.busy === "boolean") b.busy = false; +// else { +// b.removeAttribute("busy"); +// b.removeAttribute("aria-busy"); +// b.removeAttribute("inert"); +// } +// } +// +// The typeof check picks the path. An upgraded gets +// `busy = false` and runs its own draining and hide machinery. Anything else +// — the element module absent or still loading, or a plain [data-bones-slot] +// target — gets the three attributes removed directly. Clearing aria-busy or +// inert from outside a showing element would fight its attribute defense, so +// the bootstrap never touches them on an upgraded element. +// --------------------------------------------------------------------------- + +export const BOOTSTRAP_JS = + 'function __bonesSwap(e,r){var t=document.querySelector(\'template[data-bones-chunk="\'+e+\'"]\'),n=document.querySelector(\'[data-bones-slot="\'+e+\'"]\');n?(t&&(n.replaceChildren(t.content),t.remove()),r&&n.setAttribute("data-bones-error",""),"boolean"==typeof n.busy?n.busy=!1:(n.removeAttribute("busy"),n.removeAttribute("aria-busy"),n.removeAttribute("inert"))):t&&t.remove()}'; + +export const BOOTSTRAP_SCRIPT = ``; diff --git a/packages/bones/tests/server-bootstrap.test.ts b/packages/bones/tests/server-bootstrap.test.ts new file mode 100644 index 0000000..6d4f421 --- /dev/null +++ b/packages/bones/tests/server-bootstrap.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test"; +import "../src/element/index.ts"; +import { BOOTSTRAP_JS, BOOTSTRAP_SCRIPT } from "../src/server/bootstrap.ts"; + +// --------------------------------------------------------------------------- +// The shipped bootstrap string, executed against both of its branches: an +// upgraded (busy = false, its own machinery hides) and a +// plain [data-bones-slot] target (attributes removed directly). +// --------------------------------------------------------------------------- + +type Swap = (id: string, err?: number) => void; + +function swap(): Swap { + // oxlint-disable-next-line no-eval -- the deliverable is a string of JS; executing it is the test + window.eval(BOOTSTRAP_JS); + return (window as unknown as { __bonesSwap: Swap }).__bonesSwap; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + document.body.innerHTML = ""; + vi.useRealTimers(); +}); + +test("BOOTSTRAP_SCRIPT is the runtime in one script tag", () => { + expect(BOOTSTRAP_SCRIPT).toBe(``); +}); + +test("non-upgraded target: swaps children, removes the template and all three attributes", () => { + document.body.innerHTML = + '

fallback

' + + ''; + swap()("a"); + const target = document.querySelector('[data-bones-slot="a"]')!; + expect(target.innerHTML).toBe("

real

"); + expect(target.hasAttribute("busy")).toBe(false); + expect(target.hasAttribute("aria-busy")).toBe(false); + expect(target.hasAttribute("inert")).toBe(false); + expect(document.querySelector("template")).toBeNull(); +}); + +test("upgraded boundary: content swaps at once, the element hides on its own clock", () => { + document.body.innerHTML = + '

fallback

' + + ''; + const el = document.querySelector("bones-boundary")!; + // Server-rendered aria-busy adopts as showing at upgrade (BON-3). + expect(el.showing).toBe(true); + const log: string[] = []; + el.addEventListener("bones:hide", () => log.push("hide")); + swap()("a"); + expect(el.innerHTML).toBe("

real

"); + expect(el.busy).toBe(false); + // min-duration (default 400ms) counts from upgrade: still draining, and + // the bootstrap left aria-busy and inert to the element. + expect(el.getAttribute("aria-busy")).toBe("true"); + expect(el.hasAttribute("inert")).toBe(true); + vi.advanceTimersByTime(400); + expect(el.hasAttribute("aria-busy")).toBe(false); + expect(log).toEqual(["hide"]); +}); + +test("error chunk with content: swaps, marks, unbusies", () => { + document.body.innerHTML = + '

fallback

' + + ''; + swap()("a", 1); + const target = document.querySelector('[data-bones-slot="a"]')!; + expect(target.innerHTML).toBe("

broke

"); + expect(target.getAttribute("data-bones-error")).toBe(""); + expect(target.hasAttribute("aria-busy")).toBe(false); +}); + +test("bare error chunk: keeps the fallback children, marks, unbusies", () => { + document.body.innerHTML = + '

fallback

'; + swap()("a", 1); + const target = document.querySelector('[data-bones-slot="a"]')!; + expect(target.innerHTML).toBe("

fallback

"); + expect(target.getAttribute("data-bones-error")).toBe(""); + expect(target.hasAttribute("busy")).toBe(false); + expect(target.hasAttribute("aria-busy")).toBe(false); + expect(target.hasAttribute("inert")).toBe(false); +}); + +test("missing boundary: removes the orphan template, throws nothing", () => { + document.body.innerHTML = ''; + swap()("a"); + expect(document.querySelector("template")).toBeNull(); +}); + +test("a chunk can contain a nested pending boundary", () => { + document.body.innerHTML = + '

fallback

' + + '"; + const run = swap(); + run("outer"); + const inner = document.querySelector('[data-bones-slot="inner"]')!; + expect(inner.getAttribute("aria-busy")).toBe("true"); + document.body.insertAdjacentHTML( + "beforeend", + '', + ); + run("inner"); + expect(inner.innerHTML).toBe("

inner real

"); + expect(inner.hasAttribute("aria-busy")).toBe(false); +}); From 84d63464658b17de166680b93a1b7083ca5b9010 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:35:08 -0400 Subject: [PATCH 2/7] feat: add @camp.dev/bones/server streaming helper Co-Authored-By: Claude Fable 5 --- packages/bones/package.json | 1 + packages/bones/src/server/index.ts | 93 ++++++++++++++++++ packages/bones/tests/server.test.ts | 143 ++++++++++++++++++++++++++++ packages/bones/vite.config.ts | 2 +- 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 packages/bones/src/server/index.ts create mode 100644 packages/bones/tests/server.test.ts diff --git a/packages/bones/package.json b/packages/bones/package.json index b787eec..8528cc2 100644 --- a/packages/bones/package.json +++ b/packages/bones/package.json @@ -25,6 +25,7 @@ ".": "./dist/index.mjs", "./element": "./dist/element/index.mjs", "./react": "./dist/react/index.mjs", + "./server": "./dist/server/index.mjs", "./package.json": "./package.json", "./css": { "style": "./src/css/bones.css", diff --git a/packages/bones/src/server/index.ts b/packages/bones/src/server/index.ts new file mode 100644 index 0000000..ab95a8a --- /dev/null +++ b/packages/bones/src/server/index.ts @@ -0,0 +1,93 @@ +// --------------------------------------------------------------------------- +// @camp.dev/bones/server — the wire protocol, as functions +// +// Runs anywhere with Web Streams: no DOM, no Node APIs, no imports from the +// element or React entries. streamBones is built from the exported +// primitives, so the high-level path and the documented protocol cannot +// drift. Ids are validated instead of escaped: [A-Za-z0-9_-]+ needs no +// escaping in an attribute value or a JS string literal. +// --------------------------------------------------------------------------- + +import { BOOTSTRAP_SCRIPT } from "./bootstrap.ts"; + +export { BOOTSTRAP_SCRIPT }; + +const ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +function assertId(id: string): void { + if (!ID_PATTERN.test(id)) { + throw new Error(`bones slot id ${JSON.stringify(id)} must match [A-Za-z0-9_-]+`); + } +} + +export function renderBoundary(id: string, fallbackHtml: string, attrs?: string): string { + assertId(id); + const extra = attrs === undefined || attrs === "" ? "" : ` ${attrs}`; + return `${fallbackHtml}`; +} + +export function renderChunk(id: string, html: string): string { + assertId(id); + return ``; +} + +export function renderErrorChunk(id: string, html?: string): string { + assertId(id); + if (html === undefined) return ``; + return ``; +} + +export interface StreamBonesOptions { + /** + * Renders the error HTML for a rejected slot. Returning undefined (and + * throwing) falls back to the bare error chunk, which keeps the boundary's + * fallback children. + */ + onError?: (id: string, error: unknown) => string | undefined; +} + +export function streamBones( + shell: string, + slots: Record>, + options: StreamBonesOptions = {}, +): ReadableStream { + const ids = Object.keys(slots); + for (const id of ids) assertId(id); + const encoder = new TextEncoder(); + let cancelled = false; + return new ReadableStream({ + start(controller) { + const send = (html: string): void => { + if (!cancelled) controller.enqueue(encoder.encode(html)); + }; + send(shell + BOOTSTRAP_SCRIPT); + if (ids.length === 0) { + controller.close(); + return; + } + let pending = ids.length; + const settle = (chunk: string): void => { + send(chunk); + pending -= 1; + if (pending === 0 && !cancelled) controller.close(); + }; + for (const id of ids) { + slots[id].then( + (html) => settle(renderChunk(id, html)), + (error) => { + let html: string | undefined; + try { + html = options.onError?.(id, error); + } catch { + html = undefined; + } + settle(renderErrorChunk(id, html)); + }, + ); + } + }, + cancel() { + cancelled = true; + }, + }); +} diff --git a/packages/bones/tests/server.test.ts b/packages/bones/tests/server.test.ts new file mode 100644 index 0000000..b6faca9 --- /dev/null +++ b/packages/bones/tests/server.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + BOOTSTRAP_SCRIPT, + renderBoundary, + renderChunk, + renderErrorChunk, + streamBones, +} from "../src/server/index.ts"; + +// --------------------------------------------------------------------------- +// Stream assembly, no DOM: the wire protocol as strings and their order. +// The bootstrap's behavior in a document lives in server-bootstrap.test.ts. +// --------------------------------------------------------------------------- + +async function drain(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) return chunks; + chunks.push(decoder.decode(value)); + } +} + +function deferred(): { + promise: Promise; + resolve: (v: T) => void; + reject: (r: unknown) => void; +} { + let resolve!: (v: T) => void; + let reject!: (r: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const SHELL = "

shell

"; + +describe("primitives", () => { + test("renderBoundary emits the server-busy boundary", () => { + expect(renderBoundary("a", "

fallback

")).toBe( + '

fallback

', + ); + }); + + test("renderBoundary appends extra attributes", () => { + expect(renderBoundary("a", "", 'precision="measured"')).toBe( + '', + ); + }); + + test("renderChunk emits the template and the swap call together", () => { + expect(renderChunk("a", "

hi

")).toBe( + '', + ); + }); + + test("renderErrorChunk is bare without html", () => { + expect(renderErrorChunk("a")).toBe(''); + }); + + test("renderErrorChunk carries html when given", () => { + expect(renderErrorChunk("a", "

broke

")).toBe( + '', + ); + }); + + test.each(["", "a b", "a { + expect(() => renderBoundary(id, "")).toThrowError(/must match/); + expect(() => renderChunk(id, "")).toThrowError(/must match/); + expect(() => renderErrorChunk(id)).toThrowError(/must match/); + expect(() => streamBones("", { [id]: Promise.resolve("") })).toThrowError(/must match/); + }); +}); + +describe("streamBones", () => { + test("the first chunk is the shell plus the bootstrap, exactly once", async () => { + const chunks = await drain(streamBones(SHELL, {})); + expect(chunks).toEqual([SHELL + BOOTSTRAP_SCRIPT]); + }); + + test("chunks flush in settlement order and the stream closes after the last", async () => { + const first = deferred(); + const second = deferred(); + const reading = drain(streamBones(SHELL, { first: first.promise, second: second.promise })); + second.resolve("

2

"); + first.resolve("

1

"); + expect(await reading).toEqual([ + SHELL + BOOTSTRAP_SCRIPT, + renderChunk("second", "

2

"), + renderChunk("first", "

1

"), + ]); + }); + + test("a rejection flushes the bare error chunk by default", async () => { + const chunks = await drain(streamBones(SHELL, { a: Promise.reject(new Error("nope")) })); + expect(chunks).toEqual([SHELL + BOOTSTRAP_SCRIPT, renderErrorChunk("a")]); + }); + + test("onError renders the error chunk's content", async () => { + const chunks = await drain( + streamBones( + SHELL, + { a: Promise.reject(new Error("nope")) }, + { onError: (id, error) => `

${id}: ${(error as Error).message}

` }, + ), + ); + expect(chunks[1]).toBe(renderErrorChunk("a", "

a: nope

")); + }); + + test("onError returning undefined and onError throwing both fall back to the bare chunk", async () => { + const silent = await drain( + streamBones(SHELL, { a: Promise.reject(new Error("nope")) }, { onError: () => undefined }), + ); + expect(silent[1]).toBe(renderErrorChunk("a")); + const throwing = await drain( + streamBones( + SHELL, + { a: Promise.reject(new Error("nope")) }, + { + onError: () => { + throw new Error("renderer broke"); + }, + }, + ), + ); + expect(throwing[1]).toBe(renderErrorChunk("a")); + }); + + test("cancelling stops future flushes without throwing", async () => { + const slot = deferred(); + const stream = streamBones(SHELL, { a: slot.promise }); + const reader = stream.getReader(); + await reader.read(); // the shell + await reader.cancel(); + slot.resolve("

late

"); + await slot.promise; + await Promise.resolve(); // the settle callback runs here — it must not throw + }); +}); diff --git a/packages/bones/vite.config.ts b/packages/bones/vite.config.ts index 17a8a33..f669549 100644 --- a/packages/bones/vite.config.ts +++ b/packages/bones/vite.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ "*": "vp check --fix", }, pack: { - entry: ["src/index.ts", "src/react/index.ts", "src/element/index.ts"], + entry: ["src/index.ts", "src/react/index.ts", "src/element/index.ts", "src/server/index.ts"], unbundle: true, copy: "src/css", dts: { From aa694f72cbc0a5a89cb6235dfa46a14415eb92ec Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:48:42 -0400 Subject: [PATCH 3/7] docs: document the streaming wire protocol Co-Authored-By: Claude Fable 5 --- .changeset/streaming-server.md | 5 + README.md | 1 + apps/docs/content/docs/api/bones-boundary.mdx | 4 + apps/docs/content/docs/meta.json | 1 + apps/docs/content/docs/streaming.mdx | 127 ++++++++++++++++++ packages/bones/README.md | 1 + 6 files changed, 139 insertions(+) create mode 100644 .changeset/streaming-server.md create mode 100644 apps/docs/content/docs/streaming.mdx diff --git a/.changeset/streaming-server.md b/.changeset/streaming-server.md new file mode 100644 index 0000000..9e3910b --- /dev/null +++ b/.changeset/streaming-server.md @@ -0,0 +1,5 @@ +--- +"@camp.dev/bones": minor +--- + +Add `@camp.dev/bones/server`: `streamBones` streams an HTML shell with busy `` regions and flushes out-of-order `