Skip to content

Real-world journey ​

That was all very theoretical, let’s confront it to a more realistic world.

Let’s take back our grocery list and add 3 features:

  • Creating a grocery list
  • Listing grocery lists
  • Deleting a grocery list

Those 3 features will consist of a home page with a list for which each item will have an "archive" button, and a form to add a new grocery list.
In terms of handlers, that means:

  1. GET / to display the home page
  2. POST /create-list to create a new grocery list
  3. POST /archive-list/:listName to archive a grocery list – we can only use HTML form methods, thus no DELETE.

For demo purposes, I will cheat regarding the authentication and use the query parameter memberId when getting the home page.

Defining the domain ​

Here it is quite simple, I will settle for just one file:

ts
// src/server-first/7-grocery-list-project/grocery-list.ts

export type MemberId = string // FIXME: use branded type
export type ListName = string // FIXME: use branded type
export type ItemName = string // FIXME: use branded type

export interface GroceryList {
  memberId: MemberId
  name: ListName
  items: Map<ItemName, { quantity: number }>
}

export interface GroceryListRepository {
  listByMemberId: (memberId: MemberId) => Promise<GroceryList[]>
  archive: (memberId: MemberId, name: ListName) => Promise<void>
  create: (memberId: MemberId, name: ListName) => Promise<GroceryList>
}
I also wrote an in-memory repository to get us started
ts
// src/server-first/7-grocery-list-project/grocery-list-in-memory-repo.ts

import {
  GroceryList,
  GroceryListRepository,
  ListName,
  MemberId,
} from './grocery-list'

export function makeGroceryListInMemoryRepository(): GroceryListRepository {
  const store = new Map<`${MemberId} ${ListName}`, GroceryList>()

  return {
    async listByMemberId(memberId) {
      return [...store.values()].filter((list) => list.memberId === memberId)
    },
    async archive(memberId, name) {
      store.delete(`${memberId} ${name}`)
    },
    async create(memberId, name) {
      const list = store.get(`${memberId} ${name}`)
      if (list) throw new Error(`List ${name} already exists`)
      const groceryList: GroceryList = {
        memberId,
        name,
        items: new Map(),
      }
      store.set(`${memberId} ${name}`, groceryList)
      return groceryList
    },
  }
}

Home page ​

tsx
// src/server-first/7-grocery-list-project/get/home.tsx

/* @jsxImportSource hono/jsx */
import { respondWith } from '@/server-first/definition/response'
import * as cookie from 'cookie'
import * as x from 'unhoax'
import { Authenticate } from '../authenticate'
import { GroceryListForm } from '../components/GroceryListForm'
import { Html } from '../components/Html'
import { UnorderedGroceryLists } from '../components/UnorderedGroceryLists'
import { GroceryList, GroceryListRepository } from '../grocery-list'
import { HandlerBuilder } from '../handler-builder'

type Ports = {
  authenticate: Authenticate<GroceryList['memberId']>
  listGroceryLists: GroceryListRepository['listByMemberId']
}

export function makeHomeHandler(ports: Ports) {
  return HandlerBuilder.get('/')
    .query({ memberId: x.optional(x.string) }, () => {
      throw new Error('this should not happen')
    })
    .handleWith(async ({ query, cookies }) => {
      const memberId = query.memberId || (await ports.authenticate(cookies)).id
      const groceryLists = await ports.listGroceryLists(memberId)

      const authCookie = cookie.serialize('id', memberId, {
        httpOnly: true,
        sameSite: 'strict',
        maxAge: 60 * 60 * 24 * 30,
      })
      return respondWith.headers({ 'Set-Cookie': authCookie }).html(
        <Html>
          <UnorderedGroceryLists groceryLists={groceryLists} />
          <GroceryListForm action="/create-list" submitLabel="Create" />
        </Html>,
      )
    })
}

Create grocery list handler ​

tsx
// src/server-first/7-grocery-list-project/post/create-grocery-list.tsx

/* @jsxImportSource hono/jsx */
import { respondWith } from '@/server-first/definition/response'
import pipe from 'just-pipe'
import * as x from 'unhoax'
import { Authenticate } from '../authenticate'
import { Html } from '../components/Html'
import { GroceryList, GroceryListRepository } from '../grocery-list'
import { HandlerBuilder } from '../handler-builder'

type Ports = {
  authenticate: Authenticate<GroceryList['memberId']>
  createGroceryList: GroceryListRepository['create']
}

export function makeCreateGroceryListHandler(ports: Ports) {
  return HandlerBuilder.post('/create-list')
    .body({ listName: pipe(x.string, x.nonEmpty()) }, () => {
      return respondWith.status(400).html(
        <Html>
          <div>A list name is required</div>
        </Html>,
      )
    })
    .handleWith(async ({ cookies, body }) => {
      const member = await ports.authenticate(cookies)

      await ports.createGroceryList(member.id, body.listName)

      return respondWith.seeOther('/')
    })
}

Archive grocery list handler ​

tsx
// src/server-first/7-grocery-list-project/post/archive-grocery-list.tsx

/* @jsxImportSource hono/jsx */
import { respondWith } from '@/server-first/definition/response'
import { Authenticate } from '../authenticate'
import { GroceryList, GroceryListRepository } from '../grocery-list'
import { HandlerBuilder } from '../handler-builder'

type Ports = {
  authenticate: Authenticate<GroceryList['memberId']>
  archiveGroceryList: GroceryListRepository['archive']
}

export function makeArchiveGroceryListHandler(ports: Ports) {
  return HandlerBuilder.post('/archive-list/:listName').handleWith(
    async ({ cookies, params }) => {
      const member = await ports.authenticate(cookies)

      await ports.archiveGroceryList(member.id, params.listName)

      return respondWith.seeOther('/')
    },
  )
}

End-to-End Testing ​

sh
npx tsx ./src/server-first/7-grocery-list-project/server.ts

Let’s test our handlers, head to http://localhost:6600/?memberId=John

It looks like it all works like a charm 😘

Curious about the components? ​

There you go:

UnorderedGroceryLists.tsx
tsx
// src/server-first/7-grocery-list-project/components/UnorderedGroceryLists.tsx

/** @jsxImportSource hono/jsx */
import { GroceryList } from '../grocery-list'

type Props = {
  groceryLists: GroceryList[]
}
export function UnorderedGroceryLists({ groceryLists }: Props) {
  return groceryLists.length === 0 ? (
    <div>You don’t have any list yet.</div>
  ) : (
    <ul class="grocery-lists">
      {groceryLists.map((groceryList) => (
        <li>
          {groceryList.name} ({groceryList.items.size} items) &nbsp;
          <form
            method="post"
            action={`/archive-list/${groceryList.name}`}
            style="display: inline"
          >
            <button>Archive</button>
          </form>
        </li>
      ))}
    </ul>
  )
}
GroceryListForm.tsx
tsx
// src/server-first/7-grocery-list-project/components/GroceryListForm.tsx

/* @jsxImportSource hono/jsx */

type Props = {
  action: string
  values?: { listName: string }
  submitLabel: string
}

export function GroceryListForm({ action, values, submitLabel }: Props) {
  return (
    <form action={action} method="post">
      <input
        type="text"
        name="listName"
        value={values?.listName}
        placeholder="221B Bake Street"
        required
      />
      <button type="submit">{submitLabel}</button>
    </form>
  )
}
Html.tsx
tsx
// src/server-first/7-grocery-list-project/components/Html.tsx

/* @jsxImportSource hono/jsx */
import { html } from 'hono/html'
import { Child } from 'hono/jsx'

export function Html({ children }: { children: Child }) {
  return (
    <>
      {html`<!DOCTYPE html>`}
      <html lang="en">
        <head>
          <meta charset="UTF-8" />
          <meta
            name="viewport"
            content="width=device-width, initial-scale=1.0"
          />
          <title>Grocery List</title>
        </head>
        <body>{children}</body>
      </html>
    </>
  )
}