Server-Side Development

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 of middlewares 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 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.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

Save the above code to a file called app.js and start it with the following command. When the server is started for the first time, Deno downloads Hono and any dependencies that it needs. After this, the server starts and it responds to requests.

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 with the string “Hello world!”.

curl localhost:8000
Hello world!

Let’s decompose the application code, studying it line by line.

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

const app = new Hono();

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

Deno.serve(app.fetch);

The first line imports Hono, loading the library and it’s dependencies from the JavaScript Registry (JSR), if it is not already available in a local cache.

Alternatively, a deno.json file could be used to specify the dependencies, as done in the walking skeleton.

After importing Hono, we create an instance of Hono, assigning it to the variable app. This is followed by creating a route for the application — all GET requests to the path / should be handled by the given function. Finally, we start the server, providing the fetch property of the application as a parameter to the Deno.serve command.

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

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

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.

Loading Exercise...

Information in Context

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.

The function and the mapping could have also been written out as follows:

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

app.get("/", handleRequest);
// ...

The context provides access, among other things, to the request method and the path, as well as to the request parameters.

Request method and path

The request method and the requested path are included in the req property of the context. The following example outlines an application that would listen to requests made with GET method to any path, and then respond with the method and the path.

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

const app = new Hono();

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

Deno.serve(app.fetch);

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 "jsr:@hono/hono@4.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

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

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

Checking whether a specific request parameter exists can be done with JavaScript’s ?? operator. The ?? operator returns the value of the right-hand side if the left-hand side is null or undefined.

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

const app = new Hono();

app.get("/", (c) => {
  let name = c.req.query("name") ?? "Jane";
  return c.text(`Hi ${name}`)
});

Deno.serve(app.fetch);

The above is functionally equivalent to the following.

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

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}`)
});

Deno.serve(app.fetch);

The above nullish coalescing operator ?? is used to provide a default value for a variable if the variable is null or undefined.

The conditional (ternary) operator ? that is used as an alternative to an if-else statement is a good choice in some scenarios such as when data needs to be transformed. The following example outlines how to use the conditional operator to transform the value of a request parameter.

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

const app = new Hono();

app.get("/", (c) => {
  const name = c.req.query("name") ? c.req.query("name").toUpperCase() : "Jane";
  return c.text(`Hi ${name}`);
});

Deno.serve(app.fetch);
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 added 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 "jsr:@hono/hono@4.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

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 "jsr:@hono/hono@4.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

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 response with “yksi” to GET requests to ‘/one’, with “kaksi” to GET requests to ‘/two’, and with “pong” to any other GET requests.

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

const app = new Hono();

app.get("/one", (c) => c.text("yksi"));
app.get("/two", (c) => c.text("kaksi"));
app.get("/*", (c) => c.text("pong"));

Deno.serve(app.fetch);

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 "jsr:@hono/hono@4.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

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

It is also possible to use information from the paths as a part of the route. As an example, consider the three following addresses of an application.

Each address returns JSON-formatted information about a product. The number at the end of the path is an identifier of a specific product. To implement such an application, one possibility would be to hard-code the paths and the identifiers in the application, as shown below.

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

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"));

Deno.serve(app.fetch);

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

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 "jsr:@hono/hono@4.6.5";

const app = new Hono();

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

Deno.serve(app.fetch);

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%

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 "jsr:@hono/hono@4.6.5";

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}`);
  },
);

Deno.serve(app.fetch);
curl localhost:8000/lists/1/items/3
List 1, item 3%
curl localhost:8000/lists/42/items/8
List 42, item 8%
Loading Exercise...

Responding and handling JSON data

Plenty of web applications respond with JSON-formatted data. JSON is a text-based data-interchange format that allows transmitting objects as text.

Hono provides a method json that can be used to format a JavaScript object as JSON and to respond with the JSON data. The method takes a JavaScript object as an argument and returns a response object that is typically returned from a function that handles a request.

The following example outlines an application that responds with a JavaScript object with the property message that has the value Hello world!, transformed into JSON format.

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

const app = new Hono();

app.get("/", (c) => {
    const data = { message: "Hello world!" };
    return c.json(data);
  },
);

Deno.serve(app.fetch);

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

curl localhost:8000
{"message":"Hello world!"}%

We can also use the json method to respond with JSON data when using path variables (and other information from the request). The following application demonstrates how one of the earlier applications with two path variables could be modified to respond with JSON data.

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

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.json({ listId, itemId });
  },
);

Deno.serve(app.fetch);

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

curl localhost:8000/lists/1/items/3
{"listId":"1","itemId":"3"}%
curl localhost:8000/lists/42/items/8
{"listId":"42","itemId":"8"}%
Shorthand for creating JavaScript objects

The above example shows a shorthand for creating JavaScript objects. The shorthand is explicated in the following example, where objects obj1 and obj2 are equivalent.

const listId = 1;
const itemId = 2;

const obj1 = { listId: listId, itemId: itemId };
const obj2 = { listId, itemId };

Loading Exercise...

If a request has JSON data, the JSON data can be accessed using the asynchronous json method of the req property of the context. As the method is asynchronous, the function that processes the request needs to be marked as asynchronous as well, and the method call needs to be awaited.

The following example outlines an application that responds with the JSON data that it receives.

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

const app = new Hono();

app.post("/", async (c) => {
  const data = await c.req.json();
  return c.json(data);
});

Deno.serve(app.fetch);

When we run the above program and query it with a POST request, we see that the response is in JSON format.

curl -X POST -H "Content-Type: application/json" -d '{"message": "Hello world!"}' localhost:8000
{"message":"Hello world!"}%

Similarly, the following example outlines an application that responds only with the property message of the JSON data that it receives if the property exists, otherwise responding with a message that the property is missing.

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

const app = new Hono();

app.post("/", async (c) => {
  const data = await c.req.json();
  const message = data.message ?? "Message missing";
  return c.json({ message });
});

Deno.serve(app.fetch);

When we run the above program and query it with a POST request, we see that the response is in JSON format.

curl -X POST -H "Content-Type: application/json" -d '{"message": "Test!"}' localhost:8000
{"message":"Test!"}%
curl -X POST -H "Content-Type: application/json" -d '{}' localhost:8000
{"message":"Message missing"}%
Loading Exercise...

Middlewares

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

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.

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

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!`));

Deno.serve(app.fetch);

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.

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 "jsr:@hono/hono@4.6.5";
import { logger } from "jsr:@hono/hono/logger";

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

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

Deno.serve(app.fetch);

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.