From 2b9acb8c7ea9177aa2c9f93ffb8bb2908fe7e87a Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 13:56:53 -0400 Subject: [PATCH 1/4] feat: add the streaming demo app Co-Authored-By: Claude Fable 5 --- apps/stream/package.json | 21 ++++++++++++ apps/stream/src/app.ts | 49 ++++++++++++++++++++++++++++ apps/stream/src/page.ts | 65 ++++++++++++++++++++++++++++++++++++++ apps/stream/src/server.ts | 6 ++++ apps/stream/tsconfig.json | 15 +++++++++ apps/stream/vite.config.ts | 8 +++++ package.json | 1 + pnpm-lock.yaml | 35 ++++++++++++++++++++ 8 files changed, 200 insertions(+) create mode 100644 apps/stream/package.json create mode 100644 apps/stream/src/app.ts create mode 100644 apps/stream/src/page.ts create mode 100644 apps/stream/src/server.ts create mode 100644 apps/stream/tsconfig.json create mode 100644 apps/stream/vite.config.ts diff --git a/apps/stream/package.json b/apps/stream/package.json new file mode 100644 index 0000000..44b0f9d --- /dev/null +++ b/apps/stream/package.json @@ -0,0 +1,21 @@ +{ + "name": "bones-stream", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vp run --filter @camp.dev/bones build && node --watch src/server.ts", + "start": "node src/server.ts", + "check": "tsc --noEmit", + "test": "vp test" + }, + "dependencies": { + "@camp.dev/bones": "workspace:*", + "@hono/node-server": "^1.19.0", + "hono": "^4.9.0" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.8.3" + } +} diff --git a/apps/stream/src/app.ts b/apps/stream/src/app.ts new file mode 100644 index 0000000..51fbe9b --- /dev/null +++ b/apps/stream/src/app.ts @@ -0,0 +1,49 @@ +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { streamBones } from "@camp.dev/bones/server"; +import { Hono } from "hono"; +import { load, REGIONS, shell } from "./page.ts"; + +const require = createRequire(import.meta.url); +const bonesRoot = path.dirname(require.resolve("@camp.dev/bones/package.json")); + +const TYPES: Record = { + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", +}; + +export const app = new Hono(); + +app.get("/", (c) => { + const rawSpeed = Number(c.req.query("speed") ?? "1"); + const speed = Number.isFinite(rawSpeed) && rawSpeed >= 0 ? rawSpeed : 1; + const fail = c.req.query("fail") ?? null; + const slots = Object.fromEntries(REGIONS.map((region) => [region, load(region, speed, fail)])); + const stream = streamBones(shell(), slots, { + onError: (id, error) => `

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

`, + }); + return new Response(stream, { + headers: { + "content-type": "text/html; charset=utf-8", + // nginx-style proxies buffer streamed responses unless told not to. + "x-accel-buffering": "no", + }, + }); +}); + +// Serves the workspace package's built element module and its stylesheets, +// so the demo always runs the current build. Only these two subtrees exist. +app.get("/assets/*", async (c) => { + const rel = c.req.path.slice("/assets/".length); + const allowed = + (rel.startsWith("dist/element/") || rel.startsWith("src/css/")) && !rel.includes(".."); + const type = TYPES[path.extname(rel)]; + if (!allowed || type === undefined) return c.notFound(); + try { + const body = await readFile(path.join(bonesRoot, rel)); + return c.body(new Uint8Array(body), 200, { "content-type": type }); + } catch { + return c.notFound(); + } +}); diff --git a/apps/stream/src/page.ts b/apps/stream/src/page.ts new file mode 100644 index 0000000..e729ec7 --- /dev/null +++ b/apps/stream/src/page.ts @@ -0,0 +1,65 @@ +import { renderBoundary } from "@camp.dev/bones/server"; + +export const REGIONS = ["profile", "stats", "feed"] as const; +export type Region = (typeof REGIONS)[number]; + +// Milliseconds at speed=1. The feed is first in the DOM and last to arrive, +// so the out-of-order flush is visible on every load. +const LATENCY: Record = { profile: 500, stats: 1500, feed: 3000 }; + +const CONTENT: Record = { + profile: + "

Ada Lovelace

Writes notes on the Analytical Engine. First programmer, occasional gambler.

", + stats: + "

This week

12 deploys, 3 rollbacks, 41 commits.

Busiest day: Thursday. Quietest: Sunday.

", + feed: "

Activity

  • Deployed bones to production.
  • Merged the streaming kit after review.
  • Opened an issue about dark-mode contrast.
", +}; + +const FALLBACKS: Record = { + profile: "

A name loads here

And a line or two about the person.

", + stats: + "

Weekly stats

Deploy counts and commit totals.

Busiest and quietest days.

", + feed: "

Activity

  • Three recent events
  • land in this list
  • when the feed resolves.
", +}; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function load(region: Region, speed: number, fail: string | null): Promise { + await sleep(LATENCY[region] * speed); + if (region === fail) throw new Error(`the ${region} endpoint failed`); + return CONTENT[region]; +} + +// No : the closing tags are optional in HTML, chunks parse +// inside the still-open body, and the end of the stream ends the document. +export function shell(): string { + return ` + + + + +Bones streaming demo + + + + + +

Bones streaming demo

+

Three regions stream in out of DOM order: the feed is first on the page and last to arrive. +Reload with ?speed=0 (a fast server — no skeleton ever flashes), +?speed=3 (a slow one), or ?fail=stats (an error chunk).

+${renderBoundary("feed", FALLBACKS.feed)} +${renderBoundary("profile", FALLBACKS.profile)} +${renderBoundary("stats", FALLBACKS.stats, 'precision="measured"')} +
View source: the shell, one bootstrap script, then one template-plus-script chunk per region, +in arrival order. Or watch the bytes arrive: curl --no-buffer localhost:3000.
+`; +} diff --git a/apps/stream/src/server.ts b/apps/stream/src/server.ts new file mode 100644 index 0000000..c389a8b --- /dev/null +++ b/apps/stream/src/server.ts @@ -0,0 +1,6 @@ +import { serve } from "@hono/node-server"; +import { app } from "./app.ts"; + +serve({ fetch: app.fetch, port: 3000 }, (info) => { + console.log(`bones streaming demo → http://localhost:${info.port}`); +}); diff --git a/apps/stream/tsconfig.json b/apps/stream/tsconfig.json new file mode 100644 index 0000000..4f6a3d9 --- /dev/null +++ b/apps/stream/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023"], + "types": ["node"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true + }, + "include": ["src", "test"] +} diff --git a/apps/stream/vite.config.ts b/apps/stream/vite.config.ts new file mode 100644 index 0000000..7d208ae --- /dev/null +++ b/apps/stream/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite-plus"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); diff --git a/package.json b/package.json index 1bd3a7d..a254f00 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "dev": "vp run --filter @camp.dev/bones dev", "build": "vp run --filter @camp.dev/bones build", "demo": "vp run --filter bones-demo dev", + "stream": "vp run --filter bones-stream dev", "test": "vp run --filter @camp.dev/bones test", "health": "vp run --filter @camp.dev/bones health", "docs": "vp run --filter bones-docs dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 675eef6..55ea8f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,25 @@ importers: specifier: ^5.8.3 version: 5.9.3 + apps/stream: + dependencies: + '@camp.dev/bones': + specifier: workspace:* + version: link:../../packages/bones + '@hono/node-server': + specifier: ^1.19.0 + version: 1.19.17(hono@4.13.4) + hono: + specifier: ^4.9.0 + version: 4.13.4 + devDependencies: + '@types/node': + specifier: ^25.5.0 + version: 25.6.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/bones: dependencies: react-dom: @@ -502,6 +521,12 @@ packages: tailwindcss: optional: true + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -2488,6 +2513,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.13.4: + resolution: {integrity: sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==} + engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -4027,6 +4056,10 @@ snapshots: '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 + '@hono/node-server@1.19.17(hono@4.13.4)': + dependencies: + hono: 4.13.4 + '@img/colour@1.1.0': optional: true @@ -5734,6 +5767,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hono@4.13.4: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0) From 98a6a5561a728b022595d1b4f787899147fd1df9 Mon Sep 17 00:00:00 2001 From: Hunter Garrett Date: Mon, 24 Aug 2026 14:02:05 -0400 Subject: [PATCH 2/4] test: cover the streaming demo and run it in CI Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++ apps/stream/test/app.test.ts | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 apps/stream/test/app.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 44f6835..5e3673f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,6 +139,43 @@ jobs: - name: Run tests run: vp run --filter bones-demo test -- --run + # ── Streaming demo ───────────────────────────────────────── + stream-check: + name: "Stream: Types" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + cache: true + + - name: Build bones library + run: vp run --filter @camp.dev/bones build + + - name: Run type check + run: vp run --filter bones-stream check + + stream-test: + name: "Stream: Test" + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + cache: true + + - name: Build bones library + run: vp run --filter @camp.dev/bones build + + - name: Run tests + run: vp run --filter bones-stream test -- --run + # ── Docs site ────────────────────────────────────────────── docs-check: name: "Docs: Types" diff --git a/apps/stream/test/app.test.ts b/apps/stream/test/app.test.ts new file mode 100644 index 0000000..5f32c20 --- /dev/null +++ b/apps/stream/test/app.test.ts @@ -0,0 +1,45 @@ +import { expect, test } from "vite-plus/test"; +import { app } from "../src/app.ts"; + +// app.request() collects the full streamed body; order within it still +// proves the wire shape: shell, one bootstrap, then chunks. + +test("streams the shell, one bootstrap, then all three chunks", async () => { + const res = await app.request("/?speed=0"); + expect(res.headers.get("content-type")).toContain("text/html"); + const body = await res.text(); + expect(body.match(/function __bonesSwap/g)).toHaveLength(1); + const bootstrapAt = body.indexOf("function __bonesSwap"); + for (const region of ["profile", "stats", "feed"]) { + expect(body).toContain(`data-bones-slot="${region}"`); + const chunkAt = body.indexOf(`