Design and implement a minimalistic single-file library for building simple web apps with Bun using server-side TSX rendering — a lightweight JSX-to-HTML rendering layer that works with Bun.serve(), kept as small and simple as possible.
| Idea | Proposed (R1) | Adopted (R2) | Defended/Yielded (R3) | Final Status |
|---|---|---|---|---|
| String-only JSX (no VDOM) | c, d | a, b, d | a defends; b yields to Markup; c, e defend VNodes | Split — a uses Symbol-branded strings; c, e use VNodes |
VNode objects ({tag, props}) |
b, e | c, e | c, e defend; a rejects as heavyweight | Split — 3 agents use VNodes, 2 use branded strings |
| Symbol-branded Markup | — | a (originated R2) | a defends; b adopts | Adopted by 2/5 — solves double-escaping on strings |
| Auto-escape by default | b, e | all except a(R1) | all defend | Consensus |
| Async components | b, e | c, d, e | a rejects; d yields; c, e defend | Split — c, e keep; a, b, d go sync-only |
URLPattern router |
c | a(rejected), d | c, d defend; a, b, e reject | Split — c, d include; a, b, e exclude |
Bun.serve routes (no custom router) |
— | a (originated R2) | a defends; b defends | 2/5 prefer native routes |
Classic JSX pragma (@jsx h) |
d, e | d | all peers use classic in R3; a defends automatic | Split — a uses automatic; rest classic |
Automatic JSX transform (jsxImportSource) |
— | a (originated R2) | a defends; b adopts R3 | Adopted by 2/5 |
className→class mapping |
b, e | c, d, e | a rejects (use native HTML); b yields to a; c, e keep | Split |
style object support |
c | b, d, e | a rejects; others keep | 4/5 include it |
raw() helper |
e | a | a defends over dangerouslySetInnerHTML; b adopts |
3/5 include it |
dangerouslySetInnerHTML |
c | b, d | a rejects (prefers raw()); c, e keep both |
3/5 include it |
| Built-in SSE | — | a (originated R2) | a defends; d keeps; b, e reject as scope creep | Split — 2 include, 3 exclude |
| Live reload (WebSocket) | — | a (originated R2) | a defends; d keeps; b, e reject | Split — 2 include, 3 exclude |
| Static file fallback | — | a (originated R2) | a, c, d include; b, e exclude | Split |
serve() wrapper |
e | a, b, d | a, b, e defend thin wrapper | Consensus (form varies) |
| Response polymorphism (return string or Response) | c | all | all defend | Consensus |
| Zero dependencies | all | all | all | Consensus |
| Void tag set | all | all | all | Consensus |
After three rounds, all five agents converge on these points:
- JSX compiles to HTML with no Virtual DOM diffing — this is server-only rendering, so reconciliation is unnecessary overhead.
- Auto-escaping of text content by default to prevent XSS, with an explicit opt-out mechanism (
raw()ordangerouslySetInnerHTML). - Response polymorphism — route handlers can return either JSX/string output (auto-wrapped as
text/html) or aResponseobject for full control (JSON, redirects, etc.). - Zero external dependencies — the library uses only Bun built-in APIs (
Bun.serve,Bun.file,TextEncoder,ReadableStream). - Void tag awareness — a
Setof self-closing HTML tags for spec-compliant output. - A thin
serve()wrapper aroundBun.serve()that converts JSX output to HTTP responses automatically. - Single-file distribution — everything in one file, under ~120 LOC.
VNode camp (agents c, e): JSX returns { tag, props, children } objects, rendered to strings via a separate async render() call. This cleanly separates structure from serialization, avoids double-escaping (objects vs strings are distinguishable), and naturally supports async components.
Symbol-branded Markup camp (agent a, joined by b in R3): JSX returns a string-like Markup object branded with a Symbol. The renderer checks for the Symbol to skip escaping on trusted HTML, while toString() makes it transparent in concatenation, SSE, and template literals. This avoids the two-phase workflow (create tree → render) while still being correct.
Plain string camp (agent d): JSX returns raw strings. Simplest approach but has a confirmed double-escaping bug when nesting components — a fatal flaw that d never fully resolved.
Assessment: The VNode approach is the safest and most conventional. The Symbol-branded approach is clever and avoids a render step, but adds an unconventional abstraction. Plain strings are broken for nested components.
Async camp (c, e): render() is async and recursively awaits Promises, allowing components to fetch their own data. This co-locates data fetching with UI, simplifying architecture for SSR apps.
Sync camp (a, b, d): JSX is synchronous; data fetching happens in route handlers, which pass data as props. This keeps the rendering layer simple, avoids "Promise infection" of every JSX expression, and preserves immediate composability (no await needed to use output).
Assessment: Both patterns are valid. Sync is simpler and more composable; async is more ergonomic for data-heavy pages. For a minimalistic library targeting simple apps, sync is the more appropriate default.
Router camp (c, d): Include a ~30-line App class with URLPattern-based get()/post() methods. Provides a familiar Express-like API and encapsulates error handling.
Native routes camp (a, b, e): Bun.serve() already supports routes with path parameters. A second routing layer is redundant for a minimalistic library.
Assessment: Since Bun's native routes already handles path matching, a custom router is duplicative. However, Bun's native routes doesn't support :param patterns in the same way — it uses exact path matching. For apps needing dynamic routes, the URLPattern wrapper adds genuine value. For truly simple apps, native routes suffices.
Minimal core camp (b, e): The library should be strictly JSX→HTML + Response bridge. Router, SSE, live-reload are separate concerns.
Batteries-included camp (a, c, d): Include SSE (~25 LOC) and live reload (~15 LOC) because HTMX-style apps (the primary use case) need server-sent events, and every developer needs live reload during development.
Assessment: This is ultimately a philosophy question. At ~110 LOC total, including SSE and live reload is still "minimal" by any reasonable standard, and the value-to-size ratio is high. But it does push the library from "rendering layer" toward "micro-framework."
React-compat camp (b, c, d, e): Map className→class and support style objects with camelCase→kebab conversion. Reduces friction for developers with React habits.
HTML-native camp (a): Use class directly (it works in JSX), use string style attributes. Since we render HTML not DOM, React conventions are unnecessary overhead.
Assessment: The React-compat camp has the practical advantage — TypeScript's JSX types use className, and muscle memory matters. But agent a's point that this is extra code for zero functional benefit in SSR is technically correct.
The recommended implementation synthesizes the strongest ideas:
Architecture: Use VNode objects (from agents c, e) as the JSX output type. While agent a's Symbol-branded Markup is clever, VNodes are more conventional, naturally support async rendering, and have no risk of the double-escaping bug that plagues plain strings. The separate render() step is a small price for correctness.
Rendering: Support async components but keep it opt-in — sync components work naturally, and only components that return Promises trigger async behavior. Use Promise.all for sibling children (from agent e) to enable parallel data fetching.
Escaping: Auto-escape text and attributes by default. Support both raw() (simpler API, from agents a, e) and dangerouslySetInnerHTML (React compatibility, from agents b, c). Both are cheap to implement.
JSX Transform: Export both h/Fragment (classic) and jsx/jsxs/jsxDEV (automatic) from the same file (from agent e's R3). Let users choose their preferred tsconfig setup. This costs one line: export { h as jsx, h as jsxs, h as jsxDEV }.
Scope: Include only the rendering core + response bridge in the primary library. Provide SSE and live-reload as clearly marked optional sections or a separate companion file. This respects the "as small as possible" requirement while acknowledging their practical value.
Router: Do not include a router. Bun.serve()'s routes handles the common case, and a switch statement handles the rest. For apps needing more, external routers exist.
Attributes: Include className→class and style object support. The code cost is ~8 lines, and the developer experience benefit is significant.
Target size: ~80-90 LOC for the core (JSX runtime + render + response helpers). ~120 LOC if SSE + live-reload are included inline.
| Agent | Unique Contribution |
|---|---|
| Agent a | Invented the Symbol-branded Markup type — the most novel idea in the entire deliberation. Identified the double-escaping bug in plain-string approaches. Advocated strongly for automatic JSX transform and HTML-native attributes. Brought the richest feature set (SSE, live-reload, static files) while keeping LOC minimal. Most thorough in analyzing trade-offs and identifying correctness bugs in other solutions. |
| Agent b | Provided the most complete async rendering pipeline with proper Promise handling. Strongest advocate for React attribute compatibility. Honest about yielding to superior ideas — adopted Symbol-branded Markup, automatic transform, and sync-only rendering when persuaded. |
| Agent c | Proposed the most complete VNode architecture with async render, URLPattern router, and static file serving. Strongest defender of async components and built-in routing. Provided the most "batteries-included" solution. |
| Agent d | Earliest proponent of URLPattern for routing. Attempted to be the synthesizer across rounds, combining ideas from others. Weakest on correctness — plain-string approach retained the double-escaping bug through all rounds. |
| Agent e | Most disciplined about scope minimalism — consistently defended the smallest possible core. Originated the raw() helper and dual JSX runtime exports (classic + automatic). Strongest advocate for keeping router/SSE/live-reload out of the core. Most practically validated — actually ran smoke tests. |
| Feature | Agent a | Agent b | Agent c | Agent d | Agent e |
|---|---|---|---|---|---|
| JSX output type | Symbol Markup | Symbol Markup | VNode | string | VNode |
| Async components | No | No | Yes | No | Yes |
| Escaping | Auto + raw() |
Auto + raw() |
Auto + raw() + dSIH |
Auto + dSIH |
Auto + raw() + dSIH |
| JSX transform | Automatic | Automatic | Classic + auto aliases | Classic | Classic + auto aliases |
| Router | No (Bun routes) | No | Yes (URLPattern) | Yes (URLPattern) | No |
| SSE | Yes | No | No | Yes | No |
| Live reload | Yes | No | No | Yes | No |
| Static files | Yes | No | Yes | No | No |
className→class |
No | No | Yes | Yes | Yes |
style objects |
No | No | Yes | Yes | Yes |
| Approx LOC | ~110 | ~80 | ~90 | ~100 | ~100 |
| Double-escape safe | Yes (Symbol) | Yes (Symbol) | Yes (VNode) | No (bug) | Yes (VNode) |
| Needs render step | No | No | Yes (await render()) |
No | Yes (await render()) |
| Immediate composability | Yes (toString) | Yes (toString) | No (need render) | Yes (string) | No (need render) |