Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions apps/stream/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
49 changes: 49 additions & 0 deletions apps/stream/src/app.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
".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) => `<p class="error">${id} failed: ${(error as Error).message}</p>`,
});
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();
}
});
65 changes: 65 additions & 0 deletions apps/stream/src/page.ts
Original file line number Diff line number Diff line change
@@ -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<Region, number> = { profile: 500, stats: 1500, feed: 3000 };

const CONTENT: Record<Region, string> = {
profile:
"<h2>Ada Lovelace</h2><p>Writes notes on the Analytical Engine. First programmer, occasional gambler.</p>",
stats:
"<h2>This week</h2><p>12 deploys, 3 rollbacks, 41 commits.</p><p>Busiest day: Thursday. Quietest: Sunday.</p>",
feed: "<h2>Activity</h2><ul><li>Deployed bones to production.</li><li>Merged the streaming kit after review.</li><li>Opened an issue about dark-mode contrast.</li></ul>",
};

const FALLBACKS: Record<Region, string> = {
profile: "<h2>A name loads here</h2><p>And a line or two about the person.</p>",
stats:
"<h2>Weekly stats</h2><p>Deploy counts and commit totals.</p><p>Busiest and quietest days.</p>",
feed: "<h2>Activity</h2><ul><li>Three recent events</li><li>land in this list</li><li>when the feed resolves.</li></ul>",
};

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export async function load(region: Region, speed: number, fail: string | null): Promise<string> {
await sleep(LATENCY[region] * speed);
if (region === fail) throw new Error(`the ${region} endpoint failed`);
return CONTENT[region];
}

// No </body></html>: 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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Bones streaming demo</title>
<link rel="stylesheet" href="/assets/src/css/auto.css" />
<script type="module" async src="/assets/dist/element/index.mjs"></script>
<style>
body { font: 16px/1.5 system-ui, sans-serif; margin: 2rem auto; max-width: 40rem; padding: 0 1rem; }
bones-boundary { display: block; margin: 1.5rem 0; }
bones-boundary[data-bones-error] { outline: 2px solid #c0392b; border-radius: 4px; }
.error { color: #c0392b; }
footer { margin-top: 3rem; font-size: 0.875rem; color: #666; }
</style>
</head>
<body>
<h1>Bones streaming demo</h1>
<p>Three regions stream in out of DOM order: the feed is first on the page and last to arrive.
Reload with <a href="/?speed=0">?speed=0</a> (a fast server — no skeleton ever flashes),
<a href="/?speed=3">?speed=3</a> (a slow one), or <a href="/?fail=stats">?fail=stats</a> (an error chunk).</p>
${renderBoundary("feed", FALLBACKS.feed)}
${renderBoundary("profile", FALLBACKS.profile)}
${renderBoundary("stats", FALLBACKS.stats, 'precision="measured"')}
<footer>View source: the shell, one bootstrap script, then one template-plus-script chunk per region, in arrival order — the <a href="https://github.com/campdotdev/bones/blob/main/apps/docs/content/docs/streaming.mdx">wire protocol</a> in the raw.
Or watch the bytes arrive: <code>curl --no-buffer localhost:3000</code>.</footer>
`;
}
6 changes: 6 additions & 0 deletions apps/stream/src/server.ts
Original file line number Diff line number Diff line change
@@ -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}`);
});
48 changes: 48 additions & 0 deletions apps/stream/test/app.test.ts
Original file line number Diff line number Diff line change
@@ -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(`<template data-bones-chunk="${region}">`);
expect(chunkAt).toBeGreaterThan(bootstrapAt);
expect(body).toContain(`__bonesSwap("${region}")`);
}
expect(body).toContain('precision="measured"');
expect(body).not.toContain("</body>");
});

test("?fail=stats flushes an error chunk with rendered content", async () => {
const body = await (await app.request("/?speed=0&fail=stats")).text();
expect(body).toContain('__bonesSwap("stats",1)');
expect(body).toContain("stats failed");
expect(body).toContain('__bonesSwap("profile")');
});

test("serves the element module from the workspace build", async () => {
const res = await app.request("/assets/dist/element/index.mjs");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/javascript");
});

test("serves the stylesheets", async () => {
expect((await app.request("/assets/src/css/auto.css")).status).toBe(200);
expect((await app.request("/assets/src/css/bones.css")).status).toBe(200);
});

test("refuses paths outside the two published subtrees", async () => {
expect((await app.request("/assets/package.json")).status).toBe(404);
// WHATWG URL normalization collapses this to /assets/package.json before Hono ever sees it, so
// this 404 comes from the prefix allowlist, not the `rel.includes("..")` guard in app.ts — that
// guard is only reachable via raw HTTP requests that skip normalization (e.g. `curl --path-as-is`).
expect((await app.request("/assets/src/css/../../package.json")).status).toBe(404);
expect((await app.request("/assets/dist/index.mjs")).status).toBe(404);
});
15 changes: 15 additions & 0 deletions apps/stream/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
8 changes: 8 additions & 0 deletions apps/stream/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from "vite-plus";

export default defineConfig({
test: {
environment: "node",
include: ["test/**/*.test.ts"],
},
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 35 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading