Work with HTTP in Deno

Interacting with HTTP servers in Deno is easy due to Deno’s adherence to web standards. For example, Deno uses standards like Request, Response or Fetch. The latter is largely implemented according to the WHATWG fetch spec, but either deviates in some places or is missing implementations (Spec Deviations of fetch). Deno is also part of the WinterCG, a community group that aims to develop standardised APIs between JavaScript runtimes. Part of the minimum common API are the Request and Response standards mentioned above. In the following chapters we will use some of these web standard classes. These make working with HTTP much easier, as we do not have to redefine many of the things we need.

Web API Standards

Request & Response

Request is an object that we can either use to make fetch requests, or to get information about a request on the server side. This makes working with APIs, for example, more convenient and standardised. We get a lot of information about the request in an object. The definition below is an example:

const req = new Request("https://example.com", {
  headers: { "content-type": "application/json" },
});
console.log(req.url); // https://example.com
console.log(req.method); // GET
console.log(req.headers); // Headers { "content-type": "application/json" }

Here we set up a request that we can use for a fetch request, for example by setting the URL to https://example.com and the content type we expect to JSON (application/json). Unless otherwise specified, the request method is GET by default. We will see the same application on the server side in the following chapters. The same handling also applies to the Response object.

We will see how to process and read a Request object in our HTTP server in the chapter on the Deno.serve HTTP server.

URL & URLPattern

const url = new URL("https://example.com/my-path?query=string");
console.log(url.hostname); // example.com
console.log(url.protocol); // https:
console.log(url.pathname); // /my-path
console.log(url.search); // ?query=string
console.log(url.searchParams.get("query")); // string

Using URLPattern we can easily match other URL strings or parts of them against a given pattern. This allows us to create API routes for our HTTP server. Even dynamic values are possible. More on this later.

const SINGLE_BOOK = new URLPattern({ pathname: "/book/" });
const url = new URL("https://example.com/book");

// check if the URL matches the request
if (SINGLE_BOOK.exec(url)) {
  console.log("The path matches");
}

As you can see here, we define the URLPattern with the pathname /book/. Accordingly, the if will only resolve to true if the URL is exactly /book/. If, for example, the / is missing at the end or other things are appended to this path, e.g. /book/test-book, the if will not be executed.

Now let’s get started and create an HTTP server and use the APIs we discussed. We will see how well Deno can be used without a framework to create servers and APIs.

Deno.serve HTTP Server

Basic Server

Creating HTTP servers is one of the easiest things to do in Deno. Using Deno.serve and a simple Response object, we can provide a basic HTTP server. To do this we create a file with the following code. For example main.ts.

const handler = (_req: Request): Response => {
  return new Response("Hello, World!");
};

Deno.serve(handler);

To start the server, we need to remember that we need to give network permissions. So we have to start the server with deno run --allow-net main.ts. If we want to test our server, we can use curl to get a response in another terminal window.

 curl http://localhost:8000
Hello, World!

We have now created the simplest type of HTTP server in Deno. Now let’s look at how to implement routes for our HTTP server.

Listen to Paths

Currently, our HTTP server would respond to every request with Hello, World!. This makes limited sense. So let’s look at how we can define different paths, including dynamic parameters.

As mentioned earlier, we have a number of web standards in Deno. One of them is URLPattern. We can use this to add different paths to our HTTP server without using any other frameworks.

const ALL_BOOKS = new URLPattern({ pathname: "/books/" });

This definition prepares us to compare the incoming request with our URL pattern and respond with a specific response.

URLPattern implements an exec method that we can use to match another URL. Let’s have a look at an example.

const ALL_BOOKS_PATTERN = new URLPattern({ pathname: "/books/" });

This allows us to create a /books/ route. In other words, we can easily check if the incoming request is against this path.

If we extend our HTTP server to check for this route, we get the following `handler’.

const ALL_BOOKS_PATTERN = new URLPattern({ pathname: "/books/" });

const handler = (req: Request): Response => {
  const url = new URL(req.url);
  if (ALL_BOOKS_PATTERN.exec(url.pathname)) {
    return new Response("All books");
  }

  return new Response("Hello, World!");
};

Deno.serve(handler);

Set Headers and handle those

HTTP servers must also set headers so that the client can interpret the data returned correctly. Since we are working with the `Response’ object, we can add these to the object. The headers will then be sent to the client in the response.

Deno.serve((_req) =>
  new Response("Hello World", {
    status: 200,
    headers: { "my-cool-header": "Look!" },
  })
);

Here we set the my-cool-header for every response that goes against our HTTP server. Now if we start the server with deno run --allow-net http.ts, we can make a request against our server. For example, with curl curl -i http://localhost:8000 we get the following response:

 curl -i http://localhost:8000
HTTP/1.1 200 OK
my-cool-header: Look!
content-type: text/plain;charset=UTF-8
vary: Accept-Encoding
content-length: 11
date: Fri, 20 Dec 2024 16:49:11 GMT

Hello World

As we can see, the header my-cool-header: Look! header is now set. This means that we can configure the content-type or charset etc. manually as well.

HTTP/1.1 and HTTP/2 support

Support for HTTP versions 1.1 and 2 works out of the box. We do not need to do anything to take advantage of HTTP/2 (e.g. request multiplexing or header compression). Read more about the advantages HTTP/2 vs. HTTP/1.1:.

Automatic Body Compression

Another out-of-the-box feature that Deno builds into the HTTP server is the automatic compression of response bodies (gzip or brotli, depending on what the client supports). In order for this to work automatically, we need to consider 3 things.

Read more: Writing an HTTP Server on Deno.com

Basic CRUD Server (Get, Post, Patch, Delete)

We have covered all the basics to create a simple CRUD (Create, Read, Update, Delete) server. In terms of content, we want to have an HTTP server in which we manage books.

We will start with the two books ‘Atomic Habits’ and ‘The Alchemist’. This list will be expandable. So we need routes to show us 1. all books and 2. a single book by ID. We want to be able to type in a title and create a new book in our list. We also want to be able to change and delete a book title using the ID.

The code for this is as follows

// Define the routes necessary
const ALL_BOOKS = new URLPattern({ pathname: "/books/" });
const SINGLE_BOOK = new URLPattern({ pathname: "/books/:id" });

// Define our "database" of books
const books: Record<string, string> = {
  "1": "Atomic Habits",
  "2": "The Alchemist",
};

// GET /books/
const getBooks = (_req: Request): Response => {
  return new Response(JSON.stringify(books), {
    status: 200,
  });
};

// GET /books/:id
const getBook = (req: Request): Response => {
  const url = new URL(req.url);

  const id = SINGLE_BOOK.exec(url)?.pathname.groups?.id;
  if (!id) {
    return new Response("Invalid id", { status: 400 });
  }

  const book = books[id];
  if (!book) {
    return new Response("Book not found", { status: 400 });
  }

  return new Response(book, {
    status: 200,
  });
};

// POST /books/
const postBook = async (req: Request): Promise<Response> => {
  const { title } = await req.json();

  if (!title) {
    return new Response("Invalid title", { status: 400 });
  }

  const id = Object.keys(books).length + 1;
  books[id] = title;

  return new Response("Book added", { status: 201 });
};

// PATCH /books/:id
const patchBook = async (req: Request): Promise<Response> => {
  const { title } = await req.json();
  const url = new URL(req.url);

  const id = SINGLE_BOOK.exec(url)?.pathname.groups?.id;
  if (!id) {
    return new Response("Invalid id", { status: 400 });
  }

  books[id] = title;

  return new Response(`Book updated: ${id}: ${title} `, { status: 200 });
};

// DELETE /books/:id
const deleteBook = (req: Request): Response => {
  const url = new URL(req.url);

  const id = SINGLE_BOOK.exec(url)?.pathname.groups?.id;
  if (!id) {
    return new Response("Invalid id", { status: 400 });
  }

  delete books[id];

  return new Response(`Book ${id} deleted`, { status: 200 });
};

const handler = async (req: Request): Promise<Response> => {
  // Check if /books/ is requested
  if (ALL_BOOKS.exec(req.url)) {
    switch (req.method) {
      case "GET":
        return getBooks(req);
      case "POST":
        return await postBook(req);
      default:
        return new Response("Method not allowed", { status: 405 });
    }
  }

  // Check if /books/:id is requested
  if (SINGLE_BOOK.exec(req.url)) {
    switch (req.method) {
      case "GET":
        return getBook(req);
      case "PATCH":
        return patchBook(req);
      case "DELETE":
        return deleteBook(req);
      default:
        return new Response("Method not allowed", { status: 405 });
    }
  }

  // if none of the above paths are requested
  return new Response("Not found", { status: 404 });
};

Deno.serve(handler);

In Deno, a simple CRUD server without any framework, using only web standard APIs, is created from these 110 lines.

Rewriting our HTTP Server with Hono

While it is possible to build an HTTP server without a framework, there are point in favor of using one.

One of them is a simpler API and therefore more readable code. Readable code => maintainable code

There are also ready-made modules for CORS, authentication or logging. These features can of course be built by us, but they are more time consuming.

Let’s have a look at the framework Hono. Like Deno, it is based on web standards and therefore supports any other JavaScript runtime (e.g. Bun, Cloudflare Workers, NodeJS) in addition to Deno. Hono also offers the middleware features mentioned above (e.g. CORS support or basic auth). In the following example, we will look at a translation of the previous example.

We will see that Hono’s API makes it easier to define our routes. We can omit URLPattern and switch statements and still get the routes we want, including dynamic ID parameters.

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

const app = new Hono();

const books: Record<string, string> = {
  "1": "Atomic Habits",
  "2": "The Alchemist",
};

// GET /books/
const getBooks = (c: Context) => {
  return c.json(books);
};

// GET /books/:id
const getBook = (c: Context): Response => {
  const id = c.req.param("id");

  if (!id) {
    return c.text("Invalid id", { status: 400 });
  }

  const book = books[id];
  if (!book) {
    return c.text("Book not found", { status: 400 });
  }

  return c.text(book);
};

// POST /books/:id
const postBook = async (c: Context): Promise<Response> => {
  const { title } = await c.req.json();

  if (!title) {
    return c.text("Invalid title", { status: 400 });
  }

  const id = Object.keys(books).length + 1;
  books[id] = title;

  return c.text("Book added", { status: 201 });
};

// PATCH /books/:id
const patchBook = async (c: Context): Promise<Response> => {
  const { title } = await c.req.json();

  const id = c.req.param("id");
  if (!id) {
    return c.text("Invalid id", { status: 400 });
  }

  books[id] = title;

  return c.text(`Book updated: ${id}: ${title} `, { status: 200 });
};

// DELETE /books/:id
const deleteBook = (c: Context): Response => {
  const id = c.req.param("id");
  if (!id) {
    return c.text("Invalid id", { status: 400 });
  }

  delete books[id];

  return c.text(`Book ${id} deleted`, { status: 200 });
};

app
  .get("/books/", getBooks)
  .post("/books/", postBook)
  .get("/books/:id", getBook)
  .patch("/books/:id", patchBook)
  .delete("/books/:id", deleteBook);

Deno.serve(app.fetch);

Concluding Deno and HTTP

Throughout this exploration of HTTP servers in Deno, we’ve seen how the runtime’s commitment to web standards and modern design principles makes it an excellent choice for building web applications. The key takeaways include:

  1. Web Standards First: Deno’s implementation of web standards like Request, Response, and URLPattern makes it intuitive for developers familiar with web development, reducing the learning curve and maintaining consistency with browser APIs.

  2. Simplicity Without Sacrifice: The native HTTP server implementation (Deno.serve) provides robust functionality out of the box, including:

    • HTTP/1.1 and HTTP/2 support
    • Automatic body compression
    • Efficient request handling
  3. Framework Optional: While frameworks like Hono can provide additional convenience and features, Deno’s standard library is powerful enough to build fully functional CRUD applications with minimal boilerplate code.

  4. Performance: Thanks to its Rust-based implementation using the Hyper library, Deno’s HTTP server provides excellent performance characteristics without requiring developers to manage low-level details.

The comparison between the vanilla Deno implementation and the Hono framework version demonstrates that while frameworks can provide more structured and concise APIs, Deno’s native capabilities are more than sufficient for many use cases. This flexibility allows developers to choose the right level of abstraction for their specific needs, whether that’s using Deno’s built-in features or incorporating additional frameworks and libraries.