Web Applications with Deno
Learning objectives
- Rehearses building basic web applications with Deno.
Deno is a JavaScript runtime similar to Node.js and Bun. It is used in the Web Software Development course and we'll continue relying on it in this course as well.
Deno versions
In the present version of the materials, we're using Deno 1.42.2
and the Deno standard library version 0.222.1
. These are also in use in the system used for automated assessment.
Listening to requests
A web server that listens to requests to port 7777
and responds to requests with the message Hello world!
is implemented as follows. The request
is an instance of Request, while the function handleRequest
returns an instance of Response. The function Deno.serve that launches a server is given (1) options (e.g. the port) and (2) a function used to handle incoming requests.
const portConfig = { port: 7777 };
const handleRequest = async (request) => {
return new Response("Hello world!");
};
Deno.serve(portConfig, handleRequest);
Running the server is straightforward; the command deno run --allow-net --unstable app.js
starts the server.
deno run --allow-net --unstable app.js
Listening on http://127.0.0.1:7777/
Now, the server is up and running and is listening to requests.
curl -v "localhost:7777"
// ..
> GET / HTTP/1.1
// ...
< HTTP/1.1 200 OK
//
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 12
<
* Connection #0 to host localhost left intact
Hello world!%
You can shut down the server with ctrl+c
(or similar, matching your operating system).
It is also possible to create a lower-level implementation of the web server using Deno.listen and Deno.serveHttp as shown in the following example.
const portConfig = { port: 7777 };
const handleRequest = async (request) => {
return new Response("Hello world!");
};
const handleHttpConnection = async (conn) => {
for await (const requestEvent of Deno.serveHttp(conn)) {
requestEvent.respondWith(await handleRequest(requestEvent.request));
}
}
for await (const conn of Deno.listen(portConfig)) {
handleHttpConnection(conn);
}
Again, starting up the server is straightforward.
deno run --allow-net --unstable app.js
Now, the server is up and running and is listening to requests.
curl -v "localhost:7777"
// ...
> GET / HTTP/1.1
// ...
< HTTP/1.1 200 OK
< content-type: text/plain;charset=UTF-8
< content-length: 12
// ...
* Connection #0 to host localhost left intact
Hello world!%
The conn
is an instance of Connection, and the requestEvent
is an instance of RequestEvent. In effect, the above example exposes handling connections on the server.
HTTP/1.1 vs HTTP/2
The key difference between the two implementations shown above is that they use different APIs. The first one only supports HTTP/1.1, while the second supports both HTTP/1.1 and HTTP/2. When we start the first web server and send a request to it using the command curl -v --http2-prior-knowledge localhost:7777
, we see the following output.
curl -v --http2-prior-knowledge "localhost:7777"
// ...
> GET / HTTP/2
// ...
* http2 error: Remote peer returned unexpected data while we expected ...
* Connection #0 to host localhost left intact
curl: (16) Error in the HTTP2 framing layer
The output effectively states that HTTP/2 is not supported by the server.
On the other hand, using the same command for the second server, we see the following output. Here, HTTP/2 is supported.
curl -v --http2-prior-knowledge "localhost:7777"
// ...
> GET / HTTP/2
// ...
< HTTP/2 200
< content-type: text/plain;charset=UTF-8
< vary: Accept-Encoding
< content-length: 12
// ...
* Connection #0 to host localhost left intact
Hello world!%
Handling requests to paths
Request objects have url
and method
as string properties, which provide information about the requested url and the request method. The url
can be transformed into an URL object by providing the string to the constructor of URL. The following demonstrates responding to requests with the request method, the requested path, and any URLSearchParams in the request url.
const handleRequest = async (request) => {
const url = new URL(request.url);
const { pathname, searchParams } = url;
// or:
// const pathname = url.pathname;
// const searchParams = url.searchParams;
return new Response(`${request.method}; path: ${pathname}; params: ${searchParams}`);
};
With the above example, the response to a request would be formatted as follows.
curl "localhost:7777/thepath?id=3"
GET; path: /thepath; params: id=3%
With this information, and knowing about the URLPattern API, simple functionality that would map requests to specific functions could be implemented as follows.
const handleGetRoot = async (request) => {
return new Response("Hello world at root!");
};
const handleGetItem = async (request, urlPatternResult) => {
const id = urlPatternResult.pathname.groups.id;
return new Response(`Retrieving item with id ${id}`);
};
const handleGetItems = async (request) => {
return new Response("Retrieving all items.");
};
const handlePostItems = async (request) => {
return new Response("Posting an item.");
};
const urlMapping = [
{
method: "GET",
pattern: new URLPattern({ pathname: "/items/:id" }),
fn: handleGetItem,
},
{
method: "GET",
pattern: new URLPattern({ pathname: "/items" }),
fn: handleGetItems,
},
{
method: "POST",
pattern: new URLPattern({ pathname: "/items" }),
fn: handlePostItems,
},
{
method: "GET",
pattern: new URLPattern({ pathname: "/" }),
fn: handleGetRoot,
},
];
const handleRequest = async (request) => {
const mapping = urlMapping.find(
(um) => um.method === request.method && um.pattern.test(request.url)
);
if (!mapping) {
return new Response("Not found", { status: 404 });
}
const mappingResult = mapping.pattern.exec(request.url);
return await mapping.fn(request, mappingResult);
};
When added to a web server, the output of the application would be as follows.
curl -X POST "localhost:7777/items"
Posting an item.%
curl "localhost:7777/items/4"
Retrieving item with id 4%
curl -X POST "localhost:7777/asd"
Not found%
curl -X POST "localhost:7777"
Not found%
curl -X GET "localhost:7777"
Hello world at root!%
curl -X GET "localhost:7777/"
Hello world at root!%
Efficiency in URL pattern matching
In the above example, the URL patterns are iterated over for each request. Web frameworks often implement URL pattern matching using a Radix tree (or a similar tree structure). With a radix tree, there would be no need for iteration, and finding a function that corresponds to a path and a request method would be somewhat faster. The differences are not visible in small applications however, as using a data structure tree does create some overhead.
APIs and JSON
APIs and JSON are briefly discussed in the Web Software Development course.
Retrieving JSON data from a request is straightforward -- the Request class has an asynchronous method json that parses the request body into a JSON object and returns it. Similarly, for constructing a JSON response, the Response class has a static method json that can be used to create a JSON response from an object.
Continuing on the previous example, we could have a list of item objects, which would be handled by our methods. An example of the functionality could be as follows.
const items = [];
const handleGetRoot = async (request) => {
return new Response("Hello world at root!");
};
const handleGetItem = async (request, urlPatternResult) => {
const id = urlPatternResult.pathname.groups.id;
// trying to respond with an item at index id
return Response.json(items[id]);
};
const handleGetItems = async (request) => {
return Response.json(items);
};
const handlePostItems = async (request) => {
const item = await request.json();
items.push(item);
return new Response("OK", { status: 200 });
};
Running the application, we now would have an API for working with simple items.
curl "localhost:7777/items"
[]%
curl -X POST -d '{"name": "hamburger"}' "localhost:7777/items"
OK%
curl -X POST -d '{"name": "water"}' "localhost:7777/items"
OK%
curl "localhost:7777/items"
[{"name":"hamburger"},{"name":"water"}]%
curl "localhost:7777/items/0"
{"name":"hamburger"}%
curl "localhost:7777/items/1"
{"name":"water"}%
curl "localhost:7777/items/2"
curl: (52) Empty reply from server
As you might notice from the last line, the server has no functionality for handling errors though. In the above example, when we ask for an item that does not exist, the server crashes. This is visible in the server logs.
error: Uncaught (in promise) TypeError: Value is not JSON serializable.
return Response.json(items[id]);
^
at serializeJSValueToJSONString (deno:ext/web/00_infra.js:306:13)
at Function.json (deno:ext/fetch/23_response.js:299:19)
at Object.handleGetItem [as fn] (file:///path-to-file/app.js:12:19)
at handleRequest (file:///path-to-file/app.js:58:24)
at handleHttpConnection (file:///path-to-file/app.js:63:36)