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/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..61b4381 --- /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 — the wire protocol in the raw. +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/test/app.test.ts b/apps/stream/test/app.test.ts new file mode 100644 index 0000000..43fbd40 --- /dev/null +++ b/apps/stream/test/app.test.ts @@ -0,0 +1,48 @@ +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(`