DEV Community

ndesmic
ndesmic

Posted on • Updated on

Building a minimal web dev server with Deno

Writing pure client-side apps is good in theory but you still need a local server as file-based urls are heavily restricted. The easiest way to deal with this is to use a file server. There are a number of perfectly fine options. I like the http-server module in node but Deno also has a perfectly acceptable option https://deno.land/manual@v1.31.1/examples/file_server#using-the-stdhttp-file-server, there's also plenty of other options in python etc. These all have a simple task, take files on your hard-drive and serve them on a web server so you can access them with urls.

Since there are so many good options if all you need is files we don't even have to bother with doing this ourselves but sometime web development requires a bit more, so that's what I want to build. Something simple to start but maybe something to expand upon later to demonstrate various capabilities.

The server that we'll be making can serve files but you can also write file-based handlers as well. I'm choosing Deno here because it has a fast server that adheres to web APIs and some helpful things in the standard library so we don't need to go find rando packages with a zillion dependencies.

Just getting a response

So to start let's just make a hello world app server.

Deno.serve(req => {
    return new Response("Hello World!", {
        headers: {
            "content-type" : "text/plain"
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

As of this writing you'll need the --unstable flag as the native http server isn't 100% stable, but I also don't expect it to change much either. This should be straight-forward. We have a single handler that take a request and returns a response.

Reading a file

Next let's read in a file:

Deno.serve(req => {
    const file = await Deno.open("./index.html");
    return new Response(file.readable, {
        headers: {
            "content-type" : "text/plain"
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

You can put whatever in index.html. Note that we don't use Deno.readTextFile, instead we open the file and stream it to the response which is better for performance.

Some basic routing

We can add some basic url routing:

Deno.serve(req => {
    const file = await Deno.open(new URL(req.url).pathname);
    return new Response(file.readable, {
        headers: {
            "content-type" : "text/plain"
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

This maps the url path to the file path. This isn't great though, aside from possible security issues web urls aren't well formed with extensions. For instance, what does the root localhost:9000/ point to? The convention since the dawn of time has been to use index.html in this place. Otherwise the lookup just fails and we return a 404. But how do we know when to use index? It's when the path ends with /.

Index files

We can add support for that too.

import { typeByExtension } from "https://deno.land/std/media_types/mod.ts";
import { extname } from "https://deno.land/std/path/mod.ts";

Deno.serve(req => {
    let path = new URL(req.url).pathname;

    if(path.endsWith("/")){
        path += "index.html";
    }
    try {
        const file = await Deno.open(path);
    } catch(ex){
        if(ex.code === "ENOENT"){
            return new Response("Not Found", { status: 404 });
        }
        return new Response("Internal Server Error", { status: 500 });
    }
    return new Response(file.readable, {
        headers: {
            "content-type" : typeByExtension(extname(path))
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

If the path ends in / then add index.html to it. This works for nested paths too. If there's no file that matches the path we serve a 404 error (if there's some other problem reading it it's a 500). We also update the content mime-type based on the extension so the browser knows what to do with it.

Handlers

How what about things that aren't flat files? What if we want handlers? Here it gets more complicated. What I want to do is have certain js files act as handlers themselves. This poses a problem since we also want to pass back static js to the client. So I propose an extra extension to differentiate the two .js for client js, and .server.js for js files that execute server-side (most frameworks get around this by segmenting static assets and handlers by folder which I feel is odd because from the url perspective these should all be in the same path and there's no reason they can't live together). I also want index.server.js as a possibility in-case index.html is not found. I also want extensionless file paths to resolve to either {path}.html or {path}.server.js. To do this I will use a function that checks a list of paths and finds the first one that exists.

//file-utils.js
export async function probeStat(filepaths){
    for(const filepath of filepaths){
        try {
            const fileInfo = await Deno.stat(filepath);
            return [fileInfo, filepath];
        } catch (ex){
            if(ex.code === "ENOENT") continue;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This will stat (get file metadata) for the path, if it exists then we use it, otherwise we try the next one. If all fail then this returns null. The return type is a little strange, it's a tuple of fileInfo and filepath. This is because the fileInfo does not contain the path, but we want to know which path it was that ultimately matched. One last things I want to do is to constrain the path to a folder so the user can't do things like read our source code. We'll set a base path ./routes where all the file are.

import { typeByExtension } from "https://deno.land/std/media_types/mod.ts";
import { extname } from "https://deno.land/std/path/mod.ts";
import { probeStat } from "./utils/fs-utils.js";

const baseDir = "./routes";

Deno.serve(async req => {
    const url = new URL(req.url);
    let inputPath = url.pathname;
    const filePaths = [];

    //normalize path
    if (inputPath.endsWith("/")) {
        inputPath += "index";
    }
    if(!inputPath.includes(".")){
        filePaths.push(...[".html", ".server.js"].map(ext => baseDir + inputPath + ext));
    } else {
        const path = baseDir + inputPath;
        filePaths.push(path);
    }

    //find
    const fileMatch = await probeStat(filePaths);
    if(!fileMatch){
        return new Response("Not Found", { status: 404 });
    }

    //read or execute
    const ext = extname(fileMatch[1]);

    switch(ext){
        case ".js": { 
            if(fileMatch[1].endsWith(".server.js")){
                const mod = await import(fileMatch[1]);
                return await mod.default(req);
            }
        }
        // falls through
        default: {
            const file = await Deno.open(fileMatch[1]);
            return new Response(file.readable, {
                headers: {
                    "Content-Type": typeByExtension(ext)
                }
            });
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

All we have to do is use a dynamic import to get the matching module. The handler will use the default export from the module. This will work for nested routes too! One thing to note is that the user can access a handler both with the extensionless route (eg ./api) or the extension route (eg ./route/api.server.js). So far I haven't found any reason to care if they use the explicit route so I didn't disable it.

HTTP verbs

Another thing we can add is the ability to handle other HTTP verbs besides GET. We can do this by exporting functions with the name of the verb.

case ".js": { 
    if(fileMatch[1].endsWith(".server.js")){
        const mod = await import(fileMatch[1]);
        if(req.method === "GET"){
            return mod.get?.(req) ?? mod.default?.(req)
                ?? new Response("Method not allowed", { status: 405 });
        } else if (req.method === "DELETE"){
            return mod.del?.(req)
                ?? new Response("Method not allowed", { status: 405 });
        } else {
            return mod[req.method.toLowerCase()]?.(req) 
                ?? new Response("Method not allowed", { status: 405 });
        }
    }
}
// falls through
Enter fullscreen mode Exit fullscreen mode

This will match the verb with the appropriate method. eg:

//handler.server.js

export default get(req){ ... }
export post(req){ ... }
export put(req){ ... }
export del(req){ ... }
Enter fullscreen mode Exit fullscreen mode

Some things to note. If the module exports get we try that first for get before moving to default. In the case of DELETE we can't use delete as a function name because it conflicts with the javascript keyword. Instead we'll call it del so we need a special case. Otherwise we try to use the lower-cased method name. If it doesn't exist we send back a 405 error saying we don't support that. I'm not worrying about which ones take request bodies or anything like that.

TS/JSX/TSX

Since we're using Deno this becomes really easy since it's all built-in. At this point JSX/TSX probably isn't super useful to us, but lots of people enjoy typescript and if we get it for free then why not?

We need to expand our search path options when we have a bare file path:

if(!inputPath.includes(".")){
        filePaths.push(...[".html", ".server.js", ".server.ts", ".server.jsx", ".server.tsx"].map(ext => baseDir + inputPath + ext));
}
Enter fullscreen mode Exit fullscreen mode

Then if we find one the server files we import it like usual.

switch(ext){
    case ".js": 
    case ".ts":
    case ".tsx":
    case ".jsx": { 
        if(/\.server\.(js|ts|jsx|tsx)/.test(fileMatch[1])){
            const mod = await import(fileMatch[1]);
            if(req.method === "GET"){
                return mod.get?.(req) ?? mod.default?.(req)
                    ?? new Response("Method not allowed", { status: 405 });
            } else if (req.method === "DELETE"){
                return mod.del?.(req)
                    ?? new Response("Method not allowed", { status: 405 });
            } else {
                return mod[req.method.toLowerCase()]?.(req) 
                    ?? new Response("Method not allowed", { status: 405 });
            }
        }
    }
    // falls through
//...etc
Enter fullscreen mode Exit fullscreen mode

I'm not especially happy with how this looks because the extensions are maintained in 3 places but this list is unlikely to ever change unless we create another popular flavor of JS (please no) so it's fine for now.

With that I think we have a pretty decent start to a dev server that can handle files and APIs. There's definitely some more fun features we could add and maybe we'll take a look at those next time.

You can find the code for this post here: https://github.com/ndesmic/dev-server/tree/v1

Top comments (0)