Hono + SSR + JWT
What is this about
Section titled “What is this about”This example builds a small Hono API with 3 routes:
GET /pingfor health checks.POST /ssrto SSR a user card.POST /jwtto issue a valid signed JWT for a user.
This example is split into three files:
hono_knitting.ts(the Hono server, plus a Knitting worker pool).hono_componets_ssr.tsx(SSR task: parse + defaults + render).hono_components_jwt.ts(JWT task: validate + sign + return JSON string).
Technologies used (and why)
Section titled “Technologies used (and why)”- Hono: a small, fast routing layer. It keeps the request path minimal so most overhead is in your actual route work.
@hono/node-server: a thin adapter that runs a Honofetchhandler on Node/Bun.- React SSR (
react-dom/server): renders a tiny HTML page for/ssrso you can simulate CPU-heavy server work. hono/jwt: signs a JWT (HS256) for/jwtso the example includes common auth-like CPU work.- Knitting (
@vixeny/knitting): runs selected transforms in a worker pool (threads). This is the core “offload expensive work” technique the example demonstrates.
Install
Section titled “Install”deno add --npm jsr:@vixeny/knittingdeno add npm:hono npm:@hono/node-server npm:react npm:react-dom npm:zodnpx jsr add @vixeny/knittingnpm i hono @hono/node-server react react-dom zod# pnpm 10.9+pnpm add jsr:@vixeny/knitting
# fallback (older pnpm)pnpm dlx jsr add @vixeny/knitting
pnpm add hono @hono/node-server react react-dom zod# yarn 4.9+yarn add jsr:@vixeny/knitting
# fallback (older yarn)yarn dlx jsr add @vixeny/knitting
yarn add hono @hono/node-server react react-dom zodbunx jsr add @vixeny/knittingbun add hono @hono/node-server react react-dom zodThe JWT route uses hono/jwt and signs with HS256.
Set JWT_SECRET in production.
Deno setup (TSX + npm)
Section titled “Deno setup (TSX + npm)”This example imports TSX and npm packages. For Deno, keep a root deno.json
like this:
{ "nodeModulesDir": "auto", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" }}Without this, TSX files can fail with:
Uncaught SyntaxError: Unexpected token '<'JWT_SECRET="replace-me" bun src/hono_knitting.tsJWT_SECRET="replace-me" deno run -A src/hono_knitting.tsRoute quick checks
Section titled “Route quick checks”# Pingcurl -s http://localhost:3000/ping
# SSR (returns HTML)curl -s http://localhost:3000/ssr \ -H 'content-type: application/json' \ -d '{"name":"Ari","plan":"pro","bio":"Building on Knitting","projects":17}'
# JWT (returns JSON string with token)curl -s http://localhost:3000/jwt \ -H 'content-type: application/json' \ -d '{"user":{"id":"u_42","email":"ari@example.com","role":"admin"},"ttlSec":900}'Performance notes (how to talk about it correctly)
Section titled “Performance notes (how to talk about it correctly)”What the numbers say (hono_only → hono+knitting)
Section titled “What the numbers say (hono_only → hono+knitting)”Throughput (RPS)
Section titled “Throughput (RPS)”| Route | Hono only | Hono + Knitting | Delta |
|---|---|---|---|
/ping | 2998 | 5471 | +82% |
/ssr | 2998 | 5421 | +81% |
/jwt | 2916 | 5454 | +87% |
So: roughly ~1.8× throughput across the board. Offloading the heavy work frees the main thread, so even the “cheap” routes handle more requests per second when Knitting is in play.
Latency improvements (lower is better)
Section titled “Latency improvements (lower is better)”| Percentile | Hono only | Hono + Knitting | Delta |
|---|---|---|---|
p50 | 16.32ms | 8.84ms | -46% |
p99 | 27.05ms | 14.52ms | -46% |
p99.9 | 71.01ms | 64.59ms | -9% |
p99.99 | 128.70ms | 99.44ms | -23% |
slowest | 129.25ms | 99.90ms | -23% |
| Percentile | Hono only | Hono + Knitting | Delta |
|---|---|---|---|
p50 | 16.32ms | 8.71ms | -47% |
p99 | 27.32ms | 17.35ms | -36% |
p99.9 | 71.96ms | 63.97ms | -11% |
p99.99 | 128.61ms | 98.75ms | -23% |
slowest | 129.31ms | 106.73ms | -17% |
| Percentile | Hono only | Hono + Knitting | Delta |
|---|---|---|---|
p50 | 16.47ms | 8.71ms | -47% |
p99 | 34.49ms | 15.91ms | -54% |
p99.9 | 114.95ms | 59.48ms | -48% |
p99.99 | 121.47ms | 101.07ms | -17% |
slowest | 161.28ms | 138.48ms | -14% |
Headline: the median and p99 basically halve, and the JWT tail (p99.9) improves dramatically.
Why this pattern matters
Section titled “Why this pattern matters”pingstays cheap and synchronous.- You can benchmark workers vs host-only with the same route behavior.
- In Knitting mode, the JWT task returns stringified JSON to reduce structured-clone overhead.
- Heavy route logic stays in one shared file, keeping both server entrypoints small.
import { serve } from "@hono/node-server";import { createPool } from "@vixeny/knitting";import { Hono } from "hono";import { issueJwt } from "./hono_components_jwt.ts";import { renderSsrPage } from "./hono_componets_ssr.tsx";
const handlers = createPool({})({ issueJwt, renderSsrPage,});
async function main() { const app = new Hono();
app.get("/ping", (c) => { return c.json({ ok: true, pong: true, runtime: process.release?.name ?? "unknown", ts: new Date().toISOString(), }); });
app.post("/ssr", async (c) => { const html = await handlers.call.renderSsrPage(c.req.arrayBuffer()); return c.html(html); });
app.post("/jwt", async (c) => {
const responseJson = await handlers.call.issueJwt(c.req.arrayBuffer());
return c.body(responseJson ?? "Bad request", responseJson ? 200 : 400, { "content-type": "application/json; charset=utf-8", }); });
const server = serve({ fetch: app.fetch, port: 3000 }, (info) => { console.log("GET /ping"); console.log("POST /ssr body: { name?, plan?, bio?, projects? }"); console.log("POST /jwt body: { user: { id, email?, role? }, ttlSec? }"); });
const close = () => { // IMPORTANT TO CLOSE CONNECTION handlers.shutdown(); server.close(); };
process.on("SIGINT", close); process.on("SIGTERM", close);}
main().catch((error) => { console.error(error); process.exitCode = 1;});import React from "react";import { renderToString } from "react-dom/server";import { task } from "@vixeny/knitting";import { z } from "zod";
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
type SsrInput = { name: string; plan: "free" | "pro"; bio: string; projects: number;};
function UserCard({ user }: { user: SsrInput & { updatedAt: string } }) { return ( <html lang="en"> <head> <meta charSet="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{`${user.name} - SSR Card`}</title> <style> {` body { margin: 0; font-family: ui-sans-serif, system-ui, sans-serif; background: #f7f8fa; color: #111827; } main { min-height: 100vh; display: grid; place-items: center; padding: 24px; } article { width: min(680px, 100%); background: #fff; border: 1px solid #e5e7eb; border-radius: 16px; padding: 20px; } h1 { margin: 0 0 8px; font-size: 1.4rem; } p { margin: 0 0 10px; line-height: 1.45; } .meta { color: #4b5563; font-size: 0.92rem; display: flex; gap: 12px; flex-wrap: wrap; } .pill { display: inline-block; background: #eef2ff; color: #4338ca; border-radius: 999px; padding: 4px 10px; font-weight: 600; } `} </style> </head> <body> <main> <article> <h1>{user.name}</h1> <p>{user.bio}</p> <div className="meta"> <span className="pill">{user.plan.toUpperCase()} plan</span> <span>{user.projects.toLocaleString()} projects</span> <span>Rendered at {user.updatedAt}</span> </div> </article> </main> </body> </html> );}
const ParsedJsonObjectSchema = z.string().transform((raw, ctx) => { try { const parsed = JSON.parse(raw) as unknown; if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; } return parsed as Record<string, unknown>; } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; }});
const RawSsrInputSchema = z.object({ name: z.preprocess((value) => { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }, z.string().optional()), plan: z.preprocess( (value) => (value === "free" || value === "pro" ? value : undefined), z.enum(["free", "pro"]).optional(), ), bio: z.preprocess((value) => { if (typeof value !== "string") return undefined; const normalized = value.trim(); return normalized.length > 0 ? normalized : undefined; }, z.string().optional()), projects: z.preprocess((value) => { const numberValue = Number(value); if (!Number.isFinite(numberValue)) return undefined; return Math.max(0, Math.min(100_000, Math.floor(numberValue))); }, z.number().int().optional()),});
const SsrInputSchema = RawSsrInputSchema.transform( (value): SsrInput => ({ name: value.name ?? "Anonymous", plan: value.plan ?? "free", bio: value.bio ?? "No bio yet.", projects: value.projects ?? 0, }),);
export function renderSsrPageHost(rawPayload: ArrayBuffer): string { let decodedPayload = ""; try { decodedPayload = utf8Decoder.decode(rawPayload); } catch { decodedPayload = ""; }
const parsed = ParsedJsonObjectSchema.safeParse(decodedPayload); const user: SsrInput = SsrInputSchema.parse( parsed.success ? parsed.data : {}, ); const html = renderToString( <UserCard user={{ ...user, updatedAt: new Date().toISOString() }} />, );
return `<!doctype html>${html}`;}
export const renderSsrPage = task<ArrayBuffer, string>({ f: renderSsrPageHost,});import { sign } from "hono/jwt";import { task } from "@vixeny/knitting";import { z } from "zod";
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
const ParsedJsonObjectSchema = z.string().transform((raw, ctx) => { try { const parsed = JSON.parse(raw) as unknown; if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; } return parsed; } catch { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "payload: expected JSON object", }); return z.NEVER; }});
const JwtUserSchema = z.object({ id: z.string().min(1), email: z.string().email().optional(), role: z.string().min(1).optional(),});
const TtlSecSchema = z.preprocess((value) => { const n = Number(value); if (!Number.isFinite(n)) return 900; return Math.max(30, Math.min(86_400, Math.floor(n)));}, z.number().int());
const JwtPayloadSchema = z.object({ user: JwtUserSchema, ttlSec: TtlSecSchema.optional().default(900),});
export async function issueJwtHost( rawPayload: ArrayBuffer,): Promise<string | null> { let decodedPayload: string; try { decodedPayload = utf8Decoder.decode(rawPayload); } catch { return null; }
const parsedResult = ParsedJsonObjectSchema.safeParse(decodedPayload); if (!parsedResult.success) { return null; }
const payloadResult = JwtPayloadSchema.safeParse(parsedResult.data); if (!payloadResult.success) { return null; }
const { user, ttlSec } = payloadResult.data; const now = Math.floor(Date.now() / 1000); const exp = now + ttlSec;
const token = await sign( { sub: user.id, email: user.email, role: user.role ?? "member", iat: now, exp, }, process.env.secret ?? "hello", );
return JSON.stringify({ ok: true, token, sub: user.id, exp, });}
export const issueJwt = task<ArrayBuffer, string | null>({ f: issueJwtHost,});