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 // 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ě refaktor do pomocných funkcí, ale vše by jsme si museli napsat sami. Mnohem jednodušší řešení 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í vystavit 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 - context 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é nezachytili 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 šablonovacích knihoven najdeme 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štené při každém požadavku na server a mohou pracovat s c.req a c.res objekty. Pomocí druhého parametru - funkce next také ovládají kdy je požadavek předán dalšímu middlewaru/handle funkci.

app.use(async (c, next) => {
  // Tento middleware při každém requestu vypíše do konzole metodu a cestu reqeustu
  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 vratí odpověď, vypíšeme status
  console.log(c.res.status)
})

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

  await next()
})

Middleware je tak skvělý nástroj pro modifikaci req & res objektů nebo pro univerzální požadavky (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šteny vždy v pořadí jakém jsou definovány

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

Middleware je možné omezit na určitou URL/metodu + kombinace

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

Hono také nabízí několik zabudovaných middlewarů + hromady 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 by jsme 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 text. Hodnoty z formuláře jsou odesílány v nepřívětivém formátu a tak použijeme funkci c.req.formData() která 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: "Zajit 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 tedy o dynamické URL. Proto v Hono použijeme v cestě dynamické parametry. a po kliknutí odesílá GET requesty 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 sme todo nenašli, vrátíme 404 not found
  if (!todo) return c.notFound()

  // Pokud jsme našli změnime status 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 element v poli nenašla (0 je první prvek)
  // vrátíme 404 not found
  if (index === -1) return c.notFound()

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

  return c.redirect("/")
})