Hono, EJS & middleware

Základní Node.js HTTP server není vhodný na komplexnější aplikace. Pokud bychom chtěli zobrazovat různý obsah na základě URL a HTTP metody, povede to ke špatně čitelnému a často se opakujícímu kódu:

import http from 'http'
import fs from 'fs/promises'

const port = 3000

const server = http.createServer(async (req, res) => {
  try {
    if (req.method === 'GET' && req.url === '/') {
      const response = await fs.readFile('index.html')
      res.statusCode = 200
      res.setHeader('Content-Type', 'text/html')
      res.end(response)
    } else if (req.method === 'POST' && req.url === '/add') {
      // ???
    } else {
      try {
        const response = await fs.readFile('public' + req.url)
        res.statusCode = 200
        // res.setHeader('Content-Type', '???')
        res.end(response)
      } catch {
        res.statusCode = 404 // Not found
        res.setHeader('Content-Type', 'text/plain')
        res.end('404 - Not found')
      }
    }
  } catch (e) {
    res.statusCode = 500
    res.setHeader('Content-Type', 'text/plain')
    res.end(e.message)
  }
})

server.listen(port, () => {
  console.log(`Server listening at http://localhost:${port}`)
})

Možností je samozřejmě refaktorování do pomocných funkcí, ale vše bychom si museli napsat sami. Mnohem jednodušším řešením je využít knihovnu express https://www.npmjs.com/package/express. Express je jedna z nejběžnějších knihoven pro psaní serverů.

Hono

Hono je knihovna, která nám usnadní práci při psaní serverů:

npm install hono

Hono samo o sobě neumí vytvořit server pod Node.js, a tak je potřeba doinstalovat dodatečnou knihovnu:

npm install @hono/node-server
import { Hono } from "hono"
import { serve } from "@hono/node-server"

const app = new Hono()

// get – HTTP metoda GET (může být i .post, .patch, .put, .delete nebo univerzální .use)
// / – URL, pro kterou se zavolá callback
// c – kontext, ve kterém Hono drží request (c.req), response (c.res) a pomocné funkce.
app.get("/", (c) => {
  // c.html funkce vytvoří HTML odpověď
  return c.html("<h1>Hello, World!</h1>")
})

app.get("/json", (c) => {
  // c.json funkce vytvoří JSON odpověď
  return c.json({ firstName: "Franta", lastName: "Sádlo" })
})

// :name reprezentuje dynamický parametr
// /hello/Franta – projde
// /hello/Lojza – projde
// /hello – neprojde
// /hello/Pepa/Zdepa – neprojde
app.get("/hello/:name", (c) => {
  const name = c.req.param("name")

  return c.html(`<h1>Hello, ${name}!</h1>`)
})

// Univerzální handler, který zachytí všechny požadavky,
// které nezachytily handlery výše, a zobrazí 404
app.use((c) => {
  console.log(`Not found: ${c.req.path}`)
  return c.notFound()
})

serve(app, (info) => {
  console.log(
    `Server listening at http://localhost:${info.port}`
  )
})

Pokud chceme vykreslovat dynamické HTML (server vrací personalizovanou odpověď například na základě stavu databáze), je vhodné použít šablonovací knihovnu (něco jako staré PHP, které začalo jako šablonovací jazyk). Na NPM najdeme šablonovacích knihoven spousty a zde si ukážeme EJS: https://www.npmjs.com/package/ejs.

npm install ejs

index.js

import { Hono } from "hono"
import { serve } from "@hono/node-server"
import { renderFile } from "ejs"

const app = new Hono()

app.get("/", async (c) => {
  const index = await renderFile("views/index.html")

  return c.html(index)
})

serve(app, (info) => {
  console.log(
    `App started on http://localhost:${info.port}`
  )
})

views/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h1>Hello, World!</h1>
  </body>
</html>

Jako druhý parametr funkce renderFile můžeme poslat objekt, k jehož hodnotám bude mít EJS následně přístup.

index.js

let id = 1

const todos = [
  {
    id: id++,
    text: "Vzít si dovolenou",
    done: false,
  },
  {
    id: id++,
    text: "Koupit Elden Ring",
    done: false,
  },
]

app.get("/", async (c) => {
  const index = await renderFile("views/index.html", {
    title: "Todos!",
    todos,
  })

  return c.html(index)
})

views/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title><%= title %></title>
  </head>
  <body>
    <h1><%= title %></h1>

    <table>
      <tr>
        <th>Text</th>
        <th>Hotovo</th>
      </tr>

      <% for (const todo of todos) { %>
      <tr>
        <td><%= todo.text %></td>
        <td><%= todo.done ? 'ano' : 'ne' %></td>
      </tr>
      <% } %>
    </table>
  </body>
</html>

EJS interpretuje vše, co najde uvnitř speciálních tagů:

  • <%= value %> bezpečně vypíše hodnotu proměnné value (odstraní HTML nebezpečné znaky jako „většítka“ a „menšítka“ – chrání tak proti útokům typu XSS).
  • <%- value %> nebezpečně vypíše hodnotu proměnné value. Je možné tedy vkládat HTML. Nikdy nepoužívejte pro proměnné, jež obsahují uživatelský obsah.
  • <% příkaz %> používá se pro JavaScriptové příkazy jako for nebo if.

Middleware

Jedná se o funkce, které jsou spuštěné při každém požadavku na server a mohou pracovat s objekty c.req a c.res. Pomocí druhého parametru – funkce next – také ovládají, kdy je požadavek předán dalšímu middlewaru nebo handleru.

app.use(async (c, next) => {
  // Tento middleware při každém requestu vypíše do konzole metodu a cestu requestu
  console.log(c.req.method, c.req.path)

  // Následně předá exekuci dalšímu middleware/handleru
  await next()

  // Poté co nějaký následující middleware/handler vrátí odpověď, vypíšeme status
  console.log(c.res.status)
})

app.use(async (c, next) => {
  // Pokud uživatel nemá Authorization hlavičku
  if (!c.req.header("Authorization")) {
    // Nastavíme status na 401
    c.status(401)
    // A vrátíme hlášku Unauthorized
    return c.html("<h1>Unauthorized</h1>")
  }

  await next()
})

Middleware je skvělý nástroj pro modifikaci req & res objektů nebo pro univerzální požadavky (např. statické soubory):

// Tento middleware nepoužívejte, existuje lepší řešení
app.use(async (c, next) => {
  if (c.req.path.startsWith("/public")) {
    // Pokud URL začíná na /public,
    // odešli obsah souboru z public adresáře a nepokračuj dál
    const data = await fs.readFile(
      path.join(process.cwd(), c.req.path)
    )

    return c.newResponse(data)
  } else {
    // Pokud URL nezačíná na /public, jedná se o běžný dotaz, a tak ho předáme dál
    await next()
  }
})

Middleware jsou spouštěny vždy v pořadí, v jakém jsou definovány:

app.use(...) // první
app.use(...) // druhý
app.use(...) // třetí

Middleware je možné omezit na určitou URL nebo metodu:

app.use('/hello', (c, next) => {}) // libovolná metoda na URL /hello
app.get((c, next) => {}) // pouze GET na libovolné URL

Hono také nabízí několik zabudovaných middlewarů + hromady dalších na NPM.

Přidání nového Todo

Do views/index.html přidáme formulář pro vytváření nových Todo:

<form action="/todos" method="post">
  <input type="text" name="title" />
  <button type="submit">Přidat todo</button>
</form>

A do index.js přidáme nový route handler:

// /todos koresponduje s action="/todos" z formuláře
// .post koresponduje s method="post" z formuláře
app.post('/todos', async (c) => {
  // Formulář uživatele poslal na URL /todos
  // Na této URL ovšem nechceme nic zobrazovat
  // (mohli bychom zobrazit notifikaci o úspěchu/neúspěchu)
  // a tak uživatele přesměrujeme zpět na index
  return c.redirect('/')
})

Nyní se potřebujeme dostat k hodnotě z inputu title. Hodnoty z formuláře jsou odesílány ve formátu, který je třeba zpracovat funkcí c.req.formData(). Ta nám vrátí obsah formuláře, který použijeme při vytvoření nového todo.

app.post("/todos", async (c) => {
  const form = await c.req.formData()

  todos.push({
    id: todos.length + 1,
    title: form.get("title"),
    done: false,
  })

  return c.redirect("/")
})

Pro kontrolu celý index.js:

import { Hono } from "hono"
import { serve } from "@hono/node-server"
import { logger } from "hono/logger"
import { serveStatic } from "@hono/node-server/serve-static"
import { renderFile } from "ejs"

const todos = [
  {
    id: 1,
    title: "Zajít na pivo",
    done: false,
  },
  {
    id: 2,
    title: "Doplnit skripty",
    done: false,
  },
]

const app = new Hono()

app.use(logger())
app.use(serveStatic({ root: "public" }))

app.get("/", async (c) => {
  const index = await renderFile("views/index.html", {
    title: "My todo app",
    todos,
  })

  return c.html(index)
})

app.post("/todos", async (c) => {
  const form = await c.req.formData()

  todos.push({
    id: todos.length + 1,
    title: form.get("title"),
    done: false,
  })

  return c.redirect("/")
})

serve(app, (info) => {
  console.log(
    `App started on http://localhost:${info.port}`
  )
})

A views/index.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Document</title>
  </head>
  <body>
    <h1><%= title.toUpperCase() %></h1>

    <ul>
      <% for (const todo of todos) { %>
      <li>
        <%= todo.title %>
      </li>
      <% } %>
    </ul>

    <form method="post" action="/todos">
      <input name="title" />
      <button type="submit">Přidat todo</button>
    </form>
  </body>
</html>

Editace jednotlivých Todos

Upravíme seznam a přidáme odkazy s akcemi:

<ul>
  <% for (const todo of todos) { %>
  <li>
    <a href="/todos/<%= todo.id %>"><%= todo.title %></a>
    -
    <% if (todo.done) { %>
      <a href="/todos/<%= todo.id %>/toggle">dokončeno</a>
      <a href="/todos/<%= todo.id %>/remove">odebrat</a>
    <% } else { %>
      <a href="/todos/<%= todo.id %>/toggle">nedokončeno</a>
    <% } %>
  </li>
  <% } %>
</ul>

Odkazy z a tagů nyní vedou na URL jako /todos/1/toggle, /todos/2/toggle nebo /todos/3/remove. Jedná se o dynamické URL. Proto v Hono použijeme v cestě dynamické parametry. Odkaz a po kliknutí odesílá GET požadavek, a tak použijeme metodu .get.

app.get("/todos/:id/toggle", async (c) => {
  // Ujistíme se, že id je číslo
  const id = Number(c.req.param("id"))

  // Najdeme todo podle id
  const todo = todos.find((todo) => todo.id === id)

  // Pokud jsme todo nenašli, vrátíme 404 Not Found
  if (!todo) return c.notFound()

  // Pokud jsme našli, změníme stav done
  todo.done = !todo.done

  // Přesměrujeme uživatele zpět na výpis todos
  return c.redirect("/")
})

app.get("/todos/:id/remove", async (c) => {
  const id = Number(c.req.param("id"))

  // Pokud chceme smazat prvek v poli, musíme najít jeho index (pozici v poli)
  const index = todos.findIndex((todo) => todo.id === id)

  // Funkce .findIndex vrací -1, pokud prvek v poli nenašla
  if (index === -1) return c.notFound()

  // .splice(a, b) odstraní b prvků od indexu a
  todos.splice(index, 1)

  return c.redirect("/")
})