diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 90423d22..6cf8e661 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -70,3 +70,17 @@ repos: language: system files: ^dashboard/src/ pass_filenames: false + + - id: docs-lint + name: docs lint (biome) + entry: bash -c 'cd docs && pnpm exec biome ci app/' + language: system + files: ^docs/(app/|biome\.json) + pass_filenames: false + + - id: docs-typecheck + name: docs typecheck (react-router typegen + tsc) + entry: bash -c 'cd docs && pnpm run typecheck' + language: system + files: ^docs/(app/|tsconfig\.json|package\.json) + pass_filenames: false diff --git a/docs/app/app.css b/docs/app/app.css index c066aaee..0c542660 100644 --- a/docs/app/app.css +++ b/docs/app/app.css @@ -8,5 +8,6 @@ @import "./styles/changelog.css"; @import "./styles/diagrams.css"; @import "./styles/landing.css"; +@import "./styles/demos.css"; @import "./styles/shiki.css"; @import "./styles/sdk.css"; diff --git a/docs/app/components/demos/index.ts b/docs/app/components/demos/index.ts new file mode 100644 index 00000000..99fbcffb --- /dev/null +++ b/docs/app/components/demos/index.ts @@ -0,0 +1,3 @@ +export { useRafLoop, useReducedMotion } from "./lib"; +export { DEMO_COMPONENTS, demoComponent } from "./registry"; +export type { DemoId, DemoProps } from "./types"; diff --git a/docs/app/components/demos/lib/index.ts b/docs/app/components/demos/lib/index.ts new file mode 100644 index 00000000..6e001513 --- /dev/null +++ b/docs/app/components/demos/lib/index.ts @@ -0,0 +1,2 @@ +export { useRafLoop } from "./use-raf-loop"; +export { useReducedMotion } from "./use-reduced-motion"; diff --git a/docs/app/components/demos/lib/use-raf-loop.ts b/docs/app/components/demos/lib/use-raf-loop.ts new file mode 100644 index 00000000..0b41f648 --- /dev/null +++ b/docs/app/components/demos/lib/use-raf-loop.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; + +/** + * Run `callback` once per animation frame while `active` is true, passing the + * high-resolution timestamp. The loop is fully torn down (no dangling frame) + * when `active` flips false or the component unmounts, so a closed demo modal + * never leaves a RAF running. + * + * `callback` is held in a ref, so a demo can pass a fresh closure each render + * without restarting the loop. + */ +export function useRafLoop( + callback: (now: number) => void, + active: boolean, +): void { + const cbRef = useRef(callback); + cbRef.current = callback; + + useEffect(() => { + if (!active) return; + let frame = 0; + const tick = (now: number) => { + cbRef.current(now); + frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [active]); +} diff --git a/docs/app/components/demos/lib/use-reduced-motion.ts b/docs/app/components/demos/lib/use-reduced-motion.ts new file mode 100644 index 00000000..4deee75e --- /dev/null +++ b/docs/app/components/demos/lib/use-reduced-motion.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react"; + +const QUERY = "(prefers-reduced-motion: reduce)"; + +/** + * Reactively tracks the user's reduced-motion preference. Demos use it to render + * a single static frame instead of running their animation loop. SSR-safe: + * returns `false` until mounted on the client. + */ +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + + useEffect(() => { + if (typeof window === "undefined" || !window.matchMedia) return; + const mq = window.matchMedia(QUERY); + const update = () => setReduced(mq.matches); + update(); + mq.addEventListener("change", update); + return () => mq.removeEventListener("change", update); + }, []); + + return reduced; +} diff --git a/docs/app/components/demos/mesh-demo.tsx b/docs/app/components/demos/mesh-demo.tsx new file mode 100644 index 00000000..b5f37054 --- /dev/null +++ b/docs/app/components/demos/mesh-demo.tsx @@ -0,0 +1,625 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Mesh scheduling demo — jobs carry a routing key; the brokerless mesh sends + * each to a worker pool built to run it (gpu / default / email). Click a machine + * to drop it and watch its work reroute across the pool. React port of the + * vendored demo-mesh.js (SVG + RAF token simulation). + */ + +const W = 940; +const H = 330; +const DISP = { x: 92, y: 165 }; +const EX = 430; // pool entry x +const SPEED = 560; +const NX0 = 560; +const NSTEP = 128; +const NW = 104; +const NH = 46; +const WEIGHT = { gpu: 0.24, default: 0.52, email: 0.24 }; +const MAX_TOKENS = 34; + +interface PoolDef { + key: "gpu" | "default" | "email"; + label: string; + col: string; + y: number; + n: number; + dur: number; +} +const POOL_DEFS: PoolDef[] = [ + { + key: "gpu", + label: "gpu · render", + col: "var(--cyan)", + y: 60, + n: 2, + dur: 1650, + }, + { + key: "default", + label: "default · web", + col: "var(--indigo-br)", + y: 165, + n: 3, + dur: 900, + }, + { + key: "email", + label: "email · notify", + col: "var(--amber)", + y: 270, + n: 2, + dur: 720, + }, +]; + +type Phase = "toentry" | "tonode" | "processing" | "waiting" | "done"; + +interface NodeState { + x: number; + y: number; + busy: Token | null; + off: boolean; + key: string; + idx: number; + pool: Pool; +} +interface Token { + id: number; + pool: Pool; + x: number; + y: number; + phase: Phase; + tx: number; + ty: number; + pUntil: number; + node: NodeState | null; +} +interface Pool extends PoolDef { + nodes: NodeState[]; + wait: Token[]; +} +interface Sim { + pools: Pool[]; + tokens: Token[]; + metrics: { routed: number; rerouted: number }; + emitAcc: number; + last: number; + ids: number; +} + +/** First online, idle node in a pool, or null when none is free. */ +function freeNode(p: Pool): NodeState | null { + return p.nodes.find((nd) => !nd.off && !nd.busy) ?? null; +} + +function freshSim(): Sim { + const pools: Pool[] = POOL_DEFS.map((d) => ({ ...d, nodes: [], wait: [] })); + for (const p of pools) { + for (let i = 0; i < p.n; i++) { + p.nodes.push({ + x: NX0 + i * NSTEP, + y: p.y, + busy: null, + off: false, + key: p.key, + idx: i, + pool: p, + }); + } + } + return { + pools, + tokens: [], + metrics: { routed: 0, rerouted: 0 }, + emitAcc: 0, + last: 0, + ids: 0, + }; +} + +export default function MeshDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const simRef = useRef(freshSim()); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const poolByKey = useCallback( + (k: string) => simRef.current.pools.find((p) => p.key === k) as Pool, + [], + ); + + const pickKey = useCallback((): PoolDef["key"] => { + const r = Math.random(); + if (r < WEIGHT.gpu) return "gpu"; + if (r < WEIGHT.gpu + WEIGHT.default) return "default"; + return "email"; + }, []); + + const emit = useCallback( + (n: number) => { + const sim = simRef.current; + for (let i = 0; i < n; i++) { + if (sim.tokens.length >= MAX_TOKENS) break; + const p = poolByKey(pickKey()); + sim.tokens.push({ + id: sim.ids++, + pool: p, + x: DISP.x + 22, + y: DISP.y + (Math.random() * 16 - 8), + phase: "toentry", + tx: EX, + ty: p.y, + pUntil: 0, + node: null, + }); + } + }, + [poolByKey, pickKey], + ); + + const assignPool = useCallback((p: Pool) => { + let nd = freeNode(p); + while (p.wait.length && nd) { + const t = p.wait.shift() as Token; + nd.busy = t; + t.node = nd; + t.phase = "tonode"; + t.tx = nd.x; + t.ty = nd.y; + nd = freeNode(p); + } + }, []); + + const arriveEntry = useCallback((t: Token) => { + const nd = freeNode(t.pool); + if (nd) { + nd.busy = t; + t.node = nd; + t.phase = "tonode"; + t.tx = nd.x; + t.ty = nd.y; + } else { + const idx = t.pool.wait.length; + t.phase = "waiting"; + t.pool.wait.push(t); + t.tx = 445; + t.ty = t.pool.y - 20 + idx * 13; + } + }, []); + + const step = useCallback( + (now: number, dt: number) => { + const sim = simRef.current; + if (!reduced) { + sim.emitAcc += dt; + if (sim.emitAcc > 880) { + sim.emitAcc = 0; + if (sim.tokens.length < 26) emit(1); + } + } + const move = (SPEED * dt) / 1000; + for (const p of sim.pools) { + p.wait.forEach((t, i) => { + t.tx = 445; + t.ty = p.y - 20 + i * 13; + }); + assignPool(p); + } + for (let i = sim.tokens.length - 1; i >= 0; i--) { + const t = sim.tokens[i]; + const dx = t.tx - t.x; + const dy = t.ty - t.y; + const d = Math.hypot(dx, dy); + if (d > 0.5) { + const s = Math.min(move, d); + t.x += (dx / d) * s; + t.y += (dy / d) * s; + } + const arrived = d < 2.5; + switch (t.phase) { + case "toentry": + if (arrived) arriveEntry(t); + break; + case "tonode": + if (arrived) { + t.phase = "processing"; + t.pUntil = now + t.pool.dur * (0.82 + Math.random() * 0.4); + } + break; + case "processing": + if (now >= t.pUntil) { + if (t.node) { + t.node.busy = null; + assignPool(t.pool); + } + sim.metrics.routed++; + t.phase = "done"; + t.tx = W + 30; + t.ty = t.y; + } + break; + case "done": + if (t.x > W + 20) sim.tokens.splice(i, 1); + break; + } + } + repaint(); + }, + [reduced, emit, assignPool, arriveEntry], + ); + + const toggleNode = useCallback( + (nd: NodeState) => { + nd.off = !nd.off; + if (nd.off && nd.busy) { + const t = nd.busy; + nd.busy = null; + simRef.current.metrics.rerouted++; + t.node = null; + t.phase = "waiting"; + nd.pool.wait.unshift(t); + t.tx = 445; + t.ty = nd.pool.y - 20; + } + if (!nd.off) assignPool(nd.pool); + repaint(); + }, + [assignPool], + ); + + const tick = useCallback( + (now: number) => { + const sim = simRef.current; + if (!sim.last) sim.last = now; + const dt = Math.min(now - sim.last, 60); + sim.last = now; + step(now, dt); + }, + [step], + ); + + // Seed on mount: a few in-flight tokens (live) or a static frame (reduced). + // biome-ignore lint/correctness/useExhaustiveDependencies: re-seed only when motion preference flips; emit/arriveEntry are stable. + useEffect(() => { + simRef.current = freshSim(); + if (reduced) { + emit(7); + for (const t of simRef.current.tokens) { + t.x = EX; + t.y = t.pool.y; + arriveEntry(t); + } + } else { + emit(3); + } + repaint(); + }, [reduced]); + + useRafLoop(tick, !reduced); + + const sim = simRef.current; + + // Static wires (dispatcher → entry curves + entry → node lines). + const wires = useMemo(() => { + const mx = (148 + EX) / 2; + const out: { d: string; arrow: boolean }[] = []; + for (const p of POOL_DEFS) { + out.push({ + d: `M148 165 C ${mx} 165, ${mx} ${p.y}, ${EX} ${p.y}`, + arrow: true, + }); + for (let i = 0; i < p.n; i++) { + out.push({ + d: `M420 ${p.y} H ${NX0 + i * NSTEP - NW / 2}`, + arrow: false, + }); + } + } + return out; + }, []); + + // Readout. + let online = 0; + let total = 0; + let busy = 0; + let waiting = 0; + for (const p of sim.pools) { + for (const nd of p.nodes) { + total++; + if (!nd.off) online++; + if (nd.busy) busy++; + } + waiting += p.wait.length; + } + const flight = sim.tokens.filter( + (t) => t.phase === "toentry" || t.phase === "tonode", + ).length; + + return ( +
+
+ + + worker-mesh · live + +
+ + + click a machine to drop it + + +
+
+ +
+ + + + + + + + + {wires.map((wire) => ( + + ))} + + + {/* dispatcher */} + + + + dispatcher + + + route() + + + + {/* pool chips + nodes */} + + {sim.pools.map((p) => ( + + + + + + {p.label} + + {p.nodes.map((nd) => { + const fill = nd.off + ? "var(--panel)" + : nd.busy + ? `color-mix(in oklch,${p.col} 16%,transparent)` + : "var(--panel2)"; + const stroke = nd.off + ? "var(--line2)" + : nd.busy + ? `color-mix(in oklch,${p.col} 50%,transparent)` + : "var(--line2)"; + return ( + // biome-ignore lint/a11y/useSemanticElements: SVG can't be an HTML + ))} + + + {/* tokens */} + + {sim.tokens.map((t) => ( + + ))} + + +
+ +
+ + + gpu job + + + + default job + + + + email job + + + + worker busy + + + + offline + +
+ +
+
+ nodes online + + {online} / {total} + +
+
+ routing + {flight + busy} +
+
+ waiting + 0 ? "warn" : ""}`}>{waiting} +
+
+ routed + {sim.metrics.routed} +
+
+ rerouted + 0 ? "bad" : ""}`}> + {sim.metrics.rerouted} + +
+
+
+ ); +} diff --git a/docs/app/components/demos/ratelimit-demo.tsx b/docs/app/components/demos/ratelimit-demo.tsx new file mode 100644 index 00000000..8bcbe1e0 --- /dev/null +++ b/docs/app/components/demos/ratelimit-demo.tsx @@ -0,0 +1,669 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * API rate-limiting demo — requests flow producer → queue → worker pool → + * external API. Toggle the API to 429 and watch calls fail and retry with + * exponential backoff. React port of demo-ratelimit.js (SVG + RAF pipeline). + */ + +const W = 960; +const H = 300; +const CY = 152; +const NWORK = 5; +const PROC = 1050; +const BACKOFF_BASE = 620; +const MAX_RETRY = 4; +const SPEED = 560; +const SPAWN_MS = 1000; +const QCAP = 12; +const PRO = { x: 90, y: CY }; +const QBOX = { x: 286, w: 108, y: CY }; +const API = { x: 862, y: CY }; +const SLOTS = Array.from({ length: NWORK }, (_, i) => ({ + x: 622, + y: 86 + i * 38, +})); + +type Phase = + | "toqueue" + | "queued" + | "toworker" + | "processing" + | "toapi" + | "success" + | "backoff" + | "dead"; + +interface Token { + id: number; + x: number; + y: number; + phase: Phase; + retries: number; + blocked: boolean; + tx: number; + ty: number; + worker: number | null; + pUntil: number; + bUntil: number; + dead: number; +} +interface Slot { + x: number; + y: number; + busy: Token | null; +} +interface Sim { + tokens: Token[]; + slots: Slot[]; + metrics: { retries: number; blocked: number; succeeded: number }; + spawnAcc: number; + last: number; + idc: number; +} + +function freshSim(): Sim { + return { + tokens: [], + slots: SLOTS.map((s) => ({ ...s, busy: null })), + metrics: { retries: 0, blocked: 0, succeeded: 0 }, + spawnAcc: 0, + last: 0, + idc: 0, + }; +} + +const COL = { + queued: "var(--indigo-br)", + proc: "var(--cyan)", + success: "var(--grn)", + block: "var(--red)", + backoff: "var(--amber)", +}; +function colorFor(t: Token): string { + if (t.phase === "success") return COL.success; + if (t.phase === "backoff") return COL.backoff; + if (t.blocked) return COL.block; + if (t.phase === "processing" || t.phase === "toworker" || t.phase === "toapi") + return COL.proc; + return COL.queued; +} +const queuedList = (sim: Sim) => sim.tokens.filter((t) => t.phase === "queued"); + +export default function RateLimitDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const simRef = useRef(freshSim()); + const [apiOk, setApiOk] = useState(true); + const apiOkRef = useRef(true); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const spawn = useCallback((n: number) => { + const sim = simRef.current; + for (let i = 0; i < n; i++) { + if (sim.tokens.length > 40) break; + sim.tokens.push({ + id: ++sim.idc, + x: PRO.x, + y: PRO.y + (Math.random() * 20 - 10), + phase: "toqueue", + retries: 0, + blocked: false, + tx: QBOX.x + 12, + ty: CY, + worker: null, + pUntil: 0, + bUntil: 0, + dead: 0, + }); + } + }, []); + + const assignWorkers = useCallback((sim: Sim) => { + const q = queuedList(sim); + for (const w of sim.slots) { + if (w.busy == null && q.length) { + const t = q.shift() as Token; + w.busy = t; + t.worker = sim.slots.indexOf(w); + t.phase = "toworker"; + t.tx = w.x; + t.ty = w.y; + } + } + }, []); + + const resolveAPI = useCallback((sim: Sim, t: Token, now: number) => { + const w = t.worker != null ? sim.slots[t.worker] : null; + if (apiOkRef.current) { + t.phase = "success"; + t.blocked = false; + t.tx = W; + t.ty = CY; + sim.metrics.succeeded++; + if (w) w.busy = null; + t.worker = null; + } else { + sim.metrics.blocked++; + t.retries++; + t.blocked = true; + if (w) w.busy = null; + t.worker = null; + if (t.retries > MAX_RETRY) { + t.phase = "dead"; + t.dead = now; + } else { + sim.metrics.retries++; + t.phase = "backoff"; + t.bUntil = now + BACKOFF_BASE * 2 ** (t.retries - 1); + t.tx = QBOX.x + QBOX.w / 2; + t.ty = CY + 58; + } + } + }, []); + + const step = useCallback( + (now: number, dt: number) => { + const sim = simRef.current; + if (!reduced) { + sim.spawnAcc += dt; + if (sim.spawnAcc > SPAWN_MS) { + sim.spawnAcc = 0; + if (queuedList(sim).length < 22) spawn(1); + } + } + assignWorkers(sim); + queuedList(sim).forEach((t, i) => { + t.tx = 300 + (i % 3) * 34; + t.ty = 120 + Math.floor(i / 3) * 30; + }); + const move = (SPEED * dt) / 1000; + for (let i = sim.tokens.length - 1; i >= 0; i--) { + const t = sim.tokens[i]; + const dx = t.tx - t.x; + const dy = t.ty - t.y; + const d = Math.hypot(dx, dy); + if (d > 0.5) { + const s = Math.min(move, d); + t.x += (dx / d) * s; + t.y += (dy / d) * s; + } + const arrived = d < 3; + switch (t.phase) { + case "toqueue": + if (arrived) { + t.phase = "queued"; + t.blocked = false; + } + break; + case "toworker": + if (arrived) { + t.phase = "processing"; + t.pUntil = now + PROC * (0.8 + Math.random() * 0.5); + } + break; + case "processing": + if (now >= t.pUntil) { + t.phase = "toapi"; + t.tx = API.x - 26; + t.ty = CY; + } + break; + case "toapi": + if (arrived) resolveAPI(sim, t, now); + break; + case "backoff": + if (now >= t.bUntil) { + t.phase = "toqueue"; + t.tx = QBOX.x + 12; + t.ty = CY; + } + break; + case "success": + if (t.x >= W - 1) sim.tokens.splice(i, 1); + break; + case "dead": + if (now - t.dead > 900) sim.tokens.splice(i, 1); + break; + } + } + repaint(); + }, + [reduced, spawn, assignWorkers, resolveAPI], + ); + + const tick = useCallback( + (now: number) => { + const sim = simRef.current; + if (!sim.last) sim.last = now; + const dt = Math.min(now - sim.last, 60); + sim.last = now; + step(now, dt); + }, + [step], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: re-seed only when motion preference flips; spawn/assignWorkers stable + useEffect(() => { + const sim = freshSim(); + simRef.current = sim; + if (reduced) { + // static seed: some queued, fill workers + for (let i = 0; i < 9; i++) { + sim.tokens.push({ + id: ++sim.idc, + x: 300 + (i % 3) * 34, + y: 120 + Math.floor(i / 3) * 30, + phase: "queued", + retries: 0, + blocked: false, + tx: 0, + ty: 0, + worker: null, + pUntil: 0, + bUntil: 0, + dead: 0, + }); + } + assignWorkers(sim); + for (const t of sim.tokens) { + if (t.worker != null) { + t.phase = "processing"; + t.x = sim.slots[t.worker].x; + t.y = sim.slots[t.worker].y; + } + } + } else { + spawn(4); + } + repaint(); + }, [reduced]); + + useRafLoop(tick, !reduced); + + const toggleApi = (ok: boolean) => { + apiOkRef.current = ok; + setApiOk(ok); + repaint(); + }; + + const sim = simRef.current; + const ql = queuedList(sim); + const qd = ql.length; + const inflight = sim.tokens.filter( + (t) => + t.phase === "toworker" || t.phase === "processing" || t.phase === "toapi", + ).length; + const backoff = sim.tokens.filter((t) => t.phase === "backoff").length; + let maxBo = 0; + for (const t of sim.tokens) { + if (t.phase === "backoff") { + const s = (t.bUntil - performance.now()) / 1000; + if (s > maxBo) maxBo = s; + } + } + const ovf = qd - QCAP; + const retryLane = backoff > 0 || !apiOk; + + return ( +
+
+ + + rate-limit · live + +
+ + {/* biome-ignore lint/a11y/useSemanticElements: segmented toggle of aria-pressed buttons; a fieldset/legend would fight the inline-flex .seg styling */} +
+ {( + [ + { ok: true, cls: "good", label: "API: 200 OK" }, + { ok: false, cls: "bad", label: "API: 429" }, + ] as const + ).map((opt) => ( + + ))} +
+
+
+ +
+ + + + + + + + {/* guide wires */} + {["M150 152 H286", "M394 152 H560", "M690 152 H800"].map((d) => ( + + ))} + + + retry · exponential backoff + + + {/* producer */} + + + + your code + + + .delay() + + + + {/* queue */} + + + + QUEUE + + + {ovf > 0 ? `+${ovf} more` : ""} + + + + {/* worker pool */} + + + + WORKERS · {NWORK} + + + {sim.slots.map((w, i) => { + const busy = w.busy != null; + return ( + + ); + })} + + {/* API */} + + + + external API + + + + + {apiOk ? "200" : "429"} + + + + + {/* tokens */} + + {sim.tokens.map((t) => { + let opacity = 1; + if (t.phase === "dead") opacity = 0.25; + else if (t.phase === "backoff") opacity = 0.85; + else if (t.phase === "success") + opacity = Math.max(0, 1 - (t.x - API.x) / (W - API.x)); + return ( + + ); + })} + + +
+ +
+ + + queued + + + + processing + + + + succeeded + + + + 429 · retrying + + + + backoff wait + +
+ +
+
+ queue depth + {qd} +
+
+ in flight + {inflight} +
+
+ retries + {sim.metrics.retries} +
+
+ 429 blocked + {sim.metrics.blocked} +
+
+ succeeded + {sim.metrics.succeeded} +
+
+ backoff + 0 ? "warn" : ""}`}> + {maxBo > 0 ? maxBo.toFixed(1) : "0"} + s + +
+
+
+ ); +} diff --git a/docs/app/components/demos/recovery-demo.tsx b/docs/app/components/demos/recovery-demo.tsx new file mode 100644 index 00000000..c0fcb816 --- /dev/null +++ b/docs/app/components/demos/recovery-demo.tsx @@ -0,0 +1,500 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Failure-recovery demo — a scrubbable timeline of one job's lifecycle. Drag the + * playhead across retries; toggle the ending between recovering on the last + * retry or exhausting into the DLQ. React port of demo-recovery.js (HTML + RAF). + */ + +interface StateStyle { + c: string; + soft: string; + line: string; +} +const STATES: Record = { + Queued: { + c: "var(--indigo-br)", + soft: "var(--indigo-soft)", + line: "var(--indigo-line)", + }, + Running: { + c: "var(--cyan)", + soft: "color-mix(in oklch,var(--cyan) 14%,transparent)", + line: "color-mix(in oklch,var(--cyan) 40%,transparent)", + }, + Failed: { + c: "var(--red)", + soft: "color-mix(in oklch,var(--red) 13%,transparent)", + line: "color-mix(in oklch,var(--red) 42%,transparent)", + }, + Retrying: { + c: "var(--amber)", + soft: "color-mix(in oklch,var(--amber) 14%,transparent)", + line: "color-mix(in oklch,var(--amber) 42%,transparent)", + }, + Complete: { + c: "var(--grn)", + soft: "color-mix(in oklch,var(--grn) 14%,transparent)", + line: "color-mix(in oklch,var(--grn) 42%,transparent)", + }, + Dead: { c: "var(--txt2)", soft: "var(--panel3)", line: "var(--line3)" }, +}; + +interface Event { + t: number; + state: string; + att: number; + ttl: string; + d: string; +} +const TAIL = 1400; +function base(): Event[] { + return [ + { + t: 0, + state: "Queued", + att: 0, + ttl: "Enqueued", + d: "Accepted into the queue · status pending", + }, + { + t: 800, + state: "Running", + att: 1, + ttl: "Dispatched", + d: "Worker picked up the job · attempt 1", + }, + { + t: 2600, + state: "Failed", + att: 1, + ttl: "Error", + d: "ConnectionError: upstream returned 503", + }, + { + t: 3000, + state: "Retrying", + att: 1, + ttl: "Backoff", + d: "Waiting 2.0s before retry 2", + }, + { + t: 5000, + state: "Running", + att: 2, + ttl: "Dispatched", + d: "Worker picked up the job · attempt 2", + }, + { + t: 6800, + state: "Failed", + att: 2, + ttl: "Error", + d: "ConnectionError: upstream returned 503", + }, + { + t: 7200, + state: "Retrying", + att: 2, + ttl: "Backoff", + d: "Waiting 4.0s before retry 3", + }, + { + t: 11200, + state: "Running", + att: 3, + ttl: "Dispatched", + d: "Worker picked up the job · attempt 3", + }, + ]; +} +const SCEN: Record = { + recover: base().concat([ + { + t: 12700, + state: "Complete", + att: 3, + ttl: "Success", + d: "Returned in 1.5s · result written to store", + }, + ]), + dlq: base().concat([ + { + t: 13000, + state: "Failed", + att: 3, + ttl: "Error", + d: "ConnectionError: upstream returned 503", + }, + { + t: 13300, + state: "Dead", + att: 3, + ttl: "Dead-letter", + d: "Max retries (3) exhausted → moved to DLQ", + }, + ]), +}; + +const durationOf = (events: Event[]) => events[events.length - 1].t + TAIL; + +type AttemptResult = "" | "pending" | "fail" | "ok" | "dead"; + +export default function RecoveryDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const [scen, setScen] = useState<"recover" | "dlq">("recover"); + const [playing, setPlaying] = useState(false); + const playRef = useRef(0); + const lastT = useRef(0); + const trackRef = useRef(null); + const logRef = useRef(null); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const events = SCEN[scen]; + const DUR = durationOf(events); + + const stopPlay = useCallback(() => setPlaying(false), []); + + const tick = useCallback( + (now: number) => { + if (!lastT.current) lastT.current = now; + const dt = now - lastT.current; + lastT.current = now; + playRef.current += dt; + if (playRef.current >= DUR) { + playRef.current = DUR; + setPlaying(false); + } + repaint(); + }, + [DUR], + ); + + useRafLoop(tick, playing && !reduced); + + // Reduced motion mid-playback: jump to the end and drop the stale "Pause" state. + useEffect(() => { + if (reduced && playing) { + playRef.current = DUR; + setPlaying(false); + repaint(); + } + }, [reduced, playing, DUR]); + + const startPlay = useCallback(() => { + if (reduced) { + playRef.current = DUR; + repaint(); + return; + } + if (playRef.current >= DUR) playRef.current = 0; + lastT.current = 0; + setPlaying(true); + }, [reduced, DUR]); + + const setFromX = useCallback( + (clientX: number) => { + const track = trackRef.current; + if (!track) return; + const r = track.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); + playRef.current = ratio * DUR; + stopPlay(); + repaint(); + }, + [DUR, stopPlay], + ); + + const dragging = useRef(false); + + // Keep the current log row scrolled into view. + const play = playRef.current; + let idx = 0; + for (let i = 0; i < events.length; i++) { + if (events[i].t <= play + 0.001) idx = i; + else break; + } + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll when the active event row changes + useEffect(() => { + const log = logRef.current; + if (!log) return; + const cur = log.querySelector(".fr-ev.cur"); + if (cur) { + const top = cur.offsetTop - log.clientHeight / 2 + cur.clientHeight / 2; + log.scrollTop = Math.max(0, top); + } + }, [idx, scen]); + + const ev = events[idx]; + const st = STATES[ev.state]; + const pct = (play / DUR) * 100; + + // attempt chips + const res: Record = { 1: "", 2: "", 3: "" }; + for (let i = 0; i <= idx; i++) { + const e = events[i]; + if (e.att >= 1) { + if (e.state === "Running" && !res[e.att]) res[e.att] = "pending"; + if (e.state === "Failed") res[e.att] = "fail"; + if (e.state === "Complete") res[e.att] = "ok"; + if (e.state === "Dead") res[e.att] = "dead"; + } + } + const attemptGlyph: Record = { + "": "", + pending: "", + fail: "✕", + ok: "✓", + dead: "☠", + }; + + // next-retry / result meta + let nxKey = "Next retry"; + let nxVal = "—"; + let nxColor = "var(--mut)"; + if (ev.state === "Retrying") { + const nxt = idx + 1 < events.length ? events[idx + 1].t : DUR; + nxVal = `${Math.max(0, (nxt - play) / 1000).toFixed(1)}s`; + nxColor = "var(--amber)"; + } else if (ev.state === "Complete") { + nxKey = "Result"; + nxVal = "stored"; + nxColor = "var(--grn)"; + } else if (ev.state === "Dead") { + nxKey = "Routed to"; + nxVal = "DLQ"; + nxColor = "var(--txt2)"; + } else if (ev.state === "Running") { + nxKey = "Worker"; + nxVal = "busy"; + nxColor = "var(--cyan)"; + } + + const onKeyDown = (e: React.KeyboardEvent) => { + const stepK = DUR / 40; + if (e.key === "ArrowRight") playRef.current = Math.min(DUR, play + stepK); + else if (e.key === "ArrowLeft") playRef.current = Math.max(0, play - stepK); + else if (e.key === "Home") playRef.current = 0; + else if (e.key === "End") playRef.current = DUR; + else return; + e.preventDefault(); + stopPlay(); + repaint(); + }; + + return ( +
+
+ + + job 01J8…f3 · lifecycle + +
+ + {/* biome-ignore lint/a11y/useSemanticElements: segmented toggle of aria-pressed buttons; a fieldset/legend would fight the inline-flex .seg styling */} +
+ {( + [ + { v: "recover", cls: "good", label: "Recovers" }, + { v: "dlq", cls: "bad", label: "Dead-letter" }, + ] as const + ).map((opt) => { + const on = scen === opt.v; + return ( + + ); + })} +
+
+
+ +
+
{ + dragging.current = true; + e.currentTarget.setPointerCapture(e.pointerId); + setFromX(e.clientX); + }} + onPointerMove={(e) => { + if (dragging.current) setFromX(e.clientX); + }} + onPointerUp={() => { + dragging.current = false; + }} + onPointerCancel={() => { + dragging.current = false; + }} + > +
+ {events.map((e, i) => { + const a = e.t; + const b = i + 1 < events.length ? events[i + 1].t : DUR; + return ( +
+ ); + })} +
+
+ {events.map((e, i) => + i > 0 ? ( +
+ ) : null, + )} +
+
+
+ 0.0s + {(DUR / 2000).toFixed(1)}s + {(DUR / 1000).toFixed(1)}s +
+
+ +
+
+ + + {ev.state} + +
+ {ev.ttl}. {ev.d} +
+
+
+ Elapsed + {(play / 1000).toFixed(1)}s +
+
+ Attempt + {ev.att} / 3 +
+
+ {nxKey} + + {nxVal} + +
+
+
+
Attempts
+
+ {[1, 2, 3].map((a) => { + const r = res[a]; + const cls = + r === "ok" + ? "ok" + : r === "fail" + ? "fail" + : r === "dead" + ? "dead" + : ""; + return ( + +
+ {attemptGlyph[r] || a} +
+ {a < 3 ? : null} +
+ ); + })} +
+
+
+
+ {events.map((e, i) => ( +
+
+ + +
+
+ {e.ttl} + {e.d} +
+ {(e.t / 1000).toFixed(1)}s +
+ ))} +
+
+
+ ); +} diff --git a/docs/app/components/demos/registry.ts b/docs/app/components/demos/registry.ts new file mode 100644 index 00000000..0a9e4536 --- /dev/null +++ b/docs/app/components/demos/registry.ts @@ -0,0 +1,27 @@ +import { type ComponentType, type LazyExoticComponent, lazy } from "react"; +import type { DemoId, DemoProps } from "./types"; + +type LazyDemo = LazyExoticComponent>; + +/** + * React ports of the interactive demos, keyed by demo id and lazy-loaded so the + * homepage bundle stays small. {@link DemoModal} renders the matching component + * inside its stage; the type stays partial so an unknown id degrades gracefully. + */ +export const DEMO_COMPONENTS: Record = { + mesh: lazy(() => import("./mesh-demo")), + ratelimit: lazy(() => import("./ratelimit-demo")), + recovery: lazy(() => import("./recovery-demo")), + scaling: lazy(() => import("./scaling-demo")), + saga: lazy(() => import("./saga-demo")), + workflow: lazy(() => import("./workflow-demo")), + worksteal: lazy(() => import("./worksteal-demo")), +}; + +/** The React port for `id`, or `undefined` for an unknown demo id. */ +export function demoComponent(id: string): LazyDemo | undefined { + // Own-key check so prototype names (e.g. "__proto__") never resolve. + return Object.hasOwn(DEMO_COMPONENTS, id) + ? DEMO_COMPONENTS[id as DemoId] + : undefined; +} diff --git a/docs/app/components/demos/saga-demo.tsx b/docs/app/components/demos/saga-demo.tsx new file mode 100644 index 00000000..04c9666f --- /dev/null +++ b/docs/app/components/demos/saga-demo.tsx @@ -0,0 +1,562 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Saga / compensation demo — a distributed transaction as a chain of steps, + * each with a compensating action. Pick where it fails and run it: forward + * steps commit, then on failure the compensations unwind in reverse so nothing + * is left half-done. React port of the vendored demo-saga.js (SVG + RAF). + */ + +const W = 940; +const H = 292; +const FDUR = 720; // forward step duration (ms) +const CDUR = 620; // compensation step duration (ms) +const CYC = 104; +const CW = 178; +const CH = 88; +const CX = [143, 361, 579, 797]; + +interface Step { + fwd: string; + fn: string; + comp: string; +} +const STEPS: Step[] = [ + { fwd: "reserve seat", fn: "reserve_seat.s()", comp: "release seat" }, + { fwd: "charge card", fn: "charge_card.s()", comp: "refund card" }, + { fwd: "book hotel", fn: "book_hotel.s()", comp: "cancel hotel" }, + { fwd: "issue ticket", fn: "issue_ticket.s()", comp: "void ticket" }, +]; +const N = STEPS.length; + +type Status = "pending" | "running" | "done" | "failed" | "comp" | "undone"; +type Phase = "idle" | "forward" | "compensate" | "done"; +type Outcome = "" | "committed" | "rolled back"; + +interface ColorSet { + fill: string; + stroke: string; + pip: string; + t: string; +} +const COL: Record = { + pending: { + fill: "var(--panel2)", + stroke: "var(--line2)", + pip: "var(--dim)", + t: "var(--txt2)", + }, + running: { + fill: "color-mix(in oklch,var(--cyan) 15%,transparent)", + stroke: "color-mix(in oklch,var(--cyan) 52%,transparent)", + pip: "var(--cyan)", + t: "var(--txt)", + }, + done: { + fill: "color-mix(in oklch,var(--grn) 13%,transparent)", + stroke: "color-mix(in oklch,var(--grn) 48%,transparent)", + pip: "var(--grn)", + t: "var(--txt)", + }, + failed: { + fill: "color-mix(in oklch,var(--red) 14%,transparent)", + stroke: "color-mix(in oklch,var(--red) 52%,transparent)", + pip: "var(--red)", + t: "var(--txt)", + }, + comp: { + fill: "color-mix(in oklch,var(--amber) 15%,transparent)", + stroke: "color-mix(in oklch,var(--amber) 52%,transparent)", + pip: "var(--amber)", + t: "var(--txt)", + }, + undone: { + fill: "var(--panel)", + stroke: "var(--line2)", + pip: "var(--dim)", + t: "var(--dim)", + }, +}; + +interface Sim { + st: Status[]; + phase: Phase; + idx: number; + stepStart: number; + compQueue: number[]; + outcome: Outcome; + dotProg: number; + /** Forward edge i committed (green). */ + fCommitted: boolean[]; + /** Reverse edge i opacity during compensation. */ + rOpacity: number[]; +} + +function freshSim(): Sim { + return { + st: STEPS.map(() => "pending"), + phase: "idle", + idx: 0, + stepStart: 0, + compQueue: [], + outcome: "", + dotProg: 0, + fCommitted: Array(N - 1).fill(false), + rOpacity: Array(N - 1).fill(0), + }; +} + +/** Final committed/rolled-back frame, without animating (reduced motion). */ +function instantSim(failAt: number): Sim { + const sim = freshSim(); + sim.phase = "done"; + if (failAt < 0) { + sim.st = STEPS.map(() => "done"); + sim.outcome = "committed"; + sim.fCommitted = Array(N - 1).fill(true); + } else { + for (let i = 0; i < failAt; i++) sim.st[i] = "undone"; + sim.st[failAt] = "failed"; + sim.outcome = "rolled back"; + } + return sim; +} + +export default function SagaDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const simRef = useRef(freshSim()); + const [failAt, setFailAt] = useState(1); + const [running, setRunning] = useState(false); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const finish = useCallback((outcome: Outcome) => { + simRef.current.phase = "done"; + simRef.current.outcome = outcome; + setRunning(false); + }, []); + + // One animation frame of the saga state machine. + const tick = useCallback( + (now: number) => { + const sim = simRef.current; + if (sim.stepStart === 0) sim.stepStart = now; // lazy-init on first frame + + if (sim.phase === "forward") { + const prog = (now - sim.stepStart) / FDUR; + sim.dotProg = Math.min(1, prog); + if (prog >= 1) { + if (sim.idx === failAt) { + sim.st[sim.idx] = "failed"; + sim.compQueue = []; + for (let j = sim.idx - 1; j >= 0; j--) { + if (sim.st[j] === "done") sim.compQueue.push(j); + } + if (sim.compQueue.length) { + sim.phase = "compensate"; + const ci = sim.compQueue[0]; + sim.st[ci] = "comp"; + sim.stepStart = now; + if (ci < N - 1) sim.rOpacity[ci] = 0.9; + } else { + finish("rolled back"); + } + } else { + sim.st[sim.idx] = "done"; + if (sim.idx < N - 1) { + sim.fCommitted[sim.idx] = true; + sim.idx += 1; + sim.st[sim.idx] = "running"; + sim.stepStart = now; + sim.dotProg = 0; + } else { + finish("committed"); + } + } + } + } else if (sim.phase === "compensate") { + const ci = sim.compQueue[0]; + const cp = (now - sim.stepStart) / CDUR; + if (cp >= 1) { + sim.st[ci] = "undone"; + sim.compQueue.shift(); + if (ci < N - 1) sim.rOpacity[ci] = 0; + if (ci > 0) sim.rOpacity[ci - 1] = 0.9; + if (sim.compQueue.length) { + sim.stepStart = now; + sim.st[sim.compQueue[0]] = "comp"; + } else { + finish("rolled back"); + } + } + } + repaint(); + }, + [failAt, finish], + ); + + const run = useCallback(() => { + const sim = freshSim(); + sim.phase = "forward"; + sim.st[0] = "running"; + simRef.current = sim; + setRunning(true); + }, []); + + // Auto-run when the demo mounts; render a static final frame if reduced motion. + // Re-runs when the fail point changes or the motion preference flips. + // biome-ignore lint/correctness/useExhaustiveDependencies: run/tick are stable callbacks; we intentionally re-seed only on failAt/reduced change. + useEffect(() => { + if (reduced) { + simRef.current = instantSim(failAt); + setRunning(false); + repaint(); + } else { + run(); + } + }, [failAt, reduced]); + + useRafLoop(tick, running && !reduced); + + const sim = simRef.current; + const committed = sim.st.filter((s) => s === "done").length; + const compensated = sim.st.filter((s) => s === "undone").length; + const stepLabel = + sim.phase === "idle" + ? "—" + : sim.phase === "forward" + ? `${sim.idx + 1} / ${N}` + : sim.phase === "compensate" + ? "unwind" + : "done"; + const dotVisible = + sim.phase === "forward" && sim.st[sim.idx] === "running" && sim.idx < N - 1; + const dotX = dotVisible + ? CX[sim.idx] + + CW / 2 + + (CX[sim.idx + 1] - CW / 2 - (CX[sim.idx] + CW / 2)) * sim.dotProg + : 0; + + return ( +
+
+ + + booking-saga · transaction + +
+ {/* biome-ignore lint/a11y/useSemanticElements: segmented toggle of aria-pressed buttons; a fieldset/legend would fight the inline-flex .seg styling */} +
+ {[ + { v: 1, cls: "bad", label: "charge fails" }, + { v: 2, cls: "bad", label: "hotel fails" }, + { v: -1, cls: "good", label: "all succeed" }, + ].map((opt) => { + const on = failAt === opt.v; + return ( + + ); + })} +
+ +
+
+ +
+ + + + + + + + + + + {/* edges */} + + {STEPS.slice(0, N - 1).map((_, i) => { + const x1 = CX[i] + CW / 2; + const x2 = CX[i + 1] - CW / 2; + return ( + + ); + })} + {STEPS.slice(0, N - 1).map((_, i) => { + const x1 = CX[i] + CW / 2; + const x2 = CX[i + 1] - CW / 2; + return ( + + ); + })} + + + {/* cards */} + + {STEPS.map((s, i) => { + const status = sim.st[i]; + const k = COL[status]; + const x = CX[i] - CW / 2; + const y = CYC - CH / 2; + const compActive = status === "comp" || status === "undone"; + return ( + + + + STEP {i + 1} + + + + {s.fwd} + + + {s.fn} + + + + + {s.comp} + + + ); + })} + + + {/* forward token */} + + + + +
+ +
+ + + running + + + + committed + + + + failed + + + + compensating + + + + undone + +
+ +
+
+ step + + {stepLabel} + +
+
+ committed + + {committed} + +
+
+ compensated + + {compensated} + +
+
+ outcome + + {sim.outcome || "—"} + +
+
+
+ ); +} diff --git a/docs/app/components/demos/scaling-demo.tsx b/docs/app/components/demos/scaling-demo.tsx new file mode 100644 index 00000000..63305f10 --- /dev/null +++ b/docs/app/components/demos/scaling-demo.tsx @@ -0,0 +1,463 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Worker-scaling playground — sliders drive a stochastic M/M/c queue simulation; + * throughput, queue-depth and latency charts respond live on Canvas 2D. React + * port of demo-playground.js (Canvas + RAF). + */ + +interface Params { + workers: number; + conc: number; + lambda: number; + jobMs: number; + rate: number; +} +const DEFAULTS: Params = { + workers: 4, + conc: 2, + lambda: 18, + jobMs: 300, + rate: 0, +}; + +interface Field { + k: keyof Params; + n: string; + sub: string; + min: number; + max: number; + step: number; + fmt: (v: number) => string; +} +const FIELDS: Field[] = [ + { + k: "workers", + n: "Workers", + sub: "pool size", + min: 1, + max: 12, + step: 1, + fmt: (v) => `${v}`, + }, + { + k: "conc", + n: "Concurrency", + sub: "per worker", + min: 1, + max: 8, + step: 1, + fmt: (v) => `×${v}`, + }, + { + k: "lambda", + n: "Arrival rate", + sub: "jobs / s", + min: 1, + max: 80, + step: 1, + fmt: (v) => `${v}/s`, + }, + { + k: "jobMs", + n: "Avg job time", + sub: "milliseconds", + min: 50, + max: 1200, + step: 25, + fmt: (v) => `${v}ms`, + }, + { + k: "rate", + n: "Rate limit", + sub: "0 = unlimited", + min: 0, + max: 60, + step: 2, + fmt: (v) => (v === 0 ? "off" : `${v}/s`), + }, +]; + +const BUF = 170; + +interface Sim { + N: number; + tokens: number; + thrEMA: number; + latEMA: number; + hThr: number[]; + hQ: number[]; + hLat: number[]; +} +function freshSim(rate: number): Sim { + return { + N: 0, + tokens: rate, + thrEMA: 0, + latEMA: 0, + hThr: new Array(BUF).fill(0), + hQ: new Array(BUF).fill(0), + hLat: new Array(BUF).fill(0), + }; +} + +function gauss(): number { + const u = Math.random(); + const v = Math.random(); + return Math.sqrt(-2 * Math.log(u || 1e-9)) * Math.cos(6.283185 * v); +} +function poisson(mean: number): number { + if (mean <= 0) return 0; + if (mean > 30) + return Math.max(0, Math.round(mean + Math.sqrt(mean) * gauss())); + const L = Math.exp(-mean); + let k = 0; + let p = 1; + do { + k++; + p *= Math.random(); + } while (p > L); + return k - 1; +} + +function resolveColor(varExpr: string): string { + const s = document.createElement("span"); + s.style.cssText = "position:absolute;opacity:0;pointer-events:none"; + s.style.color = varExpr; + document.body.appendChild(s); + const c = getComputedStyle(s).color; + s.remove(); + return c; +} +function withAlpha(rgb: string, a: number): string { + const m = rgb.match(/\d+/g); + if (!m) return rgb; + return `rgba(${m[0]},${m[1]},${m[2]},${a})`; +} + +interface CanvasBox { + el: HTMLCanvasElement; + w: number; + h: number; +} +const boxOf = (el: HTMLCanvasElement | null): CanvasBox | null => + el ? { el, w: el.clientWidth, h: el.clientHeight || 84 } : null; +function sizeCanvas(box: CanvasBox) { + const dpr = Math.min(2, window.devicePixelRatio || 1); + const w = box.el.clientWidth; + const h = box.el.clientHeight || 84; + box.w = w; + box.h = h; + box.el.width = w * dpr; + box.el.height = h * dpr; + box.el.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0); +} +function drawChart( + box: CanvasBox, + data: number[], + color: string, + grid: string, + yMaxFloor: number, +) { + const ctx = box.el.getContext("2d"); + if (!ctx) return; + const { w, h } = box; + ctx.clearRect(0, 0, w, h); + const pad = 6; + const gh = h - pad * 2; + let max = yMaxFloor; + for (const d of data) if (d > max) max = d; + max = max * 1.18 + 0.001; + ctx.strokeStyle = grid; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h - pad); + ctx.lineTo(w, h - pad); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, pad); + ctx.lineTo(w, pad); + ctx.globalAlpha = 0.5; + ctx.stroke(); + ctx.globalAlpha = 1; + const n = data.length; + const dx = w / (n - 1); + const X = (i: number) => i * dx; + const Y = (v: number) => pad + gh - (Math.min(v, max) / max) * gh; + ctx.beginPath(); + ctx.moveTo(0, h - pad); + for (let i = 0; i < n; i++) ctx.lineTo(X(i), Y(data[i])); + ctx.lineTo(w, h - pad); + ctx.closePath(); + const grad = ctx.createLinearGradient(0, pad, 0, h); + grad.addColorStop(0, withAlpha(color, 0.3)); + grad.addColorStop(1, withAlpha(color, 0.02)); + ctx.fillStyle = grad; + ctx.fill(); + ctx.beginPath(); + for (let i = 0; i < n; i++) { + const x = X(i); + const y = Y(data[i]); + if (i) ctx.lineTo(x, y); + else ctx.moveTo(x, y); + } + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.lineJoin = "round"; + ctx.stroke(); + const hx = X(n - 1); + const hy = Y(data[n - 1]); + ctx.beginPath(); + ctx.arc(hx, hy, 3, 0, 6.2832); + ctx.fillStyle = color; + ctx.fill(); + ctx.beginPath(); + ctx.arc(hx, hy, 5.5, 0, 6.2832); + ctx.strokeStyle = withAlpha(color, 0.4); + ctx.lineWidth = 1.5; + ctx.stroke(); +} + +export default function ScalingDemo({ theme }: DemoProps) { + const reduced = useReducedMotion(); + const [params, setParams] = useState(DEFAULTS); + const paramsRef = useRef(DEFAULTS); + paramsRef.current = params; + const simRef = useRef(freshSim(DEFAULTS.rate)); + const thrRef = useRef(null); + const qRef = useRef(null); + const latRef = useRef(null); + const colorsRef = useRef({ thr: "", q: "", lat: "", grid: "" }); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const stepSim = useCallback((dt: number) => { + const P = paramsRef.current; + const sim = simRef.current; + const C = P.workers * P.conc; + sim.N += poisson(P.lambda * dt); + const inService = Math.min(sim.N, C); + const rl = P.rate > 0; + if (rl) sim.tokens = Math.min(P.rate, sim.tokens + P.rate * dt); + const svcMean = (inService * dt) / (P.jobMs / 1000); + let done = Math.min(poisson(svcMean), sim.N); + if (rl) { + done = Math.min(done, Math.floor(sim.tokens)); + sim.tokens -= done; + } + sim.N -= done; + const inst = dt > 0 ? done / dt : 0; + sim.thrEMA += (inst - sim.thrEMA) * Math.min(1, dt * 3.2); + const q = Math.max(0, sim.N - C); + const lat = + sim.thrEMA > 0.2 ? (sim.N / sim.thrEMA) * 1000 : sim.N > 0 ? 9000 : 0; + sim.latEMA += (lat - sim.latEMA) * Math.min(1, dt * 2.2); + sim.hThr.push(sim.thrEMA); + sim.hThr.shift(); + sim.hQ.push(q); + sim.hQ.shift(); + sim.hLat.push(sim.latEMA); + sim.hLat.shift(); + }, []); + + const drawAll = useCallback(() => { + const c = colorsRef.current; + const sim = simRef.current; + const thr = boxOf(thrRef.current); + const q = boxOf(qRef.current); + const lat = boxOf(latRef.current); + if (thr) drawChart(thr, sim.hThr, c.thr, c.grid, 8); + if (q) drawChart(q, sim.hQ, c.q, c.grid, 6); + if (lat) drawChart(lat, sim.hLat, c.lat, c.grid, 400); + repaint(); + }, []); + + const refreshColors = useCallback(() => { + colorsRef.current = { + thr: resolveColor("var(--indigo-br)"), + q: resolveColor("var(--amber)"), + lat: resolveColor("var(--cyan)"), + grid: resolveColor("var(--line)"), + }; + }, []); + + const sizeAll = useCallback(() => { + for (const el of [thrRef.current, qRef.current, latRef.current]) { + const box = boxOf(el); + if (box) sizeCanvas(box); + } + }, []); + + const loop = useCallback(() => { + const sub = 2; + for (let i = 0; i < sub; i++) stepSim((0.05 / sub) * 1.6); + drawAll(); + }, [stepSim, drawAll]); + + const staticEval = useCallback(() => { + simRef.current = freshSim(paramsRef.current.rate); + for (let i = 0; i < 260; i++) stepSim(0.1); + refreshColors(); + sizeAll(); + drawAll(); + }, [stepSim, refreshColors, sizeAll, drawAll]); + + // Mount + motion-preference changes: size canvases, seed, draw. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-init only on motion preference; helper callbacks are stable + useEffect(() => { + refreshColors(); + sizeAll(); + if (reduced) staticEval(); + else simRef.current = freshSim(paramsRef.current.rate); + drawAll(); + }, [reduced]); + + // Re-resolve chart colors + redraw when the host theme flips. + // biome-ignore lint/correctness/useExhaustiveDependencies: redraw on theme change + useEffect(() => { + refreshColors(); + drawAll(); + }, [theme]); + + // Keep canvases sized to their container. + useEffect(() => { + const onResize = () => { + sizeAll(); + drawAll(); + }; + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, [sizeAll, drawAll]); + + useRafLoop(loop, !reduced); + + const onSlide = (k: keyof Params, v: number) => { + setParams((prev) => ({ ...prev, [k]: v })); + if (reduced) { + // staticEval reads paramsRef which updates next render; schedule after state flush + requestAnimationFrame(staticEval); + } + }; + const reset = () => { + setParams(DEFAULTS); + paramsRef.current = DEFAULTS; + simRef.current = freshSim(DEFAULTS.rate); + if (reduced) requestAnimationFrame(staticEval); + else drawAll(); + }; + + const sim = simRef.current; + const C = params.workers * params.conc; + const svcCap = C / (params.jobMs / 1000); + const cap = params.rate > 0 ? Math.min(svcCap, params.rate) : svcCap; + let verdict = "Overloaded — queue is growing."; + let vcls = "bad"; + if (cap >= params.lambda * 1.12) { + verdict = "Keeping up — pool has headroom."; + vcls = "good"; + } else if (cap >= params.lambda * 0.97) { + verdict = "Saturated — running near capacity."; + vcls = "warn"; + } + + return ( +
+
+ + + worker-pool · simulation + +
+ +
+
+ +
+
+ {FIELDS.map((f) => ( +
+
+ + {f.n} {f.sub} + + {f.fmt(params[f.k])} +
+ onSlide(f.k, +e.target.value)} + /> +
+ ))} +
+ {C} in-flight slots · capacity ≈{cap.toFixed(0)}/s vs + demand {params.lambda}/s + {params.rate > 0 ? ` · capped at ${params.rate}/s` : ""} + {verdict} +
+
+
+
+
+ + + Throughput + + + {sim.hThr[BUF - 1].toFixed(1)} + /s + +
+ +
+
+
+ + + Queue depth + + + {Math.round(sim.hQ[BUF - 1])} + jobs + +
+ +
+
+
+ + + Latency (p50) + + + {Math.round(sim.hLat[BUF - 1])} + ms + +
+ +
+
+
+
+ ); +} diff --git a/docs/app/components/demos/types.ts b/docs/app/components/demos/types.ts new file mode 100644 index 00000000..a7ae38ad --- /dev/null +++ b/docs/app/components/demos/types.ts @@ -0,0 +1,15 @@ +/** The interactive demos the homepage scenario finder can open. */ +export type DemoId = + | "ratelimit" + | "recovery" + | "scaling" + | "workflow" + | "mesh" + | "saga" + | "worksteal"; + +/** Props every demo component receives from {@link DemoModal}. */ +export interface DemoProps { + /** Active host theme, so a demo can pick palette variants if it needs to. */ + theme: "light" | "dark"; +} diff --git a/docs/app/components/demos/workflow-demo.tsx b/docs/app/components/demos/workflow-demo.tsx new file mode 100644 index 00000000..66fb796b --- /dev/null +++ b/docs/app/components/demos/workflow-demo.tsx @@ -0,0 +1,684 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Workflow DAG demo — a real dependency graph (chain → parallel group → chord + * join → fan-out). Run it and execution propagates as each task's inputs become + * ready; click a node to trace/run a branch. React port of demo-workflow.js + * (SVG + RAF scheduler with lit edges + traveling dots). + */ + +const W = 760; +const H = 372; +const NW = 120; +const NH = 46; + +interface NodeDef { + id: string; + label: string; + fn: string; + type: string; + x: number; + y: number; + dur: number; +} +const NODES: NodeDef[] = [ + { + id: "ingest", + label: "ingest", + fn: "fetch_source()", + type: "Source", + x: 78, + y: 186, + dur: 700, + }, + { + id: "probe", + label: "probe", + fn: "inspect.s()", + type: "Chain step", + x: 222, + y: 186, + dur: 620, + }, + { + id: "t720", + label: "transcode 720p", + fn: "encode.s(720)", + type: "Group · parallel", + x: 392, + y: 84, + dur: 1500, + }, + { + id: "t1080", + label: "transcode 1080p", + fn: "encode.s(1080)", + type: "Group · parallel", + x: 392, + y: 186, + dur: 2000, + }, + { + id: "audio", + label: "extract audio", + fn: "audio.s()", + type: "Group · parallel", + x: 392, + y: 288, + dur: 1150, + }, + { + id: "package", + label: "package", + fn: "chord(mux.s())", + type: "Chord · join", + x: 560, + y: 186, + dur: 950, + }, + { + id: "notify", + label: "notify", + fn: "webhook.s()", + type: "Fan-out", + x: 704, + y: 120, + dur: 520, + }, + { + id: "cdn", + label: "publish CDN", + fn: "upload.s()", + type: "Fan-out", + x: 704, + y: 252, + dur: 760, + }, +]; +const EDGES: [string, string][] = [ + ["ingest", "probe"], + ["probe", "t720"], + ["probe", "t1080"], + ["probe", "audio"], + ["t720", "package"], + ["t1080", "package"], + ["audio", "package"], + ["package", "notify"], + ["package", "cdn"], +]; + +const byId: Record = {}; +for (const n of NODES) byId[n.id] = { ...n, deps: [], kids: [] }; +for (const [a, b] of EDGES) { + byId[b].deps.push(a); + byId[a].kids.push(b); +} + +const CRIT = (() => { + const memo: Record = {}; + const cp = (id: string): number => { + if (memo[id] != null) return memo[id]; + let m = 0; + for (const k of byId[id].kids) m = Math.max(m, cp(k)); + memo[id] = byId[id].dur + m; + return memo[id]; + }; + return Math.max( + ...NODES.filter((n) => byId[n.id].deps.length === 0).map((n) => cp(n.id)), + ); +})(); + +const edgeKey = (a: string, b: string) => `${a}>${b}`; +function edgePath(a: NodeDef, b: NodeDef): string { + const x1 = a.x + NW / 2; + const y1 = a.y; + const x2 = b.x - NW / 2; + const y2 = b.y; + const mx = (x1 + x2) / 2; + return `M${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`; +} + +function branchSet(rootId: string): Set { + const set = new Set([rootId]); + const stack = [rootId]; + while (stack.length) { + const c = stack.pop() as string; + for (const k of byId[c].kids) { + if (!set.has(k)) { + set.add(k); + stack.push(k); + } + } + } + return set; +} +const inBranch = (rootId: string, id: string) => branchSet(rootId).has(id); + +type Status = "idle" | "queued" | "running" | "done"; +const COLOR: Record< + Status, + { fill: string; stroke: string; pip: string; t1: string } +> = { + idle: { + fill: "var(--panel2)", + stroke: "var(--line2)", + pip: "var(--dim)", + t1: "var(--txt2)", + }, + queued: { + fill: "var(--indigo-soft)", + stroke: "var(--indigo-line)", + pip: "var(--indigo-br)", + t1: "var(--txt)", + }, + running: { + fill: "color-mix(in oklch,var(--cyan) 15%,transparent)", + stroke: "color-mix(in oklch,var(--cyan) 55%,transparent)", + pip: "var(--cyan)", + t1: "var(--txt)", + }, + done: { + fill: "color-mix(in oklch,var(--grn) 13%,transparent)", + stroke: "color-mix(in oklch,var(--grn) 48%,transparent)", + pip: "var(--grn)", + t1: "var(--txt)", + }, +}; + +interface Sim { + st: Record; + finishAt: Record; + t0: number; + elapsed: number; + runMask: Set | null; + lit: Set; +} +function freshSim(): Sim { + const st: Record = {}; + for (const n of NODES) st[n.id] = "idle"; + return { st, finishAt: {}, t0: 0, elapsed: 0, runMask: null, lit: new Set() }; +} + +export default function WorkflowDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const simRef = useRef(freshSim()); + const lenRef = useRef>(new Map()); + const pathRef = useRef>(new Map()); + const flowRef = useRef(null); + const [selected, setSelected] = useState("ingest"); + const [running, setRunning] = useState(false); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + // Measure each lit edge's length once mounted, then repaint to apply dasharray. + useEffect(() => { + for (const [a, b] of EDGES) { + const p = pathRef.current.get(edgeKey(a, b)); + if (p) lenRef.current.set(edgeKey(a, b), p.getTotalLength()); + } + repaint(); + }, []); + + const spawnDot = useCallback((key: string) => { + const path = pathRef.current.get(key); + const flow = flowRef.current; + if (!path || !flow || typeof path.getPointAtLength !== "function") return; + const len = path.getTotalLength(); + const dot = document.createElementNS( + "http://www.w3.org/2000/svg", + "circle", + ); + dot.setAttribute("r", "4"); + dot.setAttribute("fill", "var(--grn)"); + dot.style.filter = "drop-shadow(0 0 5px var(--grn))"; + flow.appendChild(dot); + const start = performance.now(); + const DUR = 480; + const move = (now: number) => { + const p = Math.min(1, (now - start) / DUR); + const pt = path.getPointAtLength(len * p); + dot.setAttribute("cx", String(pt.x)); + dot.setAttribute("cy", String(pt.y)); + dot.style.opacity = String( + p < 0.12 ? p / 0.12 : p > 0.85 ? 1 - (p - 0.85) / 0.15 : 1, + ); + if (p < 1) requestAnimationFrame(move); + else dot.remove(); + }; + requestAnimationFrame(move); + }, []); + + const eligible = useCallback((sim: Sim, id: string): boolean => { + if (sim.runMask && !sim.runMask.has(id)) return false; + if (sim.st[id] !== "idle") return false; + return byId[id].deps.every((d) => + sim.runMask && !sim.runMask.has(d) ? true : sim.st[d] === "done", + ); + }, []); + + const litOut = useCallback( + (sim: Sim, id: string) => { + for (const k of byId[id].kids) { + if (sim.runMask && !sim.runMask.has(k)) continue; + const key = edgeKey(id, k); + if (!sim.lit.has(key)) { + sim.lit.add(key); + if (!reduced) spawnDot(key); + } + } + }, + [reduced, spawnDot], + ); + + const tick = useCallback( + (now: number) => { + const sim = simRef.current; + sim.elapsed = now - sim.t0; + for (const n of NODES) { + if (eligible(sim, n.id)) { + sim.st[n.id] = "running"; + sim.finishAt[n.id] = now + n.dur; + } + } + for (const n of NODES) { + if (sim.st[n.id] === "running" && now >= sim.finishAt[n.id]) { + sim.st[n.id] = "done"; + litOut(sim, n.id); + } + } + const pending = NODES.some((n) => eligible(sim, n.id)); + const anyRunning = NODES.some((n) => sim.st[n.id] === "running"); + if (!anyRunning && !pending) setRunning(false); + repaint(); + }, + [eligible, litOut], + ); + + useRafLoop(tick, running && !reduced); + + const instantFinish = useCallback((sim: Sim) => { + for (const n of NODES) { + if (!sim.runMask || sim.runMask.has(n.id)) sim.st[n.id] = "done"; + } + for (const [a, b] of EDGES) { + if (!sim.runMask || (sim.runMask.has(a) && sim.runMask.has(b))) + sim.lit.add(edgeKey(a, b)); + } + sim.elapsed = CRIT; + }, []); + + const start = useCallback( + (mask: Set | null) => { + const sim = freshSim(); + sim.runMask = mask; + sim.t0 = performance.now(); + simRef.current = sim; + if (reduced) { + instantFinish(sim); + setRunning(false); + repaint(); + } else { + setRunning(true); + } + }, + [reduced, instantFinish], + ); + + const reset = useCallback(() => { + simRef.current = freshSim(); + setRunning(false); + setSelected("ingest"); + repaint(); + }, []); + + // Auto-run on mount (matches the embed behavior); static-finish if reduced. + // biome-ignore lint/correctness/useExhaustiveDependencies: run once per motion preference; start is stable + useEffect(() => { + setSelected(null); + start(null); + }, [reduced]); + + const sim = simRef.current; + const scope = NODES.filter((n) => !sim.runMask || sim.runMask.has(n.id)); + const doneCount = scope.filter((n) => sim.st[n.id] === "done").length; + const runCount = scope.filter((n) => sim.st[n.id] === "running").length; + + const selNode = byId[selected ?? "ingest"]; + const selStatus = sim.st[selNode.id] ?? "idle"; + const selStColor = COLOR[selStatus].pip; + const downstream = branchSet(selNode.id).size - 1; + + return ( +
+
+ + + media-pipeline · DAG + +
+ + +
+
+ +
+
+ + + + + + + + + + + {/* base edges */} + + {EDGES.map(([a, b]) => { + const key = edgeKey(a, b); + return ( + + ); + })} + + + {/* lit edges + traveling dots */} + + {EDGES.map(([a, b]) => { + const key = edgeKey(a, b); + const len = lenRef.current.get(key) ?? 400; + return ( + { + if (el) pathRef.current.set(key, el); + else pathRef.current.delete(key); + }} + d={edgePath(byId[a], byId[b])} + fill="none" + stroke="var(--grn)" + strokeWidth="2.4" + strokeLinecap="round" + style={{ + strokeDasharray: len, + strokeDashoffset: sim.lit.has(key) ? 0 : len, + transition: "stroke-dashoffset .5s ease", + }} + /> + ); + })} + + + {/* nodes */} + + {NODES.map((n) => { + const status = sim.st[n.id]; + const c = COLOR[status]; + const isSel = selected === n.id; + const dim = selected && !running && !inBranch(selected, n.id); + return ( + // biome-ignore lint/a11y/useSemanticElements: SVG can't be an HTML + +
+ +
+ selected task + + + {selNode.type} + +
+ {selNode.label} + {selNode.fn} +
+
+
+ Status + + + {selStatus} + +
+
+ Depends on +
+ {selNode.deps.length ? ( + selNode.deps.map((d) => ( + + {byId[d].label} + + )) + ) : ( + none · root task + )} +
+
+
+ Est. duration + {(selNode.dur / 1000).toFixed(2)}s +
+
+ Downstream tasks + {downstream} +
+
+
+ +
+ {downstream > 0 + ? `runs this task + ${downstream} downstream` + : "runs just this task"} +
+
+
+
+ +
+ + + idle + + + + queued + + + + running + + + + done + +
+ +
+
+ tasks + {scope.length} +
+
+ completed + {doneCount} +
+
+ running + {runCount} +
+
+ elapsed + + {(sim.elapsed / 1000).toFixed(1)} + s + +
+
+ critical path + + {(CRIT / 1000).toFixed(1)} + s + +
+
+
+ ); +} diff --git a/docs/app/components/demos/worksteal-demo.tsx b/docs/app/components/demos/worksteal-demo.tsx new file mode 100644 index 00000000..a3bf047d --- /dev/null +++ b/docs/app/components/demos/worksteal-demo.tsx @@ -0,0 +1,791 @@ +import { useCallback, useEffect, useReducer, useRef, useState } from "react"; +import { useRafLoop, useReducedMotion } from "./lib"; +import type { DemoProps } from "./types"; + +/* + * Multi-region work-stealing demo — taskito runs in several regions that gossip + * queue depth; when one backlog spikes, an idle peer steals a batch over HTTP so + * load self-balances with no central broker. React port of demo-worksteal.js + * (SVG world map + RAF simulation). + */ + +const W = 940; +const H = 540; +const JOB_MS = 820; +const STEAL_INT = 620; +const HOT = 5; +const COOL = 2; +const MAPH = 470; +const MAPY = 35; + +const proj = (lon: number, lat: number): [number, number] => [ + ((lon + 180) / 360) * W, + ((90 - lat) / 180) * MAPH + MAPY, +]; + +const CONTINENTS: [number, number][][] = [ + [ + [-165, 60], + [-150, 70], + [-120, 70], + [-95, 72], + [-80, 68], + [-78, 55], + [-64, 48], + [-70, 42], + [-76, 35], + [-81, 25], + [-92, 18], + [-100, 18], + [-106, 23], + [-114, 31], + [-123, 40], + [-124, 49], + [-133, 55], + [-150, 58], + ], + [ + [-80, 8], + [-72, 11], + [-60, 5], + [-50, 0], + [-44, -2], + [-40, -10], + [-48, -23], + [-58, -34], + [-66, -44], + [-72, -52], + [-75, -48], + [-71, -35], + [-70, -18], + [-76, -12], + [-81, -5], + ], + [ + [-16, 14], + [-12, 28], + [-2, 35], + [10, 37], + [20, 33], + [32, 31], + [35, 24], + [43, 12], + [51, 11], + [48, 0], + [41, -12], + [35, -22], + [25, -34], + [18, -35], + [11, -16], + [9, 0], + [-2, 5], + [-12, 8], + ], + [ + [-9, 36], + [-2, 44], + [2, 50], + [-4, 58], + [5, 62], + [14, 66], + [28, 71], + [55, 70], + [75, 73], + [100, 73], + [130, 71], + [155, 68], + [170, 66], + [165, 60], + [150, 58], + [142, 50], + [133, 43], + [123, 40], + [122, 31], + [112, 21], + [103, 14], + [97, 8], + [93, 20], + [88, 21], + [80, 7], + [76, 8], + [70, 18], + [63, 24], + [56, 26], + [48, 30], + [42, 37], + [30, 36], + [22, 40], + [14, 45], + [4, 43], + ], + [ + [-5, 50], + [-2, 51], + [1, 53], + [-2, 56], + [-6, 58], + [-9, 55], + [-10, 52], + ], + [ + [133, 32], + [138, 34], + [143, 39], + [145, 43], + [143, 45], + [139, 43], + [136, 39], + [133, 36], + [131, 33], + ], + [ + [114, -22], + [122, -18], + [131, -12], + [137, -11], + [143, -11], + [147, -20], + [150, -37], + [141, -38], + [131, -32], + [120, -34], + [114, -26], + ], +]; +const landPath = (pts: [number, number][]) => + `M${pts + .map((c) => + proj(c[0], c[1]) + .map((v) => v.toFixed(1)) + .join(" "), + ) + .join(" L ")} Z`; +const LAND_D = CONTINENTS.map(landPath).join(" "); + +interface RegionDef { + id: string; + loc: string; + lon: number; + lat: number; + cardX: number; + cardY: number; + q0: number; + rate: number; + gx: number; + gy: number; +} +const REG: RegionDef[] = [ + { + id: "us-west-2", + loc: "Oregon", + lon: -120, + lat: 45, + cardX: 44, + cardY: 58, + q0: 2, + rate: 0.3, + }, + { + id: "us-east-1", + loc: "N. Virginia", + lon: -77, + lat: 38, + cardX: 120, + cardY: 214, + q0: 2, + rate: 0.3, + }, + { + id: "eu-west", + loc: "Ireland", + lon: -7, + lat: 53, + cardX: 386, + cardY: 44, + q0: 2, + rate: 1.35, + }, + { + id: "ap-south-1", + loc: "Mumbai", + lon: 73, + lat: 19, + cardX: 560, + cardY: 300, + q0: 1, + rate: 0.34, + }, + { + id: "ap-northeast-1", + loc: "Tokyo", + lon: 139, + lat: 36, + cardX: 742, + cardY: 66, + q0: 2, + rate: 0.34, + }, +].map((r) => { + const [px, py] = proj(r.lon, r.lat); + return { ...r, gx: Math.round(px), gy: Math.round(py) }; +}); + +const LINKS: [string, string, number | null][] = [ + ["us-west-2", "eu-west", 85], + ["eu-west", "ap-northeast-1", 88], + ["eu-west", "ap-south-1", 139], + ["us-west-2", "us-east-1", null], + ["us-east-1", "eu-west", null], + ["ap-south-1", "ap-northeast-1", null], + ["us-east-1", "ap-south-1", null], +]; +const regById = (id: string) => REG.find((r) => r.id === id) as RegionDef; +const labelW = (s: string) => s.length * 7.1 + 4; + +interface RegionSim { + q: number; + busy: [number, number]; +} +interface StealToken { + id: number; + ax: number; + ay: number; + bx: number; + by: number; + dst: string; + batch: number; + t0: number; + dur: number; + done: boolean; +} +interface Sim { + reg: Record; + completed: number; + steals: number; + moved: number; + tokens: StealToken[]; + linkSolid: number[]; + stealAcc: number; + /** Virtual sim time (ms) — advances only while running, so pause freezes it. */ + clock: number; + /** Last wall-clock RAF timestamp, for computing real dt. */ + last: number; + ids: number; +} +function freshSim(): Sim { + const reg: Record = {}; + for (const r of REG) reg[r.id] = { q: r.q0, busy: [0, 0] }; + return { + reg, + completed: 0, + steals: 0, + moved: 0, + tokens: [], + linkSolid: LINKS.map(() => 0), + stealAcc: 0, + clock: 0, + last: 0, + ids: 0, + }; +} + +function poisson(m: number): number { + if (m <= 0) return 0; + const L = Math.exp(-m); + let k = 0; + let p = 1; + do { + k++; + p *= Math.random(); + } while (p > L); + return k - 1; +} + +export default function WorkStealDemo(_props: DemoProps) { + const reduced = useReducedMotion(); + const simRef = useRef(freshSim()); + const [paused, setPaused] = useState(false); + const [, repaint] = useReducer((n: number) => n + 1, 0); + + const stepSim = useCallback((sim: Sim, now: number, dt: number) => { + for (const r of REG) { + const rs = sim.reg[r.id]; + rs.q += poisson(r.rate * dt); + for (let i = 0; i < 2; i++) { + if (rs.busy[i] && now >= rs.busy[i]) { + rs.busy[i] = 0; + sim.completed++; + } + if (!rs.busy[i] && rs.q > 0) { + rs.q--; + rs.busy[i] = now + JOB_MS * (0.8 + Math.random() * 0.5); + } + } + } + }, []); + + const trySteal = useCallback((sim: Sim, now: number, dt: number) => { + sim.stealAcc += dt * 1000; + if (sim.stealAcc < STEAL_INT) return; + sim.stealAcc = 0; + const hot = [...REG].sort((a, b) => sim.reg[b.id].q - sim.reg[a.id].q)[0]; + if (sim.reg[hot.id].q < HOT) return; + const peers = LINKS.map((L, i) => ({ L, i })) + .filter(({ L }) => L[0] === hot.id || L[1] === hot.id) + .map(({ L, i }) => ({ peer: L[0] === hot.id ? L[1] : L[0], i })) + .filter( + ({ peer }) => + sim.reg[peer].q <= COOL && sim.reg[peer].busy.indexOf(0) >= 0, + ) + .sort((a, b) => sim.reg[a.peer].q - sim.reg[b.peer].q); + if (!peers.length) return; + const dst = peers[0].peer; + const batch = Math.max( + 1, + Math.min(4, Math.floor((sim.reg[hot.id].q - sim.reg[dst].q) / 2)), + ); + sim.reg[hot.id].q -= batch; + sim.steals++; + // mark the link solid + spawn the traveling token + const linkIdx = LINKS.findIndex( + (L) => + (L[0] === hot.id && L[1] === dst) || (L[0] === dst && L[1] === hot.id), + ); + if (linkIdx >= 0) sim.linkSolid[linkIdx] = now + 700; + const a = hot; + const b = regById(dst); + const dist = Math.hypot(b.gx - a.gx, b.gy - a.gy); + sim.tokens.push({ + id: sim.ids++, + ax: a.gx, + ay: a.gy, + bx: b.gx, + by: b.gy, + dst, + batch, + t0: now, + dur: Math.max(620, dist * 1.6), + done: false, + }); + }, []); + + const moveTokens = useCallback((sim: Sim, now: number) => { + for (let i = sim.tokens.length - 1; i >= 0; i--) { + const t = sim.tokens[i]; + const p = Math.min(1, (now - t.t0) / t.dur); + if (p >= 1 && !t.done) { + t.done = true; + sim.reg[t.dst].q += t.batch; + sim.moved += t.batch; + sim.tokens.splice(i, 1); + } + } + }, []); + + const loop = useCallback( + (wall: number) => { + const sim = simRef.current; + if (!sim.last) sim.last = wall; + const dt = Math.min((wall - sim.last) / 1000, 0.05); + sim.last = wall; + // Advance a virtual clock by real dt only while running, so a pause truly + // freezes time — deadlines never expire en masse on resume. + sim.clock += dt * 1000; + stepSim(sim, sim.clock, dt); + trySteal(sim, sim.clock, dt); + moveTokens(sim, sim.clock); + repaint(); + }, + [stepSim, trySteal, moveTokens], + ); + + useEffect(() => { + const sim = freshSim(); + if (reduced) { + for (const r of REG) sim.reg[r.id].busy = [1, 0]; + } + simRef.current = sim; + repaint(); + }, [reduced]); + + useRafLoop(loop, !paused && !reduced); + + const sim = simRef.current; + const now = sim.clock; + let hotQ = 0; + for (const r of REG) { + if (sim.reg[r.id].q > hotQ) hotQ = sim.reg[r.id].q; + } + + const burst = () => { + const hot = [...REG].sort((a, b) => sim.reg[b.id].q - sim.reg[a.id].q)[0]; + sim.reg[hot.id].q += 7; + repaint(); + }; + const reset = () => { + const fresh = freshSim(); + if (reduced) { + for (const r of REG) fresh.reg[r.id].busy = [1, 0]; + } + simRef.current = fresh; + repaint(); + }; + + return ( +
+
+ + + region-mesh · gossip + +
+ + + +
+
+ +
+ + + + + + + + + + + + {/* links + latency pills */} + + {LINKS.map((L, i) => { + const a = regById(L[0]); + const b = regById(L[1]); + const on = sim.linkSolid[i] > now; + return ( + + ); + })} + {LINKS.map((L, i) => { + if (L[2] == null) return null; + const a = regById(L[0]); + const b = regById(L[1]); + const mx = (a.gx + b.gx) / 2; + const my = (a.gy + b.gy) / 2; + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: fixed link list + + + + {L[2]} ms + + + ); + })} + + + {/* leader lines + geographic pins */} + + {REG.map((r) => ( + + ))} + + + {REG.map((r) => ( + + + + + ))} + + + {/* steal tokens */} + + {sim.tokens.map((t) => { + const p = Math.min(1, (now - t.t0) / t.dur); + const x = t.ax + (t.bx - t.ax) * p; + const y = t.ay + (t.by - t.ay) * p; + const op = + p < 0.1 + ? p / 0.1 + : p > 0.9 + ? (1 - (p - 0.9) / 0.1) * 0.95 + : 0.95; + return ( + + + + {t.batch} + + + ); + })} + + + {/* region cards */} + + {REG.map((r) => { + const rs = sim.reg[r.id]; + const hot = rs.q >= HOT; + const x = r.cardX; + const y = r.cardY; + return ( + + + + + {r.id} + + + {r.loc} + + {[0, 1].map((i) => { + const wbusy = !!rs.busy[i]; + return ( + + ); + })} + + + {rs.q} + {" "} + queued + + + ); + })} + + +
+ +
+ + + region node + + + + mesh link (gossip) + + + + work-steal over HTTP + +
+ +
+
+ completed + {sim.completed} +
+
+ steals + {sim.steals} +
+
+ moved · HTTP + {sim.moved} +
+
+ busiest queue + {hotQ} +
+
+
+ ); +} diff --git a/docs/app/components/landing/demo-modal.tsx b/docs/app/components/landing/demo-modal.tsx index 30afcd54..39dd4165 100644 --- a/docs/app/components/landing/demo-modal.tsx +++ b/docs/app/components/landing/demo-modal.tsx @@ -1,9 +1,10 @@ -import { useEffect, useRef, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; +import { demoComponent } from "@/components/demos"; import { useThemeMode } from "@/lib/theme"; -/** A live demo the finder can open: the embed id + a human title for the bar. */ +/** A live demo the finder can open: the demo id + a human title for the bar. */ export interface DemoTarget { - /** Demo id understood by `demos/interactive.html?embed=` (e.g. "ratelimit"). */ + /** Demo id — a React port (see demos/registry) or an iframe fallback id. */ id: string; /** Title shown in the modal bar. */ title: string; @@ -45,14 +46,14 @@ const CLOSE_ICON = ( ); /** - * Centered overlay that opens a finder scenario's live demo in an `