Our server needs – the requirements
- rendering HTML, I like JSX for that
- authentication via cookie headers
- monitoring -> at global level (adapter) or function level, no in-between.
What is an HTML server?
An HTML server can queried in 2 ways: GETs and POSTs. Read & writes, command & queries, etc…
The inputs of an HTML request are:
- headers (cookies included)
- query parameters
- path parameters
- body (for posts), urlformencoded or form-data.
The outputs are:
- an HTML body (plain text + mime type).
- headers (cookies included).
- status code.
// src/server-first/definition/html-route.ts
import * as x from 'unhoax'
export type HttpMethod = 'GET' | 'POST'
export interface HtmlRoute {
method: HttpMethod
path: `/${string}`
params?: x.Schema<any, Record<string, string>>
query?: x.Schema<any>
body?: x.Schema<any>
}
// src/server-first/1-defining-the-server/handle-route.ts
import * as x from 'unhoax'
import { PathParameters } from '../definition/PathParameters'
import { HtmlRoute } from '../definition/html-route'
export interface HtmlHandlerInput<R extends HtmlRoute> {
headers: Headers
params: R['params'] extends x.Schema<infer Params>
? Params
: PathParameters<R['path']>
query: x.TypeOf<NonNullable<R['query']>>
body: x.TypeOf<NonNullable<R['body']>>
}
export interface HtmlHandlerOutput {
status: number
headers?: HeadersInit
body: {
// we only need something serializable to a string.
toString(): string
}
}
export type HandleRoute<R extends HtmlRoute> = (
input: HtmlHandlerInput<R>,
) => Promise<HtmlHandlerOutput>
Our first route
We can start by defining how we want to define our route:
// src/server-first/1-defining-the-server/greet-handler-1.tsx
/** @jsxImportSource hono/jsx */
import { delay } from '@/utils/delay'
import { HtmlRoute } from '../definition/html-route'
import { HandleRoute } from './handle-route'
export const greetRoute = {
method: 'GET',
path: '/hello/:name',
} as const satisfies HtmlRoute
type GreetRoute = typeof greetRoute
export const handleGreet: HandleRoute<GreetRoute> = async ({ params }) => {
await delay(150) // mimic DB access.
return {
status: 200,
body: <div style="color: blue">Hello, {params.name}</div>,
}
}
Refactoring
It looks like there’s a ton of plumbing in there: the usage is getting verbose and complicated. When stuff is optional, the builder pattern can help graciously. Here’s how I would like to use it:
export const greetHandler = HandlerBuilder
.get('/hello/:name')
.params(…, () => {}) // optional
.query(schema, () => {}) // optional
.body(…, () => {}) // optional
.handle(async () => { … }) // builds the route and its handler.
Let’s see it in action for our first route:
// src/server-first/1-defining-the-server/greet-handler-2.tsx
/** @jsxImportSource hono/jsx */
import { delay } from '@/utils/delay'
import { HandlerBuilder } from './handler-builder'
import { respondWith } from '../definition/response'
export const greetHandler = HandlerBuilder.get('/hello/:name').handleWith(
async ({ params }) => {
await delay(150) // mimic DB access.
return respondWith
.headers({ 'x-server': 'Test' })
.html(<div style="color: blue">Hello, {params.name}</div>)
},
)
Great, that looks much cleaner.
Response
utilities
Behind the scene I implemented response factories with the following API (yes I really like the builder pattern 😇):
import { response } from '@/utils/response'
respondWith.status(201).html(<div>Hello!</div>) // Response
respondWith
.status(201)
.headers({ 'X-Server': 'Meeee' })
.html(<div>Hello!</div>) // Response
// defaults to 200
respondWith.headers({ 'X-Server': 'Meeee' }).html(<div>Hello!</div>) // Response
respondWith.html(<div>Hello!</div>) // Response
// See Other = 303 redirect
respondWith.seeOther('/there/or/here')
Source Code
// src/server-first/definition/response.ts
function seeOther(location: string, headersInit?: HeadersInit) {
const headers = new Headers(headersInit)
headers.set('location', location)
return new Response(undefined, { status: 303, headers })
}
function createResponseWithBody(
status: number,
headersInit: HeadersInit,
body: StringLike,
contentType: string,
) {
const headers = new Headers(headersInit)
headers.set('content-type', contentType)
return new Response(body.toString(), { status, headers })
}
const htmlMimeType = 'text/html'
type StringLike = { toString(): string }
export const respondWith = {
/**
* 303 redirects to the given location.
*/
seeOther,
/**
* @example with html
* ```ts
* respondWith.status(200).html(<div>Hello!</div>) // Response
* ```
*
* @example with headers
* ```ts
* respondWith
* .status(200)
* .headers({ 'X-Server': 'Meeee' })
* .html(<div>Hello!</div>) // Response
* ```
*/
status: (status: number) => ({
headers: (headersInit: HeadersInit) => ({
html: (body: StringLike) =>
createResponseWithBody(status, headersInit, body, htmlMimeType),
}),
html: (body: StringLike) =>
createResponseWithBody(status, {}, body, htmlMimeType),
}),
/**
* Defaults to 200
* @example with headers
* ```ts
* respondWith
* .headers({ 'X-Server': 'Meeee' })
* .html(<div>Hello!</div>) // Response
* ```
*/
headers: (headersInit: HeadersInit) => ({
html: (body: StringLike) =>
createResponseWithBody(200, headersInit, body, htmlMimeType),
}),
/**
* Defaults to 200
* @example with headers
* ```ts
* respondWith.html(<div>Hello!</div>) // Response
* ```
*/
html: (body: StringLike) =>
createResponseWithBody(200, {}, body, htmlMimeType),
}
Unit test
Did you notice? I didn’t even need anything to start testing!
Since our handler is pure JS, it’s incredibly simple to test it:
// src/server-first/1-defining-the-server/greet-handler.spec.ts
import { describe, expect, it } from 'vitest'
import { greetHandler } from './greet-handler-2'
describe('greetHandler – simple version', () => {
it('responds with 200, a blue div & x-server header', async () => {
const result = await greetHandler.handle({
params: { name: 'Toto' },
body: undefined,
query: {},
headers: new Headers(),
})
expect(result.status).toBe(200)
expect(await result.text()).toBe(
'<div style="color: blue">Hello, Toto</div>',
)
const headers = new Headers(result.headers)
expect(headers.get('x-server')).toBe('Test')
})
})
Which outputs:
✓ server-first/1-defining-the-server/greet-handler.spec.ts (1) 514ms
✓ greetHandler – simple version (1) 514ms
✓ responds with 200, a blue div & x-server header 514ms
Defining the adapter
A server is solely a collection of route handlers exposed on a port:
// src/server-first/1-defining-the-server/server-adapter.ts
import { Handler } from './handler'
/**
* The server adapter listens straight away and resolves when listening.
*/
export type ServerAdapter = (options: {
handlers: Handler[]
port: number
}) => Promise<unknown>
Implementing the H3 server adapter
// src/server-first/1-defining-the-server/h3-adapter.ts
import {
createApp,
createRouter,
defineEventHandler,
getQuery,
readBody,
toNodeListener,
} from 'h3'
import { createServer } from 'node:http'
import { ServerAdapter } from './server-adapter'
import { Handler } from './handler'
export const createH3NodeServer: ServerAdapter = (options: {
handlers: Handler[]
port: number
}) => {
const app = createApp({ debug: true })
const router = createRouter()
// mount the router on a base path using app.use('/base', router)
app.use(router)
for (const { handle, ...route } of options.handlers) {
const routerFn = route.method === 'GET' ? router.get : router.post
routerFn.call(
router, // provide `router` to unbounded router method
route.path,
defineEventHandler(async (event) => {
return handle({
query: getQuery(event),
headers: event.headers,
body: event.headers.has('content-type')
? await readBody(event)
: undefined,
params: event.context.params ?? {},
})
}),
)
}
const server = createServer(toNodeListener(app))
return new Promise<unknown>((resolve) => {
server.listen(options.port, () => {
resolve(server)
})
})
}
Boot the server using the H3 adapter
// src/server-first/1-defining-the-server/server.ts
import { greetHandler } from './greet-handler-2'
import { createH3NodeServer } from './h3-adapter'
async function createServer() {
const port = 6600
await createH3NodeServer({
port,
handlers: [greetHandler],
})
console.info('Server listening on port', port)
}
createServer().catch(console.error)
End-to-End Testing
npx tsx ./src/server-first/1-defining-the-server/server.ts
Let’s test it:
$ curl http://localhost:6600/hello/John
<div style="color: blue">Hello, John</div>%
# ✅
All good!