Table of Contents
- Hono Assignments — Complete Tutorial Guide
- Prerequisites
- Assignment 01: Basic To-Do API
- Assignment 02: Personal Blog
- Assignment 03: User Profile & Uploads
- Assignment 04: Task Manager with Background Tasks
- Assignment 05: Weather Dashboard
- Assignment 06: Real-time Chat
- Assignment 07: Form Handling & Templates
- Assignment 08: Webhook Receiver
- Assignment 09: Inventory Management
- Assignment 10: Secure Document Vault
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Hono Assignments — Complete Tutorial Guide
This guide walks through all ten assignments in the hono-assignments/ directory. Hono is a lightweight web framework for JavaScript/TypeScript that is built on Web Standard APIs (the same Request, Response, and fetch objects used in browsers). It runs on Node.js, Cloudflare Workers, Deno, and Bun. These assignments target the Node.js runtime.
Prerequisites
Node.js Version
Node.js 18 or later is required. Version 18 introduced the native fetch API and --watch mode for auto-reload.
node --version
npm and package.json
Each assignment directory contains a package.json file that lists the project's dependencies. Installing them is always the first step:
npm install
This creates a node_modules/ directory with all required packages. Never commit node_modules/ to version control.
Running in Development Mode
Each assignment's package.json defines a dev script that starts the server with auto-reload:
npm run dev
This is equivalent to node --watch index.js.
Tools for Testing
Hono does not generate Swagger documentation. Use curl or Postman. Some assignments serve a web page — for those, a browser is sufficient.
Assignment 01: Basic To-Do API
Location: hono-assignments/01-basic-todo-api/
What You Will Learn
- Creating a Hono application and defining routes
- The Hono
Contextobject (c) - Accessing URL parameters and parsing JSON bodies
- Returning JSON responses
- Running Hono on Node.js with
@hono/node-server
Key Concepts
Hono is built on Web Standards. Its API uses Request and Response objects from the Fetch API specification — the same objects that exist in browsers. This makes Hono portable across JavaScript runtimes (Node.js, Cloudflare Workers, Deno, Bun) with minimal adaptation.
The Context object (c) is the central object in a Hono route handler. It provides:
c.req— the incoming requestc.req.param('name')— URL path parametersc.req.json()— parse the request body as JSON (async)c.req.query('key')— query string parametersc.json(data, status)— return a JSON responsec.text(string, status)— return a plain text response
@hono/node-server: Hono's fetch handler does not directly understand Node.js's HTTP server interface. The serve({ fetch: app.fetch }) adapter bridges the two: it translates Node.js HTTP requests into Web Standard Request objects, calls app.fetch, and writes the Response back to the Node.js response.
Asynchronous by default: Unlike Flask (synchronous by default), all Hono route handlers are async functions. JSON parsing, file reads, and HTTP calls all require await.
Setup
npm install
Running the Server
npm run dev
Project Files
index.js
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
let todos = []
let nextId = 1
app.post('/todos', async (c) => {
const body = await c.req.json()
const todo = { id: nextId++, title: body.title, completed: false }
todos.push(todo)
return c.json(todo, 201)
})
app.get('/todos/:id', (c) => {
const id = parseInt(c.req.param('id'))
const todo = todos.find(t => t.id === id)
if (!todo) return c.json({ error: 'Not found' }, 404)
return c.json(todo)
})
serve({ fetch: app.fetch, port: 3000 })
URL parameters use the :name syntax (not {name} as in FastAPI or <name> as in Flask). c.req.param('id') returns a string; you must convert it with parseInt().
Testing Step by Step
# Create
curl -X POST http://localhost:3000/todos \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries"}'
# List all
curl http://localhost:3000/todos
# Update
curl -X PUT http://localhost:3000/todos/1 \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries and milk"}'
# Delete
curl -X DELETE http://localhost:3000/todos/1
Assignment 02: Personal Blog
Location: hono-assignments/02-personal-blog/
What You Will Learn
- Persistent storage using
better-sqlite3(synchronous SQLite for Node.js) - Input validation with Zod
- The
@hono/zod-validatormiddleware - Handling unique constraint violations from SQLite
Key Concepts
better-sqlite3 is a synchronous SQLite driver for Node.js. Unlike most Node.js database libraries, its API is not promise-based. Queries execute immediately and return results directly, without await:
const posts = db.prepare('SELECT * FROM posts').all()
const post = db.prepare('SELECT * FROM posts WHERE slug = ?').get(slug)
This simplicity makes it ideal for learning. For production use, an asynchronous driver (like @libsql/client) avoids blocking the event loop on database I/O.
Zod is a TypeScript-first schema declaration and validation library. You define a schema, and Zod validates that input conforms to it:
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(1).max(200),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
content: z.string().min(1)
})
If validation fails, Zod produces detailed error messages explaining exactly which fields are wrong and why.
@hono/zod-validator integrates Zod with Hono as middleware. When applied to a route, it runs before the handler. Invalid requests are rejected automatically with a 400 Bad Request response. Valid requests make the parsed data available via c.req.valid('json').
Native dependencies: better-sqlite3 contains compiled C++ code. If installation fails on your machine, you may need a C/C++ build toolchain. On macOS, install Xcode Command Line Tools: xcode-select --install.
Setup
npm install
Running the Server
npm run dev
Project Files
db.js
import Database from 'better-sqlite3'
const db = new Database('./blog.db')
db.exec(`
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
)
`)
export default db
CREATE TABLE IF NOT EXISTS is idempotent — it only creates the table if it does not already exist, so this runs safely on every server start.
index.js
The POST handler uses zValidator middleware:
app.post('/posts', zValidator('json', postSchema), (c) => {
const { title, slug, content } = c.req.valid('json')
try {
const stmt = db.prepare('INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)')
const result = stmt.run(title, slug, content)
return c.json({ id: result.lastInsertRowid, title, slug, content }, 201)
} catch (err) {
if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return c.json({ error: 'Slug already exists' }, 409)
}
throw err
}
})
The ? placeholders in the SQL use parameterised queries, which prevent SQL injection.
Testing Step by Step
# Create a post
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title": "First Post", "slug": "first-post", "content": "Hello world!"}'
# Retrieve by slug
curl http://localhost:3000/posts/first-post
# Try an invalid slug (contains spaces) — Zod rejects it before the handler runs
curl -X POST http://localhost:3000/posts \
-H "Content-Type: application/json" \
-d '{"title": "Bad Post", "slug": "bad slug", "content": "..."}'
# Expect 400 Bad Request
Assignment 03: User Profile & Uploads
Location: hono-assignments/03-user-profile-uploads/
What You Will Learn
- Parsing multipart form data in Hono
- Working with the Web API
Fileobject - Writing files to disk using Node.js
fsmodule - Serving static files with Hono's
serveStaticmiddleware
Key Concepts
c.req.parseBody() parses a multipart or URL-encoded form body. It returns a plain object where each field is either a string (for text fields) or a File object (for file inputs).
The File object is a Web Standard API. It represents a file from the browser perspective. To write it to disk in Node.js:
const file = body['file'] // Web API File object
const buffer = await file.arrayBuffer() // read as ArrayBuffer
const nodeBuffer = Buffer.from(buffer) // convert to Node.js Buffer
await fs.writeFile(filePath, nodeBuffer) // write to disk
This conversion chain exists because Node.js's fs module predates the Web Standard APIs. The Web Standard ArrayBuffer must be converted to a Node.js Buffer before fs.writeFile can use it.
serveStatic from hono/node serves files from a local directory over HTTP:
app.use('/static/*', serveStatic({ root: './' }))
Files in the ./static/ directory are accessible at /static/filename. The root is relative to the current working directory (where you run npm run dev).
Setup
npm install
Testing Step by Step
# Get a profile
curl http://localhost:3000/profiles/john_doe
# Upload a profile image (using a real image file)
curl -X POST \
-F "file=@./photo.jpg" \
http://localhost:3000/profiles/john_doe/upload-image
# Update bio
curl -X PATCH \
-F "bio=JavaScript developer" \
http://localhost:3000/profiles/john_doe
# View the uploaded image in a browser
# http://localhost:3000/static/uploads/john_doe.jpg
Assignment 04: Task Manager with Background Tasks
Location: hono-assignments/04-task-manager-bg-tasks/
What You Will Learn
- The JavaScript event loop and its implications for background work
- How to fire-and-forget an async function
- Why Node.js does not need threads for background I/O
Key Concepts
JavaScript's event loop is a single-threaded concurrency model. At any moment, only one piece of JavaScript is executing. However, when JavaScript performs I/O (a network request, a timer, a file read), it registers a callback and yields control back to the event loop. Other work can proceed during the I/O wait.
Fire-and-forget is the pattern of starting an async function without awaiting its result. In JavaScript:
// This AWAITS the function — the route handler blocks until sendEmail finishes
await sendTaskNotification(email, title)
return c.json(result)
// This does NOT await — sendEmail starts, the route handler continues immediately
sendTaskNotification(email, title) // no await
return c.json(result)
When sendTaskNotification hits its first await (the setTimeout in this assignment), it suspends and yields to the event loop. The route handler has already returned its response at this point, so the HTTP client received its response. The notification function resumes after the timeout.
Unlike Python threads, there is no true concurrency here — the setTimeout callback and the next HTTP request take turns on the same thread. For CPU-intensive background tasks, you would need Worker Threads.
emailUtils.js
export async function sendTaskNotification(email, taskTitle) {
console.log('Starting background task...')
await new Promise(resolve => setTimeout(resolve, 5000)) // simulate 5s email send
console.log(`Notification SENT to ${email}: task '${taskTitle}' completed.`)
}
new Promise(resolve => setTimeout(resolve, 5000)) creates a promise that resolves after 5 seconds. This is the standard pattern for asynchronous delays in JavaScript (there is no time.sleep equivalent).
Setup
npm install
Testing Step by Step
- Start the server.
- Create a task:
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title": "Deploy", "user_email": "dev@example.com"}' - Note the task ID.
- Complete it:
curl -X POST http://localhost:3000/tasks/1/complete - Observe the terminal — the response arrives instantly, then five seconds later you see the notification log.
Assignment 05: Weather Dashboard
Location: hono-assignments/05-weather-dashboard/
What You Will Learn
- Using the native
fetchAPI to call external services - Query parameter validation and type coercion with Zod
- Building an in-memory TTL cache with a JavaScript
Map
Key Concepts
fetch is built into Node.js 18+. Unlike Python, no extra library is needed for HTTP calls. The API is:
const response = await fetch(url)
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const data = await response.json()
response.ok is true for status codes 200–299. response.json() is itself async — it reads the response body and parses it.
z.coerce.number() in Zod coerces the input to a number before validation. Query parameters in HTTP are always strings (?lat=51.5 arrives as the string "51.5"). Without coercion, z.number() would reject the string. With z.coerce.number(), Zod calls Number("51.5") first, then validates the result.
JavaScript Map is an ordered collection of key-value pairs that accepts any type as a key (unlike plain objects, which coerce keys to strings). The cache uses a string key "round(lat)_round(lon)".
Setup
npm install
Project Files
weatherService.js
export class WeatherService {
async getWeather(lat, lon) {
const url = new URL('https://api.open-meteo.com/v1/forecast')
url.searchParams.set('latitude', lat)
url.searchParams.set('longitude', lon)
url.searchParams.set('current_weather', 'true')
const response = await fetch(url.toString())
if (!response.ok) throw new Error(`Weather API error: ${response.status}`)
return response.json()
}
}
index.js (cache logic)
const weatherCache = new Map()
// Check cache
const cacheKey = `${Math.round(lat * 10) / 10}_${Math.round(lon * 10) / 10}`
const cached = weatherCache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < 15 * 60 * 1000) {
return c.json({ ...cached.data, cached: true })
}
// Fetch and cache
const data = await weatherService.getWeather(lat, lon)
weatherCache.set(cacheKey, { data, timestamp: Date.now() })
return c.json({ ...data, cached: false })
Date.now() returns milliseconds since the Unix epoch. The TTL comparison uses 15 * 60 * 1000 (15 minutes in milliseconds).
Testing Step by Step
# First call — real API
curl "http://localhost:3000/weather?city_name=London&lat=51.5&lon=-0.12"
# Second call — from cache
curl "http://localhost:3000/weather?city_name=London&lat=51.5&lon=-0.12"
# Check "cached": true in the response
Assignment 06: Real-time Chat
Location: hono-assignments/06-real-time-chat/
What You Will Learn
- WebSockets in Hono using
@hono/node-ws - The HTTP-to-WebSocket upgrade handshake
- Managing a collection of active connections
- Broadcasting to all connected clients
Key Concepts
@hono/node-ws provides WebSocket support for Hono on Node.js. Unlike Flask-SocketIO (which uses the Socket.IO protocol), this adapter uses raw WebSockets. The JavaScript client on the page uses new WebSocket(url) directly — no extra library required.
upgradeWebSocket is the Hono helper that handles the HTTP → WebSocket upgrade handshake. It returns an object with lifecycle hook functions:
import { createNodeWebSocket } from '@hono/node-ws'
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
app.get('/ws', upgradeWebSocket((c) => ({
onOpen(event, ws) { /* client connected */ },
onMessage(event, ws) { /* message received; event.data is the text */ },
onClose(event, ws) { /* client disconnected */ }
})))
injectWebSocket(server) must be called with the Node.js HTTP server instance to enable WebSocket upgrades on that server.
Broadcasting: There is no built-in broadcast function. Maintain a Map of active connections and iterate over it:
const activeConnections = new Map()
function broadcast(message, senderId) {
for (const [clientId, ws] of activeConnections) {
if (clientId !== senderId) {
ws.send(message)
}
}
}
Setup
npm install
Testing Step by Step
- Start the server.
- Open
http://localhost:3000/in two browser tabs. - Note the different Client IDs.
- Type in one tab, send. The message appears in the other tab.
- Close one tab. The other shows a disconnection message.
Assignment 07: Form Handling & Templates
Location: hono-assignments/07-forms-templates/
What You Will Learn
- Server-rendered HTML in Hono without a template engine
- Form submission handling with
c.req.parseBody() - Server-side validation in JavaScript
- The trade-off between template engines and inline HTML strings
Key Concepts
Hono does not have a built-in template engine. HTML is generated as JavaScript template literals — multi-line strings delimited by backticks that support ${expression} interpolation. This approach requires no configuration, but large templates become unwieldy.
const page = `
<!DOCTYPE html>
<html>
<body>
<h1>Hello, ${name}!</h1>
${errors.name ? `<p class="error">${errors.name}</p>` : ''}
</body>
</html>
`
return c.html(page)
c.html(string) returns a response with Content-Type: text/html.
Security note — HTML injection: When inserting user input into HTML strings, you must escape <, >, &, and ". In this assignment, the input is short-lived (not stored persistently), so the risk is limited. In a production application, use a proper template engine that escapes by default, or a function like:
function escapeHtml(str) {
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
}
c.req.parseBody() works for both multipart/form-data and application/x-www-form-urlencoded form submissions. HTML forms default to application/x-www-form-urlencoded. Both formats are supported by the same call.
Setup
npm install
Testing Step by Step
- Open
http://localhost:3000/. - Submit a valid name and email. Observe the success page.
- Submit with a single-character name. Observe the inline error and the retained email value.
- Submit with
invalid-email. Observe the email error and the retained name value.
Assignment 08: Webhook Receiver
Location: hono-assignments/08-webhook-receiver/
What You Will Learn
- Reading the raw request body as a string in Hono
- HMAC signature verification with Node.js's
cryptomodule - Constant-time comparison with
crypto.timingSafeEqual - Why JSON parsing must happen after signature verification
Key Concepts
See Assignment 08 in the FastAPI guide for the explanation of webhooks, HMAC, and timing attacks.
Node.js crypto module provides cryptographic primitives. HMAC computation:
import crypto from 'node:crypto'
function verifySignature(payload, secret, signature) {
const expected = crypto.createHmac('sha256', secret)
.update(payload)
.digest('hex')
// timingSafeEqual requires Buffers of equal length
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(signature, 'hex')
if (a.length !== b.length) return false
return crypto.timingSafeEqual(a, b)
}
c.req.text() reads the request body as a UTF-8 string — the raw bytes without any parsing. This is used for HMAC verification. After a successful check, the payload is parsed manually: JSON.parse(rawBody).
crypto.timingSafeEqual requires two Buffer objects of equal length. Always check the length before calling it, because if the provided signature has a different length from the expected signature, timingSafeEqual would throw an error.
Setup
npm install
Testing Step by Step
Create a file test_webhook.js:
import crypto from 'node:crypto'
const secret = "super-secret-webhook-key-123"
const payload = JSON.stringify({ event: "payment.success", data: { id: 123 } })
const signature = crypto.createHmac('sha256', secret).update(payload).digest('hex')
const response = await fetch("http://localhost:3000/webhook", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-webhook-signature": signature
},
body: payload
})
console.log(await response.json())
Run with: node --input-type=module < test_webhook.js or save as test_webhook.mjs and run node test_webhook.mjs.
Assignment 09: Inventory Management
Location: hono-assignments/09-inventory-management/
What You Will Learn
- Hono's sub-application pattern for code organisation
- Query parameter validation and coercion with Zod
- API versioning using mounted sub-applications
- Pagination with metadata
Key Concepts
Hono sub-applications: A new Hono instance is a sub-application. It can be mounted on a parent with app.route('/prefix', subApp). Routes defined in subApp are accessible at /prefix/route.
// src/v1/router.js
const v1Router = new Hono()
v1Router.get('/items', handler)
export { v1Router }
// index.js
import { v1Router } from './src/v1/router.js'
app.route('/v1', v1Router)
This is equivalent to Flask Blueprints and FastAPI's APIRouter.
z.coerce.number().int().min(0) in the query schema coerces string query parameters to integers and validates they are non-negative. This eliminates a category of bugs where negative offsets or non-numeric limits would cause unexpected array slicing behaviour.
Setup
npm install
Testing Step by Step
# V1: plain array
curl "http://localhost:3000/v1/items?offset=0&limit=5"
# V2: structured response
curl "http://localhost:3000/v2/items?offset=0&limit=5"
# V2: category filter
curl "http://localhost:3000/v2/items?category=Electronics&offset=0&limit=10"
Note the structural difference: V1 returns [{...}, {...}], V2 returns {"items": [...], "total": N, "has_more": true/false}.
Assignment 10: Secure Document Vault
Location: hono-assignments/10-secure-document-vault/
What You Will Learn
- JWT authentication using Hono's built-in
hono/jwtmiddleware - Password hashing with
bcryptin Node.js - Custom middleware factories for scope enforcement
- Passing user data through Hono's context system
Key Concepts
See Assignment 10 in the FastAPI guide for the concepts of JWT, password hashing, OAuth2 scopes, and RBAC. The Hono-specific implementation patterns are:
hono/jwt provides two functions:
sign(payload, secret)— creates a JWTjwt({ secret })— middleware that verifies a Bearer token and stores the decoded payload in the context
import { sign, jwt } from 'hono/jwt'
// Middleware: verifies token and stores payload in context
const requireAuth = jwt({ secret: SECRET })
// Route handler: read the decoded payload
app.get('/protected', requireAuth, (c) => {
const payload = c.get('jwtPayload')
return c.json({ user: payload.sub })
})
c.set and c.get: Hono's context has a typed key-value store for passing data between middleware functions and handlers. c.set('user', userObject) stores the user in the context; c.get('user') retrieves it downstream.
Middleware factory for scopes:
function requireScopes(requiredScopes) {
return async (c, next) => {
const payload = c.get('jwtPayload')
const tokenScopes = payload.scopes || []
const hasAll = requiredScopes.every(s => tokenScopes.includes(s))
if (!hasAll) return c.json({ error: 'Insufficient permissions' }, 403)
// fetch user and attach to context
const user = users.find(u => u.username === payload.sub)
c.set('user', user)
await next()
}
}
Chaining middleware on a route:
app.get('/documents', requireAuth, requireScopes(['vault:read']), (c) => {
const user = c.get('user')
// filter documents by clearance level...
})
bcrypt: The bcrypt package provides password hashing. Like better-sqlite3, it is a native Node.js module requiring a C++ build toolchain.
import bcrypt from 'bcrypt'
const hash = await bcrypt.hash('password123', 10) // 10 = cost factor
const valid = await bcrypt.compare('password123', hash) // true
The cost factor (10) controls how many rounds of hashing are performed. Higher values make hashing slower, increasing resistance to brute-force attacks.
Setup
npm install
Testing Step by Step
# Authenticate as alice
curl -X POST http://localhost:3000/token \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "alice123"}'
# Store the token, then:
TOKEN="<paste_token_here>"
# Read documents (alice can see clearance ≤ 3)
curl http://localhost:3000/documents \
-H "Authorization: Bearer $TOKEN"
# Try to create a document above alice's clearance
curl -X POST http://localhost:3000/documents \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Top Secret", "content": "...", "secret_level": 4}'
# Expect 403
# Try admin endpoint as alice
curl http://localhost:3000/admin/users \
-H "Authorization: Bearer $TOKEN"
# Expect 403 (alice lacks 'admin' scope)
Repeat with admin / admin123 to verify full access.