Skip to main content
Deno 2 is finally here šŸŽ‰ļø
Learn more

Melodic

An experimental server framework for Deno. It aims to provide a pleasant developer experience with an easy-to-remember API.

NOTE: This isnā€™t ready for production yet.

Features so far:

  • Declarative request routing
  • Static assets serving

The rest of this readme will serve as a getting started guide for now.

Setup

The only requirement is to install the latest version of Deno.

Melodic builds on standard Deno concepts. If you havenā€™t already, read through the manual to get acquainted. Further, the Deno Deploy documentation is helpful for learning more about handling HTTP with Denoā€™s standard library.

Routing requests

Handlers

Denoā€™s http module exports the following handler type:

// https://deno.land/std/http/server.ts

export interface ConnInfo {
  readonly localAddr: Deno.Addr;
  readonly remoteAddr: Deno.Addr;
}

export type Handler = (
  request: Request,
  connInfo: ConnInfo,
) => Response | Promise<Response>;

Melodicā€™s Router accepts handlers which implement this standard type while providing a few extra properties on the second argument:

// https://deno.land/x/melodic/router.ts

// ConnInfo comes from Deno's http module
import type { ConnInfo } from "./deps.ts";

export interface Context extends ConnInfo {
  readonly url: URL;
  readonly routed: string;
  readonly unrouted: string;
  readonly param: { readonly [x: string]: string };
}

export type RouterHandler = (
  req: Request,
  context: Context,
) => Response | Promise<Response>;

The url property is a URL instance of the full request url. The routed and unrouted properties are the routed/unrouted portions of the request path, and the param property is for path parameters captured during routing. The first Router to encounter a request will set the unrouted path equal to the full request path, the routed path equal to an empty string, and the param object will be empty. As the request is routed through the handler tree, these properties will be updated; newly captured path parameters will merge with previously captured parameters, the routed path grow from the right, and the unrouted path will shrink from the left hand side as path segments are ā€œconsumed.ā€

Note: Because of the way the RouterHandler type is defined, Routers also accept standard Deno handlers, such as those in the Deno Deploy examples gallery.

Declaring routers

To create a standard Deno handler for routing requests between RouterHandlers (using the request path), use the router factory function:

// https://deno.land/x/melodic/router.ts

import type { ConnInfo } from "./deps.ts";

export interface RouterShape {
  // The keys are the route path
  [x: string]: Handler | Handler[] | null;
}

export type Router<Shape extends RouterShape> = (
  & Shape
  & ((req: Request, connInfo: ConnInfo) => Promise<Response>)
);

export function router<Shape extends RouterShape>(shape: Shape): Router<Shape> {
  // ...
}

Because the created routers are standard Deno handlers, you can serve them with the standard library:

#!/usr/bin/env deno run --allow-net --allow-read=assets

import { serve } from "https://deno.land/std/http/mod.ts";
import { serveFile } from "https://deno.land/std/http/file_server.ts";
import { router } from "https://deno.land/x/melodic/mod.ts";

const app = router({
  "hello.jpg": (req, context) => serveFile(req, "assets/hello.jpg"),
});

serve(app, {
  onError: err => {
    if (err instanceof Deno.errors.NotFound) {
      return new Response("404 not found", { status: 404 });
    }
    console.error(err);
    return new Response("500 internal server error", { status: 500 });
  },
});

In the app above, a request to localhost:8000/hello.jpg will return the image at assets/hello.jpg, relative to the current working directory of the Deno process. Any other request will result in a plaintext 404 not found response.

Route paths

The router path syntax is partially inspired by the URLPattern syntax, but only basic path parameters are supported:

const app = router({
  ":example": (_, context) => new Response(`Example: ${context.param.example}`),
  ":a/b/:c": (_, context) => {
    return new Response(`A: ${context.param.a}, C: ${context.param.c}`);
  },
});

A request with the path /a/b/c passed into the router above will return a response with the text A: a, C: c, and if the request path was /example, it would return Example: example.

Paths can end in a * segment, which allows them to match partially with a request. The unmatched portion of the path is forwarded to the next handler on the context object as the unrouted property:

const app = router({
  "a/b/*": (_, context) => {
    return new Response(
      `Routed: ${context.routed}, Unrouted: ${context.unrouted}`,
    );
  },
});

A request to the router above with the pathname /a/b/c/d/ will result in the text Routed: a/b, Unrouted: c/d/index being sent back. (More on the index part later.)

The wildcard doesnā€™t require remaining path segments to be present in order for the route to match with a request. If a request with the path /a/b was sent to the router above, a response with the text Routed: a/b, Unrouted: will be sent back. (The context.unrouted is an empty string in this case.)

When a wildcard route conflicts with a non-wildcard route, the non-wildcard route will match first. In the following router, a request with the path /a/b will return the text hello world:

const app = router({
  "a/b": () => new Response("hello world"),
  "a/b/*": () => new Response("goodbye world"),
});

Hereā€™s some other router path rules:

  1. Route paths canā€™t contain leading, trailing, or duplicate slashes
  2. The .. and . path segments canā€™t be used (theyā€™d never match)
  3. Wildcards can only show up at the end of a path as the last segment, and they must appear alone, i.e. *hello is an invalid path segment
  4. Path parameters can have any name, as long as their path segment starts with a :
  5. Paths donā€™t match against the whole URL. Only the pathname is used during routing

404s

To tell the router to continue matching from inside a handler, throw an instance of Deno.errors.NotFound. In the example below, a request to /a will return a plaintext b response:

const app = router({
  "a": () => { throw new Deno.errors.NotFound() },
  "*": () => new Response("b"),
});

Note: If the request has a body and the body is consumed before a NotFound error is thrown, path matching will not continue and the error will be rethrown inside the router.

Handler arrays

When multiple handlers need to be registered at the same path, use an array of handlers. Each handler is attempted until one of them returns a response instead of throwing a NotFound error:

const app = router({
  "api/hello": () => new Response("hello world"),
  "*": [
    (req, context) => serveFile(req, stdPath.join("assets1", context.unrouted)),
    (req, context) => serveFile(req, stdPath.join("assets2", context.unrouted)),
  ],
});

Router composition

Constructed router handlers have the same properties as the RouterShapes used to create them, allowing routers to build on one another as if they were regular JavaScript objects:

const helloApp = router({
  "hello": () => new Response("hello world"),
});

const goodbyeApp = router({
  "goodbye": () => new Response("goodbye world"),
});

const app = router({
  ...helloApp,
  ...goodbyeApp,
});

null is allowed as a handler value, letting you turn off certain routes when composing routers:

const v1 = router({
  "foo": () => new Response("hello world"),
  "bar": () => new Response("goodbye world"),
});

const v2 = router({
  ...v1,
  "foo": null,
});

Further, router nesting is a natural consequence of them being Deno handlers. Below, a request to /foo/bar will return a plaintext baz response:

const app = router({
  "foo/*": router({
    "bar": () => new Response("baz"),
  }),
});

Indexes

When thereā€™s a trailing slash on a requested path, an index segment will be automatically appended during routing. The following will return a plaintext hello world when the request path is /blog/:

const app = router({
  "blog/*": router({
    "index": () => new Response("hello world"),
  }),
});

Routers can also have an empty route (""), allowing the trailing slash to be omitted. This time, hello world is returned when the request path is /blog:

const app = router({
  "blog/*": router({
    "": () => new Response("hello world"),
  }),
});

The root path is special. Because the leading slash is always inferred when a request is made, both the index and the empty route "" will be checked iff the unrouted request path is /. A request to the root path in the following app will return hello world:

const app = router({
  "": () => new Response("hello world"),
});

Likewise, this app will do the same thing:

const app = router({
  "index": () => new Response("hello world"),
});

The index route is matched before the empty route in these cases. The following will send back a hello (not goodbye) when the request path is /:

const app = router({
  "index": () => new Response("hello"),
  "": () => new Response("goodbye"),
});

Note that above router wonā€™t behave the same if itā€™s nested inside a different router and thereā€™s no trailing slash on the request path. The following router will respond with goodbye if the request path is /nested:

const app = router({
  "nested/*": router({
    "index": () => new Response("hello"),
    "": () => new Response("goodbye"),
  }),
});

Tip: Avoid using both index routes and empty routes in your application. Pick one and stick with it. If you like trailing slashes on your URLs, use index, and if not, use an empty route.

Serving static assets

Creating asset handlers

To create a handler specifically for serving static assets from a directory, use the assets function:

import { assets } from "https://deno.land/x/melodic/mod.ts";
import { serve } from "https://deno.land/std/http/mod.ts";

const app = assets();

serve(app, {
  onError: err => {
    if (err instanceof Deno.errors.NotFound) {
      return new Response("404 not found", { status: 404 });
    }

    console.error("Uncaught exception:", err);
    return new Response("500 internal server error", { status: 500 });
  },
});

The above app will serve assets from the assets directory relative to the cwd of the Deno process. The path of the file to serve comes from the context.unrouted path, in this case the full request path.

A string argument can be specified to change the directory to serve from. (assets is only the default.) The following will serve files from the public directory:

const app = assets("public");

Additionally, you can specify file:// urls if you want to use import.meta.resolve to calculate the path relative to the current file. The following app will serve from the public directory in the same parent folder as the script itself:

const app = assets(import.meta.resolve("./public"));

Auto-append HTML extensions

When serving assets, the assets handler will first try to serve the path as-is. If that results in a 404, it will try to serve the same path with a ā€œ.htmlā€ extension attached. For example, if thereā€™s an index.html file in the public folder for the following app:

const app = router({
  "*": assets("public"),
});

ā€¦ and the request path is the root path /, the unrouted path will be index and the assets handler will serve the public/index.html file as expected.

Automatic redirects

When a requested path is mistyped due to a missing/extraneous trailing slash, the assets handler will redirect to the proper path.

For example, if the assets/test.html file exists in the assets folder and the request path is /test/, it will be redirected to the same path without the trailing slash. Likewise, if the assets/test/index.html file exists and the request path is /test, it will be redirected to the same path with the trailing slash.

Usage inside routers

Assets handlers can be used in routers, as seen previously. The path to the file served comes from the Context passed down from the router, i.e. the unrouted path is the path used when finding the file to serve. If there was a public/test/index.html file in the same parent folder as the following app, it would be served when the request path is /nested/test/:

const app = router({
  "nested/*": assets(import.meta.resolve("./public")),
});

To be continued