Skip to main content

Cav

An experimental server framework for Deno.

NOTE: This project is currently being rebuilt from the ground up. See here for the latest release. All published versions are prototypes and should NOT be used in production.

Features:

  • Declarative request routing
  • Flexible body parsing
  • Static file serving
  • Custom context
  • Signed cookies
  • Web socket support
  • Client fetch
  • End-to-end type safety
  • Runtime TypeScript bundling

The rest of this readme is a temporary home for the rough draft of the getting started guide.

Setup

Cav 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>;

Cav’s Router (described below) accepts handlers that implement this standard type while providing a few extra properties on the second argument:

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

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

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

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

The url property is a URL instance of the full request url. The path property is the unrouted portion 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 path equal to the full request path, 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, and the path will shrink from the left hand side as path segments are “consumed.”

Note: Because of the way the Handler 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 Cav Handlers (using the request path), use the router factory function:

// https://deno.land/x/cav/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, they can be served using the standard library:

#!/usr/bin/env deno run --allow-net
import { serve, router } from "https://deno.land/x/cav/mod.ts";

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

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 });
  },
});
// Listening on http://localhost:8000/

(The serve function is a re-export from the standard library’s HTTP module, for convenience.)

In the example above, a request to either localhost:8000/hello or localhost:8000/goodbye will return the appropriate response and any other request path will result in a 404 not found error being returned to the client.

Path matching

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

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

Paths are sorted during router construction based on path depth, with static path segments taking precedence over parameter segments. 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:

const app = router({
  "a/:b/*": (_, context) => {
    return new Response(`B: ${context.param.b}, Path: ${context.path}`);
  },
});

A request to the router above with the pathname /a/b/c/d/ will result in the text B: b, Path: c/d/ being sent back.

Here’s some other router path rules:

  1. Paths can’t contain trailing, leading, 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() },
  ":b": () => 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:

// serveAssetOr404 defined elsewhere

const app = router({
  "api/hello": () => new Response("hello world"),
  "*": [
    (_, context) => serveAssetOr404("assetsDir1", context.path),
    (_, context) => serveAssetOr404("assetsDir2", context.path),
  ],
});

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"),
  }),
});

To be continued