Просмотр исходного кода

feat(skills): Add jsx-ssr + runtime-adapters references to hono-ops

hono/jsx SSR as a first-class topic (jsxRenderer layouts, async components +
Suspense streaming, raw() escaping rules, the SPA-scope guard with the
HonoX/Astro ladder) and the Node/Bun/Deno adapter delta map (per-runtime
seams table, env(c)/getRuntimeKey helpers, Workers->Node porting checklist).

Both were previously scoped mentions; the mention sites now cross-ref the
references. check-hono-facts.py tracks @hono/node-server as a fifth fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
0xDarkMatter 1 месяц назад
Родитель
Сommit
326757ee8b

+ 5 - 2
CHANGELOG.md

@@ -88,8 +88,11 @@ feature releases live in the README "Recent Updates" section.
   streaming/SSE/WebSockets, Durable Objects (Hono-in-a-DO, hibernated
   WebSockets, alarms, vs RPC methods), OpenAPI (`@hono/zod-openapi`
   schema-first vs annotations vs skipping it), CORS + a production middleware
-  stack, and the Workers gotchas (detached fetch "Illegal invocation",
-  immutable headers, per-colo `caches`, `waitUntil`). Ten references, two
+  stack, `hono/jsx` SSR (jsxRenderer layouts, Suspense streaming, the
+  don't-grow-a-SPA-here scope guard), Node/Bun/Deno adapter deltas with a
+  Workers→Node porting checklist, and the Workers gotchas (detached fetch
+  "Illegal invocation", immutable headers, per-colo `caches`, `waitUntil`).
+  Twelve references, two
   commented starter templates (composition root + vitest-pool-workers
   config), a `route-inventory.py` scanner with three registration-order
   lints (exit 10: `bypass` = route dodges a later middleware, `duplicate` =

+ 10 - 2
skills/hono-ops/SKILL.md

@@ -15,8 +15,8 @@ discipline, typed errors, validation at the HTTP boundary, SPA co-serving, RPC
 clients, and testing under vitest-pool-workers. Patterns here are distilled from a
 production multi-tenant Worker (one Hono app, 6+ mounted sub-apps, ~1350 tests).
 
-> Verified against Hono v4 (2026). Hono also runs on Bun/Deno/Node — this skill is
-> Workers-first; non-Workers deltas are noted where they matter.
+> Verified against Hono v4 (2026). Workers-first; the Node/Bun/Deno deltas and
+> porting checklist live in references/runtime-adapters.md.
 
 **Staleness check:** `python scripts/check-hono-facts.py --offline` asserts the
 version-bearing facts (Hono major, `@hono/zod-validator`,
@@ -56,6 +56,12 @@ What are you doing with Hono?
 ├─ OpenAPI docs from routes (@hono/zod-openapi)
 │  └─ references/openapi.md
+├─ Server-rendered HTML / JSX / HTML emails
+│  └─ references/jsx-ssr.md
+│
+├─ Running or porting to Node / Bun / Deno
+│  └─ references/runtime-adapters.md
+│
 ├─ Typed client (hc RPC vs hand-rolled)
 │  └─ references/rpc-clients.md
@@ -258,6 +264,8 @@ per-cron branching): [references/workers-runtime.md](references/workers-runtime.
 | [references/streaming-and-realtime.md](references/streaming-and-realtime.md) | `stream`/`streamText`/`streamSSE`, WebSockets (plain Worker vs Durable Object hibernation), proxying, service bindings |
 | [references/durable-objects.md](references/durable-objects.md) | Routing into DOs, a Hono app per object, hibernated WebSockets, alarms, Hono-in-DO vs RPC methods |
 | [references/openapi.md](references/openapi.md) | `@hono/zod-openapi` schema-first routes, swagger/Scalar UI, `hono-openapi` annotations, when to skip OpenAPI entirely |
+| [references/jsx-ssr.md](references/jsx-ssr.md) | `hono/jsx` server rendering, `jsxRenderer` layouts, async components + Suspense streaming, `raw()` escaping rules, the SPA-scope guard (HonoX ladder) |
+| [references/runtime-adapters.md](references/runtime-adapters.md) | Node (`@hono/node-server`) / Bun / Deno deltas — env, static files, WebSockets, cron — plus the Workers→Node porting checklist |
 
 **Starter assets:**
 

+ 4 - 0
skills/hono-ops/assets/hono-facts.json

@@ -18,5 +18,9 @@
   "zod_openapi": {
     "prose_token": "@hono/zod-openapi",
     "package": "@hono/zod-openapi"
+  },
+  "node_server": {
+    "prose_token": "@hono/node-server",
+    "package": "@hono/node-server"
   }
 }

+ 138 - 0
skills/hono-ops/references/jsx-ssr.md

@@ -0,0 +1,138 @@
+# JSX / SSR — Server-Rendered HTML from the Same Worker
+
+`hono/jsx` renders JSX to HTML on the server with zero client runtime — the
+right tool for server-rendered pages, admin one-pagers, HTML emails, and error
+pages living beside an API. This file covers setup, the renderer middleware,
+async/streaming components, the escaping rules, and — load-bearing — where the
+approach stops scaling.
+
+## Scope guard (read first)
+
+**Don't grow an app-scale SPA in JSX inside an API Worker.** hono/jsx has no
+client-side state model, no router, no hydration story worth building on by
+hand. The ladder:
+
+| Need | Right tool |
+|---|---|
+| A few server-rendered pages, emails, error pages | `hono/jsx` (this file) |
+| Interactive islands on mostly-static pages | **HonoX** (Hono's file-based meta-framework with islands) or Astro |
+| A real SPA | Build it separately, serve via the assets binding (workers-runtime.md) |
+
+If a `hono/jsx` page has accumulated three `hono/jsx/dom` islands and a
+hand-rolled data-fetch layer, you're past the ladder's first rung — move it.
+
+## Setup
+
+```jsonc
+// tsconfig.json
+{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx" } }
+```
+
+```tsx
+import type { FC, PropsWithChildren } from 'hono/jsx';
+
+const Layout: FC<PropsWithChildren<{ title: string }>> = (props) => (
+  <html>
+    <head><title>{props.title}</title></head>
+    <body>{props.children}</body>
+  </html>
+);
+
+app.get('/status', (c) => c.html(<Layout title="Status"><h1>All good</h1></Layout>));
+```
+
+Components are plain functions returning JSX; `Fragment`, `memo`, and a
+server-side `createContext`/`useContext` (for threading e.g. the request's
+identity through a layout tree without prop-drilling) all exist.
+
+## The renderer middleware
+
+`hono/jsx-renderer` gives every route in a subtree a shared layout:
+
+```tsx
+import { jsxRenderer } from 'hono/jsx-renderer';
+
+app.use('/admin/*', jsxRenderer(({ children }) => (
+  <Layout title="Admin">{children}</Layout>
+)));
+
+app.get('/admin/users', async (c) => c.render(<UserTable users={await load(c)} />));
+```
+
+- `c.render(...)` wraps the page in the nearest registered layout; nested
+  `jsxRenderer` calls compose (inner receives `Layout` as a prop to extend).
+- Declare the extra `c.render` argument types via the `ContextRenderer`
+  interface if you pass per-page props (title, meta) through `c.render`.
+- Because it's middleware, the ordering rules from middleware.md apply — the
+  renderer must be registered before the routes that call `c.render`.
+
+## Async components and streaming
+
+Components may be `async` and awaited data renders inline — no loader
+ceremony. For slow sections, stream the shell first:
+
+```tsx
+import { Suspense } from 'hono/jsx';
+
+const SlowReport = async () => <pre>{JSON.stringify(await expensiveQuery())}</pre>;
+
+app.get('/report', (c) =>
+  c.html(
+    <Layout title="Report">
+      <h1>Report</h1>
+      <Suspense fallback={<p>crunching…</p>}>
+        <SlowReport />
+      </Suspense>
+    </Layout>,
+  ),
+);
+```
+
+With `Suspense` in the tree, `c.html` streams: the shell (with the fallback)
+flushes immediately and the resolved content follows in the same response.
+Same caveats as any streamed body (streaming-and-realtime.md): the status line
+is committed at first flush, so errors inside a suspended component can't
+become a 500 — they surface in the streamed content. Keep failure-prone work
+*before* `c.html`, and Suspense for genuinely slow-but-safe sections.
+
+## Escaping — the one security rule
+
+Interpolated values are HTML-escaped automatically; the two escape hatches are
+the XSS surface:
+
+```tsx
+import { raw } from 'hono/html';
+
+<div>{userInput}</div>                          {/* safe — escaped */}
+<div>{raw(trustedPrerenderedHtml)}</div>        {/* raw() = you are the sanitizer */}
+<div dangerouslySetInnerHTML={{ __html: x }} /> {/* same contract as raw() */}
+```
+
+`raw()` on anything user-influenced is stored XSS. If you must render
+user-authored rich text, sanitize server-side first and mark the sanitizer
+call site with a comment — the next reader can't tell trusted from untrusted
+by looking at the JSX. The `html` tagged-template (`hono/html`) follows the
+same rule: interpolations escaped, `raw()` opts out.
+
+## Client-side sprinkle: `hono/jsx/dom`
+
+`hono/jsx/dom` is a small (~3KB) React-compatible runtime (`render`,
+`useState`, `useEffect`) for mounting an interactive widget into a
+server-rendered page. It shares component syntax with the server side, which
+makes it tempting — apply the scope guard: one or two self-contained widgets
+(a copy button, a live counter) is the intended dose. Bundling per-page client
+entries from the same Worker means a build step anyway, at which point HonoX
+(which automates exactly this islands pattern, file-routed) is less machinery
+than what you'd hand-roll.
+
+## Where it pays off in an API Worker
+
+- **Error/maintenance pages** for the non-API fallthrough — a branded 503 from
+  the Worker when the SPA assets are unavailable.
+- **HTML emails** — render the same `FC` components to strings for the mail
+  provider; JSX beats string concatenation for nested tables, and escaping is
+  handled.
+- **Admin/status one-pagers** (`/design`, `/status`) that want zero build
+  step and live beside the data they render.
+- **OG/social preview *markup*** — but rendering OG *images* is satori/resvg
+  territory, not hono/jsx.

+ 6 - 5
skills/hono-ops/references/routing-and-request.md

@@ -110,8 +110,9 @@ deleteCookie(c, 'session_hint', { path: '/' });   // path must match the set
 ## HTML / JSX
 
 `hono/jsx` renders server-side JSX (`c.html(<Page/>)`) with zero client
-runtime — fine for small server-rendered pages and emails from the same Worker.
-For an actual SPA, build it separately and serve via the assets binding
-(workers-runtime.md); don't grow a JSX app inside an API Worker past a page or
-two. `hono/html` offers a `html` template literal with auto-escaping for
-one-off snippets — never string-concatenate HTML with user input.
+runtime — the full treatment (setup, `jsxRenderer` layouts, async/Suspense
+streaming, `raw()` escaping rules, and the don't-grow-a-SPA-here scope guard)
+is **jsx-ssr.md**. For an actual SPA, build it separately and serve via the
+assets binding (workers-runtime.md). `hono/html` offers a `html` template
+literal with auto-escaping for one-off snippets — never string-concatenate
+HTML with user input.

+ 93 - 0
skills/hono-ops/references/runtime-adapters.md

@@ -0,0 +1,93 @@
+# Runtime Adapters — Node, Bun, Deno Deltas (and Porting Off Workers)
+
+Hono's core is Web-standard `Request`/`Response`, so routes, middleware,
+validation, errors, and RPC are portable verbatim. Everything that differs
+lives at the edges: how the server starts, env access, static files,
+WebSockets, and the platform services Workers provides that other runtimes
+don't. This file is the delta map, plus a porting checklist.
+
+## Starting the server
+
+```typescript
+// Cloudflare Workers (this skill's default)
+export default { fetch: app.fetch } satisfies ExportedHandler<Env>;
+
+// Node — the one runtime needing a real adapter package
+import { serve } from '@hono/node-server';
+serve({ fetch: app.fetch, port: 3000 });
+
+// Bun — Bun.serve speaks fetch natively
+export default { port: 3000, fetch: app.fetch };
+
+// Deno
+Deno.serve({ port: 3000 }, app.fetch);
+```
+
+Node's adapter translates Node's `IncomingMessage`/`ServerResponse` to Web
+`Request`/`Response`; Node 18+ required. Bun and Deno need no translation.
+
+## The per-runtime seams
+
+| Concern | Workers | Node | Bun | Deno |
+|---|---|---|---|---|
+| Env/config | `c.env` bindings | `process.env` | `process.env` / `Bun.env` | `Deno.env` |
+| Static files | assets binding (workers-runtime.md) | `serveStatic` from `@hono/node-server/serve-static` | `serveStatic` from `hono/bun` | `serveStatic` from `hono/deno` |
+| WebSockets | `upgradeWebSocket` from `hono/cloudflare-workers` (or a DO) | `@hono/node-ws` (`createNodeWebSocket` + `injectWebSocket` on the server) | `createBunWebSocket` from `hono/bun` (pass its `websocket` to `Bun.serve`) | `upgradeWebSocket` from `hono/deno` |
+| Cron | `scheduled()` handler | system cron / node-cron / your scheduler | same as Node | `Deno.cron` |
+| Post-response work | `ctx.waitUntil` | just don't await (process persists) | same | same |
+| `caches` API | per-colo cache | absent — in-memory LRU / Redis | absent | partial (`caches` exists on Deploy) |
+| Install | `npm i hono` | `npm i hono @hono/node-server` | `bun add hono` | JSR: `deno add jsr:@hono/hono` |
+
+Two `hono/adapter` helpers keep shared code honest:
+
+```typescript
+import { env, getRuntimeKey } from 'hono/adapter';
+
+const key = env<{ API_KEY: string }>(c).API_KEY;  // reads c.env OR process.env OR Deno.env
+getRuntimeKey();                                   // 'workerd' | 'node' | 'bun' | 'deno' | ...
+```
+
+Use `env(c)` in any middleware you intend to publish or reuse across runtimes;
+keep runtime branching (`getRuntimeKey()`) out of route handlers — isolate it
+in the composition root or an adapter module, or portability rots one `if` at
+a time.
+
+## Porting a Workers app to Node (the common direction)
+
+1. **Bindings → constructed dependencies.** `c.env.DB`/`c.env.FILES` have no
+   Node equivalent; construct clients (Postgres/SQLite driver, S3 client) at
+   boot and hand them to the app — a `createApp(deps)` factory that `c.set`s
+   them in a first middleware is the least-invasive shape, and it makes the
+   Workers build cleaner too.
+2. **`ASSETS.fetch` catch-all → `serveStatic`.** Replace the SPA fallback pair
+   with `serveStatic({ root: './web/dist' })` + a `serveStatic({ path: 'index.html' })`
+   fallback. Keep the JSON-404-for-`/api/*` route — that split is
+   runtime-independent.
+3. **`waitUntil` → fire-and-forget or a queue.** On Node the process outlives
+   the response, so `void promise.catch(log)` works; anything needing
+   guaranteed delivery was queue-shaped on Workers anyway.
+4. **`scheduled()` → a scheduler.** The cron branches become named jobs
+   invoked by node-cron/systemd — keep them as the same exported functions the
+   Workers `scheduled()` dispatcher called, and only the dispatcher changes.
+5. **Per-colo `caches` → explicit cache.** An in-memory LRU reproduces the
+   per-instance semantics honestly; Redis upgrades it to shared.
+6. **Re-run the same tests.** `app.request()` tests are runtime-neutral;
+   only the pool-workers suite (real bindings) needs a Node-side equivalent
+   for whatever replaced the bindings.
+
+Porting *to* Workers reverses the list — the usual sticking points are
+long-lived sockets (→ Durable Objects, durable-objects.md), filesystem access
+(→ R2/KV), and unbounded background work (→ queues + `waitUntil`).
+
+## Bun/Deno notes worth knowing
+
+- **Bun:** `bun test` runs `app.request()` suites directly and fast; Vitest
+  also works. `createBunWebSocket` returns both the middleware and the
+  `websocket` handler object you must pass to `Bun.serve` — forgetting the
+  second half compiles and then 500s on upgrade.
+- **Deno:** import Hono from JSR (`jsr:@hono/hono`), not the npm shim, for
+  first-class types; permissions apply (`--allow-net`, `--allow-env`) — a
+  middleware reading env without `--allow-env` throws at request time, not
+  boot.
+- Both runtimes run the same `app.request()` test suites unchanged — which is
+  the practical payoff of keeping runtime branching out of handlers.

+ 2 - 2
skills/hono-ops/references/streaming-and-realtime.md

@@ -88,8 +88,8 @@ Reality check before shipping that:
   after upgrade there is no per-message auth — bind identity to the socket at
   accept and treat the connection as a session.
 - The `upgradeWebSocket` import is per-runtime (`hono/cloudflare-workers`,
-  `hono/deno`, `hono/bun`) — the one non-portable seam in an otherwise portable
-  app.
+  `hono/deno`, `hono/bun`, `@hono/node-ws`) — one of the few non-portable
+  seams; the full per-runtime map is runtime-adapters.md.
 
 ## Proxying and Worker-to-Worker (service bindings)
 

+ 1 - 1
skills/hono-ops/scripts/check-hono-facts.py

@@ -50,7 +50,7 @@ EX_UNAVAILABLE = 7
 EX_DRIFT = 10
 
 SCHEMA = "claude-mods.hono-ops.facts/v1"
-FACT_KEYS = ("hono", "zod_validator", "pool_workers", "zod_openapi")
+FACT_KEYS = ("hono", "zod_validator", "pool_workers", "zod_openapi", "node_server")
 
 HERE = Path(__file__).resolve().parent
 DEFAULT_CATALOG = HERE.parent / "assets" / "hono-facts.json"