@blueshed/railroad
v0.10.0
Published
The smallest reactive layer for Bun realtime apps — signals, JSX, hash router. Built so an LLM can use it correctly without re-learning. Pair with @blueshed/delta for WebSocket document sync.
Maintainers
Readme
Railroad
Reactive UI for the Bun fullstack runtime. Signals, JSX, and a hash router that lean directly into Bun 1.3's HTML imports, HMR, and Bun.build --compile. Pair with @blueshed/delta for WebSocket document sync.
Zero runtime dependencies. Real DOM. ~1KLOC. Designed so an LLM (or you, six months from now) can use it correctly without re-reading documentation.
Why Bun + Railroad is the sweet spot
Bun 1.3 ships the parts that frontend stacks normally need three packages and a config file to assemble:
- HTML imports in
Bun.serve(1.2+) — server readsimport index from "./index.html", walks<script>and<link>tags, transpiles TSX/JSX, bundles imports, processes CSS, hashes asset URLs. Novite.config.ts. No build step in development. - First-class JSX/TSX —
jsxImportSource: "@blueshed/railroad"intsconfig.jsonis the entire setup. Bun's transpiler and bundler both honour it; railroad ships matchingjsx-runtimeandjsx-dev-runtimemodules so dev mode (jsxDEV) works too. - HMR (1.3+) —
Bun.serve({ development: { hmr: true } })gives youimport.meta.hotmodelled on Vite's, including console mirroring (development: { console: true }). bun build --compile(1.3+) — compile the entire HTML+TSX+server graph into a single static binary you can scp to a server. No Node, no install, no Docker layer.Bun.WebView(1.3.12+) — real headless browser tests in the samebun testyou already run for unit tests. WKWebView on macOS, Chrome via CDP elsewhere. Railroad uses it.
Railroad fills exactly the slot Bun leaves open: a small, push-based reactive layer that binds signals to real DOM. No virtual DOM, no compiler, no SSR layer Bun doesn't have anyway. You get reactivity, JSX, and a router; you keep Bun's pipeline.
Install
bun add @blueshed/railroadBun / bundler only. Railroad ships TypeScript source with no build step and uses extensionless imports, so your toolchain must transpile TS and resolve with
moduleResolution: "bundler"(or"bun"). It does not resolve under Node'snode16/nodenext. Bun is the intended runtime.
A working app, end to end
// tsconfig.json
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@blueshed/railroad",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true
}
}<!-- index.html -->
<!DOCTYPE html>
<html>
<body>
<div id="root"></div>
<script type="module" src="./app.tsx"></script>
</body>
</html>// app.tsx
import { signal, routes } from "@blueshed/railroad";
const count = signal(0);
function Home() {
return (
<div>
<h1>Hello World</h1>
<button onclick={() => count.update(n => n + 1)}>
Count: {count}
</button>
</div>
);
}
routes(document.getElementById("root")!, {
"/": () => <Home />,
});// server.ts
import home from "./index.html";
Bun.serve({
routes: { "/": home },
development: { hmr: true, console: true },
});bun server.ts # dev with HMR
bun build ./index.html --production # production assets
bun build --compile server.ts # one-binary deployThat's the whole stack. No Vite, no webpack, no Rollup config, no tsx-loader, no @vitejs/plugin-react. The HMR works, the TSX compiles, sourcemaps are emitted, CSS bundles, and asset URLs are content-hashed — out of the box.
Signals
import { signal, computed, effect, batch, untrack } from "@blueshed/railroad";
const count = signal(0);
const doubled = computed(() => count.get() * 2); // ReadonlySignal — no .set
const label = count.map(n => `Count: ${n}`); // also ReadonlySignal
effect(() => console.log(count.get())); // runs on count change
count.set(1);
count.update(n => n + 1);
count.peek(); // read without tracking
untrack(() => count.get()); // same, function form
// In-place mutation helpers — the realtime story
const todos = signal([{ id: 1, text: "Buy milk" }]);
todos.mutate(arr => arr.push({ id: 2, text: "Walk dog" })); // structuredClone + notify
todos.touch(); // notify without replacing the ref
// .patch() shallow-merges OBJECT signals (don't call it on an array — it would
// spread the array into a plain object and break it)
const filter = signal({ color: "all", done: false });
filter.patch({ color: "blue" }); // { color: "blue", done: false }
// Custom equality — suppress notifications when contents match
const coords = signal({ x: 1, y: 2 }, {
equals: (a, b) => a.x === b.x && a.y === b.y,
});
batch(() => { count.set(10); count.set(20); }); // effect runs onceJSX
Components run once and return real DOM nodes. Reactivity is in the signals, not in re-rendering.
const name = signal("World");
function Greeting() {
return <h1>Hello {name}</h1>; // bare signal as child — auto-reactive
}
// App root without a router: mount() brackets a dispose scope and
// returns the disposer. (Routed apps get the same from routes().)
const dispose = mount(document.getElementById("root")!, () => <Greeting />);
<span>{() => count.get() > 5 ? "High" : "Low"}</span> // function child auto-tracks
<input value={name} /> // signal as prop
<div class={visible.map(v => v ? "show" : "hide")} /> // .map() for derived attrswhen(condition, truthy, falsy?)
{when(loggedIn, () => <Dashboard />, () => <Login />)}list(items, keyFn?, render) — keyed reactive list
{list(todos, t => t.id, (todo$, idx$) => (
<li class={idx$.map(i => i % 2 ? "odd" : "even")}>
{todo$.map(t => t.text)}
</li>
))}SVG works transparently — SVG tags (circle, g, linearGradient, clipPath, filter primitives, …) are created in the SVG namespace outright, including inside when() and list(), with camelCase preserved. <foreignObject> children stay HTML.
Routes
Hash-based client router. Handlers receive (params, params$) — the second is a reactive ReadonlySignal that updates when params change within the same pattern (/users/1 → /users/2 does not re-render).
import { routes, navigate, route, when } from "@blueshed/railroad";
routes(app, {
"/": () => <Home />,
"/users/:id": (_p, params$) => <User id={params$.map(p => p.id)} />,
"/sites/*": () => <SitesLayout />, // wildcard keeps layout mounted
});
function SitesLayout() {
const detail = route<{ id: string }>("/sites/:id");
return (
<div>
<SitesNav />
{when(detail, () => <SiteDetail />, () => <SitesList />)}
</div>
);
}
navigate("/users/42");/sites → /sites/42 → /sites/99: SitesLayout stays mounted, only the inner content swaps. Navigate away from /sites/* and the layout tears down cleanly.
Realtime — the actual reason this library exists
Two patterns, depending on whether the patch stream is something you control or something you delegate to @blueshed/delta.
Hand-rolled patch streams — .touch() + .mutate()
For a signal holding a large document mutated in place by patches (CRDT updates, custom WebSocket protocols, SQL LISTEN/NOTIFY payloads), Signal.touch() and .mutate() skip the structuredClone cost of .set() on a fresh object:
import { signal, list } from "@blueshed/railroad";
type Row = { id: number; text: string; done: boolean };
const rows = signal<Row[]>([]);
const ws = new WebSocket("/ws");
ws.onmessage = (ev) => {
applyPatch(rows.peek(), JSON.parse(ev.data)); // mutate the existing array
rows.touch(); // notify without cloning
};
function App() {
return (
<ul>
{list(rows, r => r.id, (row$) => (
<li>
<input type="checkbox" checked={row$.map(r => r.done)} />
{row$.map(r => r.text)}
</li>
))}
</ul>
);
}Delta-doc — turnkey JSON-Patch sync, signal-backed
For a turnkey WebSocket sync layer use @blueshed/delta. Delta declares railroad as a peer dependency and delta/client.ts imports signal directly — openDoc("name") returns a Doc<T> whose data field is a railroad Signal<T | null>, not a wrapper. It drops straight into JSX, when(), and list() with no glue. Three backends: JSON file, SQLite (temporal), Postgres (RLS + LISTEN/NOTIFY).
// Server — same Bun.serve hosting your JSX routes
import home from "./index.html";
import { createWs, registerDoc } from "@blueshed/delta/server";
const ws = createWs();
await registerDoc(ws, "board:1", {
file: "./board.json",
empty: { columns: {}, cards: {} },
});
Bun.serve({
routes: { "/": home, [ws.path]: ws.upgrade },
websocket: ws.websocket,
development: { hmr: true, console: true },
});// Client — list() reads doc.data directly, preserving per-row identity
import { provide, list, when } from "@blueshed/railroad";
import { connectWs, WS, openDoc } from "@blueshed/delta/client";
provide(WS, connectWs("/ws"));
interface Card { id: number; title: string; column_id: number }
interface BoardDoc { columns: Record<string, Column>; cards: Record<string, Card> }
const doc = openDoc<BoardDoc>("board:1");
function Board() {
const cards = doc.data.map((d) => d ? Object.values(d.cards) : []);
return when(doc.data, () => (
<ul>
{list(cards, c => c.id, (card$) => (
<li>{card$.map(c => c.title)}</li>
))}
</ul>
), () => <p>loading…</p>);
}
await doc.send([{ op: "add", path: "/cards/-",
value: { column_id: 1, title: "new card", position: 0 } }]);One important steering note: delta also ships applyOpsToCollection (in @blueshed/delta/dom-ops) for projects without a keyed reactive list primitive. If you have railroad, use list() instead — the keyed form already does the per-row surgical update that applyOpsToCollection exists to provide. They overlap; pick one per project. Use list(doc.data.map(d => Object.values(d.coll)), r => r.id, ...) and keep the realtime story in one idiom.
Testing — bun test does both halves
Unit tests run against happy-dom (preloaded via bunfig.toml) for fast reactivity assertions. Integration tests use Bun.WebView (1.3.12+) to drive a real headless browser against your actual Bun.serve instance:
import { test, expect } from "bun:test";
import { startServer } from "./server";
test("counter increments", async () => {
const { server } = await startServer({ port: 0 });
await using view = new Bun.WebView({ width: 800, height: 600 });
await view.navigate(server.url.href);
await view.click("[data-testid=inc]");
expect(await view.evaluate<string>("document.querySelector('#count').textContent")).toBe("1");
server.stop(true);
});Same bun test runner. No Playwright install. No browser binary download on macOS (uses system WKWebView). Railroad's own suite spans happy-dom unit tests and real-browser WebView integration tests. Run the browser layer explicitly with bun run test:webview — bare bun test can drop files under tests/ from discovery, so CI runs it as its own step.
Shared (DI) and Logger
import { key, provide, inject } from "@blueshed/railroad";
const STORE = key<AppStore>("store");
provide(STORE, createStore());
const store = inject(STORE);
import { createLogger, setLogLevel } from "@blueshed/railroad";
const log = createLogger("[server]");
log.info("listening"); // gated by LOG_LEVEL in .envProgressive adoption
Each module is independent — pick the level you need:
signals no deps Use anywhere: server, CLI, worker, tests
shared no deps Typed DI without prop threading
logger no deps Bun-friendly leveled console output
jsx signals Reactive real-DOM rendering
routes signals Hash router with reactive params// Just signals (no JSX, no tsconfig changes)
import { signal, computed, effect } from "@blueshed/railroad/signals";Claude Code
Ships with two Claude Code skills under .claude/skills/: railroad (a checklist of the failure modes that have actually shown up in development — not a tutorial) and bun-route (scaffolds Bun HTML routes and Bun.WebView tests; the railroad skill refers to it). Copy both in:
cp -r node_modules/@blueshed/railroad/.claude/skills/* .claude/skills/
# or user-wide:
cp -r node_modules/@blueshed/railroad/.claude/skills/* ~/.claude/skills/What it isn't
- Not a TC39 Signals implementation. It's push-based, in the same family as Vue's
ref, Solid'screateSignal, Preact's signals. - Not an SSR / RSC framework. Bun doesn't ship those either; railroad doesn't add them on top.
- Not a 30KB framework with a hooks system, lifecycle methods, or a virtual DOM.
Sharp edges to know
- Propagation is glitch-free and topologically ordered (0.10+). One write — or one
batch()of writes — runs each affected computed/effect at most once per settled pass, upstream before downstream, so a diamond (a → b,a → c, an effect reads both) never observes half-updated state. Siblings at the same depth run in subscription order; an effect that writes signals re-queues their consumers in the same pass (a true cycle throws). when()/list()need a dispose scope. Created outside a component,routes()handler, ormount(), their internal effects are unreachable — railroad warns on the console. Mount roots viamount()orroutes().- Routes match in declaration order. The first pattern that matches wins — declare
/users/newbefore/users/:id. - Route matching is segment-based only. No query-string handling (
#/users/42?tab=1matches/users/:idwithid === "42?tab=1"), and a trailing slash is a real empty segment (/users/42/does not match/users/:id). - SVG tags get their namespace at creation (0.10+) — refs fire once and manual listeners survive. Only the four HTML/SVG-ambiguous tags (
a,script,style,title) still go through adoption when appended inside<svg>: on that path areffires twice (use the last call) and hand-attached listeners don't carry over — useon*props. provide/injectis a process-global singleton. Great for client apps and app-wide services; on the server it is shared across all requests, so don't use it for per-request state..mutate()usesstructuredClone— it only works on plain-data signals (no functions, class instances, or DOM nodes in the value).
It's the smallest correct reactive layer for the workflow Bun 1.3 actually ships: HTML imports, TSX bundling, HMR, and --compile to a single binary. That's the niche, and it's a real one.
License
MIT
