Skip to content

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.
ts
// 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>
}
ts
// 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:

ts
// 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:

tsx
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:

ts
// 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 😇):

tsx
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
ts
// 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:

ts
// 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:

sh
 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:

ts
// 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

ts
// 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

ts
// 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

sh
npx tsx ./src/server-first/1-defining-the-server/server.ts

Let’s test it:

sh
$ curl http://localhost:6600/hello/John
<div style="color: blue">Hello, John</div>%
# ✅

All good!