Serve a tree

Displaying a tree in the console is fine for playing around or debugging, but we can do much more interesting things with a tree — like serve it to a web browser.

Let’s build a small tree server directly on top of Node’s http API. (If we were already using a server like Express, it would be straightforward to adapt this same idea into a server middleware function that handles a specific portion of a larger site.)

Using the AsyncTree interface to model the tree will let us browse content regardless of how that content is stored or generated.

Treat a URL as a series of tree keys

The first thing is to recognize a URL as a tree traversal — we can treat a URL path as a series of keys to follow through a tree.

Specifically, we convert a string URL path like /foo/bar into an array of keys ["foo", "bar"].

/* In src/deep/serve.js */

// Convert a path-separated URL into an array of keys.
function keysFromUrl(url) {
  const keys = url.split("/");
  if (keys[0] === "") {
    // The path begins with a slash; drop that part.
    keys.shift();
  }
  if (keys[keys.length - 1] === "") {
    // The path ends with a slash; replace that with index.html as the default key.
    keys[keys.length - 1] = "index.html";
  }
  return keys;
}

If the path ends in a slash like foo/, this produces the keys ["foo", "index.html"].

Traverse a tree

We can then iteratively follow this array of keys through a deep tree to a final value:

/* In src/deep/serve.js */

// Traverse a path of keys through a tree.
async function traverse(tree, ...keys) {
  let value = tree;
  for (const key of keys) {
    value = await value.get(key);
    if (value === undefined) {
      // Can't go any further
      return undefined;
    }
  }
  return value;
}

The tree itself is acting as a web site router.

Handle requests using a tree

Putting these together, we can build a listener function that uses a tree to respond to HTTP requests.

/* In src/deep/serve.js */

// Given a tree, return a listener function that serves the tree.
function requestListener(tree) {
  return async function (request, response) {
    console.log(request.url);
    const keys = keysFromUrl(request.url);
    let value;
    try {
      value = await traverse(tree, ...keys);
    } catch (error) {
      console.log(error.message);
    }

    const isAsyncDictionary =
      typeof value?.get === "function" && typeof value?.keys === "function";
    if (isAsyncDictionary) {
      // Redirect to the root of the async tree.
      response.writeHead(307, { Location: `${request.url}/` });
      response.end("ok");
      return true;
    } else if (value !== undefined) {
      response.writeHead(200, { "Content-Type": "text/html" });
      response.end(value);
      return true;
    } else {
      response.writeHead(404, { "Content-Type": "text/html" });
      response.end(`Not found`, "utf-8");
      return false;
    }
  };
}

This converts a request’s URL into an array of keys, then returns what it finds there. If no value is found, the listener responds with 404 Not Found.

If a request returns an async tree, we redirect to an index.html value inside that tree. E.g., in our sample deep object and file trees, we have a subfolder called more. If someone navigates to the path more, that request will return the corresponding subtree. We then redirect to more/, which will ultimately render the page at more/index.html.

Serve the tree

Finally, we start the server at a default port.

/* src/deep/serve.js */

import http from "node:http";
import siteTree from "./siteTree.js";

const port = 5000;

/* …Plus the above code fragments… */

// Start the server.
const server = http.createServer(requestListener(siteTree));
server.listen(port, undefined, () => {
  console.log(
    `Server running at http://localhost:${port}. Press Ctrl+C to stop.`
  );
});

To add a layer of flexibility, we’ll serve the tree defined in a new file called siteTree.js. This file exports whichever tree of transformed HTML we’d like to use, as defined in htmlObject.js, htmlFiles.js, or htmlFn.js. To use the files-backed tree:

/* src/deep/siteTree.js */

export { default } from "./htmlFiles.js";

Trying our server

From inside the src/deep directory, start the server:

$ node serve
Server running at http://localhost:5000. Press Ctrl+C to stop.

Browse to that local server. The site root won’t find an index page, so the serve will initially return Not Found.

If you want, you can define an index page at markdown/index.md, and then immediately browse it at the site’s root. But we’ll also add default index pages in a minute.

In the browser preview, add Alice.html to the URL to see the expected HTML page for Alice:

Hello, Alice.

We defined the markdown-to-HTML transform such that, if it’s asked for a key that doesn’t end in .html, it will ask the inner tree for the corresponding value and return that value as is. One ramification of that is that, if we can ask the server for a markdown file, it will obtain that from the inner markdown tree.

Browse to Alice.md to see the original markdown content.

Hello, **Alice**.

async trees are lazy

async trees are lazy by nature. When you start the server, no real work is done beyond starting the HTTP listener.

The tree only generates the HTML when you ask for it by browsing to a page like Alice.html:

  1. The server asks the HTML tree for Alice.html.
  2. The transform defining the HTML tree asks the inner markdown tree for Alice.md.
  3. The inner markdown tree asks the file system for the content of Alice.md.
  4. The transform converts the markdown content to HTML.
  5. The listener responds with the HTML content.

Flexible

This server is already pretty interesting! We’ve got a simple site, but can flexibly change the representation of the data. Having done relatively little work, we can let our team write content in markdown. Unlike many markdown-to-HTML solutions, the translation is happening at runtime, so an author can immediately view the result of markdown changes by refreshing the corresponding page.

Each of our underlying object, file, or function-based trees has its advantages. For example, we can serve our function-based tree to browse HTML pages which are generated on demand.

Edit src/deep/siteTree.js to export the function-based tree from htmlFn.js instead of htmlFiles.js.

Restart the server with node serve.

Observe that, this time, an index page is shown — albeit a pretty strange one that says

Hello, index.

The served tree is responding to index.html by asking the function-based markdown tree for index.md, and the underlying function is dutifully generating a markdown file for that “name”.

Add .html to your own name, like Sara.html, and try putting that in the address bar.

Hello, Sara.

In the browser preview, you can also navigate to the corresponding Sara.md to view the markdown generated by the inner function-based markdown tree.

Hello, **Sara*..

Before moving on, in the terminal window, stop the server by pressing Ctrl+C.

 

Next: Index pages »