cfweb

A Cloudflare Workers web framework

About

cfweb is a simplistic web framework for jamming a web app in to Cloudflare Workers. The basic goals are:

Usage

The cfweb repo provides an example application. The source is heavily commented to explain all the major features of cfweb, and for convenience the documentation/source code is presented below.

The cfweb-ex worker is actually running on this site right now! Go ahead: try it!

import {App} from "@oefd/cfweb";

const hello_user =
    async (_event: FetchEvent, match: RegExpMatchArray) => {
        // The second argument to handlers is the regex match from whichever
        // route path caused this handler to be called. Since there's a named
        // captured group in the route path `/^\/user\/(?\w+)$/` we're
        // able to pull the id group out from the match.
        const id = match.groups?.["id"] as string;
        const name = `${id.charAt(0).toLocaleUpperCase()}${id.slice(1)}`;


        // we can use arbitrary files from the origin as a template to fill
        // in with the HTMLRewriter so we don't have to keep large HTML blobs
        // in the worker, or keep the HTML synced between the worker and origin
        const alice_req = new Request("https://cfweb.oefd.net/hello/alice.html");
        const origin_html = await fetch(alice_req);
        return new HTMLRewriter()
            .on("main > h1", {
                element: function (element: Element) {
                    element.setInnerContent(`Hello, ${name}!`);
                }
            })
            .on("main > p", {
                element: function (element: Element) {
                    element.setInnerContent(
                        "The HTML was pulled from the origin "
                        + "but edited by an HTMLRewriter.");
                }
                }
            })
            .transform(origin_html);
    };

const get_origin =
    (event: FetchEvent, _match: RegExpMatchArray) => {
        // Handler to defer to the origin to respond. Response middlewares are
        // still run and get a chance to modify the origin's response.
        //
        // Note `fetch` returns a Promise not just a Response - either
        // is fine to return from a handler.
        return fetch(event.request);
    }

const hello_endpoint =
    (_event: FetchEvent, _match: RegExpMatchArray) => {
        // this will end up being caught by the error_handler
        throw new Error("TODO: implement POST for /hello/");
    };

const error_to_500_response_handler =
    (_event: FetchEvent, exception: any) => {
        // Catch any exceptions throws by handlers and
        // deal with them by returning a 500 error.
        return new Response(null, {
            status: 500,
            headers: new Headers({"X-Error-Msg": exception.message}),
        })
    };

const add_watermark_middleware =
    (_event: FetchEvent, response: Response) => {
        const new_headers = new Headers(response.headers);
        new_headers.set("X-Worker-Processed", "yes");
        return new Response(response.body, {
            status: response.status,
            statusText: response.statusText,
            headers: new_headers,
        });
    };

const make_errors_pretty =
    async (_event: FetchEvent, response: Response) => {
        // Usually you want to have some HTML to accompany 404 errors and other
        // errors so users aren't left staring at the browser default error page,
        // but embedding a big blob of HTML in the worker is not ideal - especially
        // if the worker only operates on some parts of the web app and you want
        // to keep the look of the 404 page consistent between the worker's
        // responses and the origin's responses.
        //
        // A middleware to inject the origin's own 404 body is a good solution
        // because now you don't need to synchronize the error html between the
        // worker and web app, and Cloudflare still caches responses from the
        // origin so there's generally no added latency because there's usually
        // no extra hop to the origin in fetching a static page from the origin.
        if ([404, 405, 500].includes(response.status)) {
            const page = await fetch(
              `https://cfweb.oefd.net/_${response.status}.html`
            );
            return new Response(page.body, {
                status: response.status,
                statusText: response.statusText,
                headers: response.headers,
            });
        } else {
            return response;
        }
    };

const app = new App({
    error_handler: error_to_500_response_handler,
    // response middlwares are called with the event and whatever response was
    // created by the appropriate handler for the request. If there are multiple
    // middlwares each is called in the order specified here.
    response_middleware: [add_watermark_middleware, make_errors_pretty],
    routes: [
        // Matching a request path to a route is done in the order they're
        // specified here. Only the first match's handler is called, so it's
        // possible to put a more specific match (like `/user/alice`) before a
        // a rule that matches more broadly (like `/user/`).
        //
        // If the first route with a matching path regex does not indicate it
        // supports the method of the request a default response is generated
        // with a `405 Method Not Allowed` status and an allow header with the 
        // methods your route says it supports.
        //
        // There are two exceptions for implicitly handling methods:
        //
        // * If your route supports GET, but not HEAD, an implict head handler
        //   is called which calls your GET handler then strips the body off
        //   the response before returning it.
        // * If your route does not explicitly support OPTIONS an implicit
        //   handler is called which responds with `204 No Content` including
        //   an allow header with the methods your route says it supports.
        {
            paths: [/^\/hello\/alice$/, /^\/hello\/bob$/],
            methods: ["GET"],
            handler: get_origin,
        },
        {
            paths: [/^\/hello\/(?\w+)$/],
            methods: ["GET"],
            handler: hello_user,
        },
        {
            paths: [/^\/hello\/$/],
            methods: ["POST"],
            handler: hello_endpoint,
        },
        // If no routes match the incoming request path the request
        // is implictly passed to the origin to get a response, and
        // response middleware still runs on the origin's response.
    ],
});

addEventListener("fetch", function (ev: FetchEvent) {
    ev.respondWith(app.handle(ev));
});