Server-Side Functionality with a Database

Hono Web Framework


Learning Objectives

  • You know what web frameworks are and know of the Hono web framework.
  • You know how to map functions to paths and methods with Hono’s routing functionality.
  • You know how to retrieve information from Hono’s context, including request parameters and path variables.
  • You know about middleware and can add middleware to a Hono application.

Web frameworks are a specific category of software frameworks, created for developing web software applications. The key functionality that most server-side web frameworks provide is routing and middleware functionality.

Routing means mapping paths and methods to functions. Middleware, on the other hand, are functions that encapsulate functions that correspond to routes.

Here, we will introduce the Hono web framework, which we will use in the course to build web applications over vanilla Deno.

A web application with Hono

A Hono web application that responds to GET requests to the root path of the application with the string “Hello world!” looks as follows.

import { Hono } from "jsr:@hono/hono@4.8.12";

const app = new Hono();

app.get("/", (c) => c.text("Hello world!"));

Deno.serve(app.fetch);

The application imports Hono, creates an instance of Hono, and then defines a route where HTTP GET requests to the root path / are responded to with the string “Hello world!”. Finally, the application starts a server that listens to requests and forwards them to Hono’s fetch method.

Running the application is done with Deno’s run command, like we did in the last chapter. When the server is started for the first time, Deno downloads the dependencies that are imported in the source code (in this case, Hono).

$ deno run --allow-net --watch app.js
(... dependencies being loaded)
Watcher Process started.
Listening on http://0.0.0.0:8000/

When you open up another terminal and make a request to the server, you’ll see that the server responds to GET requests to the root path of the application with the string “Hello world!”.

$ curl localhost:8000
Hello world!

In the above example, we import Hono directly from the JavaScript Registry (JSR). This is a quick way to get started with Hono, but it is not a recommended way to manage dependencies in a Deno application.

Function shorthand

The (c) => c.text("Hello world!") is a shorthand for defining an anonymous function that takes a single parameter c and returns the result of c.text("Hello world!"). It is equivalent to writing:

(c) {
  return c.text("Hello world!");
}

We could also write it as a named function as follows:

const handleRequest = (c) => {
  return c.text("Hello world!");
}

Managing dependencies

To centralize dependency management, we use a deno.json file with an import map.

An import map is a way to control the behavior of JavaScript’s module resolution, allowing us to map module specifiers to specific versions or locations (see HTML standard on import maps).

Create a file called deno.json and add the following content to it. The import map specifies that the @hono/hono module specifier should be resolved to a specific version of Hono from JSR.

{
  "imports": {
    "@hono/hono": "jsr:@hono/hono@4.8.12"
  }
}

Then, create a file called app.js with the following content.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => c.text("Hello world!"));

Deno.serve(app.fetch);

Now, the string @hono/hono in app.js is resolved to the version specified in the deno.json file. Using deno.json for specifying dependencies is recommended, as it centralizes version information and helps manage the dependencies of the application.

In this course, we expect that you use the same specifiers for imports as outlined in the materials, e.g. @hono/hono. This helps in avoiding version conflicts and ensures that the libraries work as expected. The versions that the automatic grader uses are outlined in a separate Library versions page.

Loading Exercise...

Dividing app.js to two files

Similar to the earlier applications with Deno in the previous chapter, the above can be further divided into two files, app.js and app-run.js. For the above, app.js would be as follows.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => c.text("Hello world!"));

export default app;

And the app-run.js file would be as follows.

import app from "./app.js";

Deno.serve(app.fetch);

The exercises in this part also use this format, where the application code is in app.js and the server is started with app-run.js. The exercises omit the deno.json file, as it is already available on the server that runs the exercises.

Loading Exercise...

Hono’s Context Object

Functions that handle requests in Hono are given an instance of Hono’s Context object. The context contains information about the request, and provides methods for forming a response.

Request method and path

The request method and the requested path are included in the req property of the context. The method is accessed through the method property of req, while the path is accessed through the path property of req.

The following example outlines an application that would listen to requests made with the GET method to any path, and then respond with the method and the path.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/*", (c) => c.text(`${c.req.method} ${c.req.path}`));

export default app;

Trying the application out, we see that the paths (and the method) are included in the response.

$ curl localhost:8000
GET /
$ curl localhost:8000/hello
GET /hello
$ curl -X POST localhost:8000
404 Not Found
Wildcards in paths

Asterisks * in Hono route mappings are used as wildcards, matching all paths that do not correspond to other routes.

Request parameters

Request parameters can be accessed through the req property of the context. The req property has a method query, which takes the name of the parameter as an argument and returns the value of the parameter. If the parameter is not present, the method returns undefined.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => c.text(`Name: ${c.req.query("name")}`));

export default app;

Running the above program and querying it, we see the following:

$ curl localhost:8000
Name: undefined
$ curl localhost:8000?name=Harry
Name: Harry

If we want to add more complex logic to the function that handles the request, we can use a block body for the function instead of the concise body that we have used so far. The following example outlines an application that responds with “Hi ” followed by the value of the name parameter, or “Jane” if the parameter is not present.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => {
  let name = "Jane";
  if (c.req.query("name")) {
    name = c.req.query("name");
  }

  return c.text(`Hi ${name}`)
});

export default app;

When we run the above program and query it, we see the following:

$ curl localhost:8000
Hi Jane
$ curl localhost:8000?name=Larry
Hi Larry
Loading Exercise...

The above could be shortened with both the nullish coalescing operator ?? and the conditional (ternary) operator ?::

  • The nullish coalescing operator ?? returns the right-hand side operand when the left-hand side operand is null or undefined. Otherwise, it returns the left-hand side operand. It is often used to provide default values for potentially null or undefined expressions.

    const name = c.req.query("name") ?? "Jane";
  • The conditional (ternary) operator ?: takes three operands: a condition, a result for true, and a result for false. If the condition is true, it returns the result for true; otherwise, it returns the result for false. It is a concise way to perform conditional assignments or return values, and to perform transformations based on conditions.

    const name = c.req.query("name") ? c.req.query("name").toUpperCase() : "Jane";
Loading Exercise...

Adding routes

A route is a mapping from a method-path-combination to a function.

When working with Hono, each route is explicitly defined in the application. The following example outlines an application with two routes, both handling GET requests. The first route is for the path / and the second route is for the path /secret.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/", (c) => c.text("Hello world!"));
app.get("/secret", (c) => c.text("Hello Illuminati!"));

export default app;

Hono has methods for adding routes where the method names correspond to the HTTP request methods. The get method is used to define routes for requests that use the HTTP GET method. Similarly, as one might guess, the post method is used to define routes for requests that use the HTTP POST method.

The following application outlines the use of the get and post methods.

import { Hono } from "@hono/hono";

const app = new Hono();

app.post("/", (c) => c.text("POST request to /"));
app.get("/", (c) => c.text("GET request to /"));

export default app;

When we run the above application, we see that it responds differently to GET and POST requests.

$ curl localhost:8000
GET request to /
$ curl -X POST localhost:8000
POST request to /

As mentioned earlier, we can also use wildcards in paths. The following example outlines an application that responds with “yksi” to GET requests to ‘/one’, with “kaksi” to GET requests to ‘/two’, with “kolme” to GET requests to ‘/three’, and with “Sauna!” to GET requests to any other GET requests.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/one", (c) => c.text("yksi"));
app.get("/two", (c) => c.text("kaksi"));
app.get("/three", (c) => c.text("kolme"));
app.get("/*", (c) => c.text("Sauna!"));

export default app;

It is also possible to define methods that do not exist in the HTTP protocol. This is done using the on method, which takes the name of the method as the first parameter, the path as the second parameter, and the function to be executed as the third parameter.

The following example outlines the use of the on method for creating an application that responds to HTTP requests where the method is PEEK.

import { Hono } from "@hono/hono";

const app = new Hono();

app.on("PEEK", "/", (c) => c.text("Nothing to see here."));

export default app;

When we run the above application, we see that the application responds to requests made with the PEEK method. On the other hand, the server responds with not found to other requests.

$ curl -X PEEK localhost:8000
Nothing to see here.
$ curl localhost:8000
404 Not Found
Loading Exercise...

Path variables

When an application grows, we often want to create routes that are not hard-coded, but instead can handle a range of different paths. This is similar to what we have done when working with dynamic pages with Svelte.

Consider, for example, the following application with hard-coded identifiers.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get("/products/1", (c) => c.text("Information on product 1"));
app.get("/products/2", (c) => c.text("Information on product 2"));
app.get("/products/3", (c) => c.text("Information on product 3"));

export default app;

The above approach is not sensible, however, as it would require changing the application code whenever a new product is added.

Defining and accessing path variables

This is where path variables come in. Path variables are a way to define parts of a path as variables, where values from the parts can then be used in the application.

In Hono, path variables are defined by prefixing a part of a path with a colon, i.e. :. The part of the path that is prefixed with the colon will be available in Hono’s context through the param method of the req property. As an example, if a path would be /products/:id, the value of the id variable would be available through c.req.param("id").

The following outlines an example of how to use path variables in a Hono application. The application generalizes the earlier example to work for any product identifier.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get(
  "/products/:id",
  (c) => c.text(`Information on product ${c.req.param("id")}`),
);

export default app;

When we run the above program, and query it, we see that the path is reflected in the response.

$ curl localhost:8000/products/1
Information on product 1
$ curl localhost:8000/products/42
Information on product 42
$ curl localhost:8000/products/123
Information on product 123
Loading Exercise...

Multiple path variables

Paths can also hold multiple variables. The following example outlines two variables. The first variable is named listId, and the second variable is named itemId.

import { Hono } from "@hono/hono";

const app = new Hono();

app.get(
  "/lists/:listId/items/:itemId",
  (c) => {
    const listId = c.req.param("listId");
    const itemId = c.req.param("itemId");

    return c.text(`List ${listId}, item ${itemId}`);
  },
);

export default app;
$ curl localhost:8000/lists/1/items/3
List 1, item 3
$ curl localhost:8000/lists/42/items/8
List 42, item 8
Loading Exercise...

Middleware

As mentioned at the beginning of this chapter, the key functionality that web frameworks typically come with is routing and middleware. middleware functions wrap the functions that correspond to routes, and they can be used to perform tasks such as logging, authentication, and error handling.

Custom middleware

A middleware function in Hono takes two parameters, the context object and a function that corresponds to the function that the middleware encapsulates.

The following example outlines a middleware function that logs the request method and the path, then calls the next function, and finally — once the execution of the next function is finished — logs the response status code after the handler has produced the response (i.e., after await next()), just before the response is sent to the client.

import { Hono } from "@hono/hono";

const app = new Hono();

const myLogger = async (c, next) => {
  console.log(`--> ${c.req.method} to ${c.req.path}`);
  await next();
  console.log(`<-- status ${c.res.status}`);
};

app.use(myLogger);

app.get("/", (c) => c.text(`Hello world!`));

export default app;

Now, the server responds with the message “Hello world!” to GET requests to the root path. In addition, the server logs the request method and the path before the function corresponding to the route is called, and the response status code when the route has finished executing.

Loading Exercise...

Ready-made middleware functions

Hono comes with plenty of ready-made middleware functions. As an example, instead of the above custom logger, we might want to directly use Hono’s logger middleware as follows.

import { Hono } from "@hono/hono";
import { logger } from "@hono/hono/logger";

const app = new Hono();
app.use(logger());

app.get("/", (c) => c.text(`Hello world!`));

export default app;

Note that when adding Hono’s logger above, we are calling the logger function, which returns a middleware function, and adding the returned middleware function to the application. That is, we do not directly add the function with the use method.

Adding logging functionality to the server can help in debugging and monitoring the server’s behavior.

Middleware and specific paths

By default, middleware added with app.use() applies to all routes. You can also scope middleware to specific routes. As an example, in the following, only requests to paths that start with /api are logged.

import { Hono } from "@hono/hono";
import { logger } from "@hono/hono/logger";

const app = new Hono();

app.use("/api/*", logger());
app.get("/", (c) => c.text(`Hello world!`));

export default app;
Loading Exercise...

Summary

In summary:

  • Web frameworks simplify building web applications by providing routing and middleware functionality.
  • Hono is a lightweight web framework for Deno that allows mapping HTTP methods and paths to handler functions.
  • Dependencies are best managed with deno.json import maps, centralizing version information for consistency.
  • Hono’s context object (c) provides access to request information such as method, path, query parameters, and path variables, and offers helpers for forming responses.
  • Routes can be defined for different HTTP methods, use wildcards, or include path variables for dynamic behavior.
  • Middleware functions can wrap routes to add cross-cutting functionality such as logging, authentication, or error handling.