Skip to content

Instantly share code, notes, and snippets.

@baetheus
Last active October 23, 2023 03:58
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save baetheus/a4ff018238614e88bf83a73f0e649228 to your computer and use it in GitHub Desktop.
Save baetheus/a4ff018238614e88bf83a73f0e649228 to your computer and use it in GitHub Desktop.
Basic Router
/** @jsx h */
import { pipe } from "fun/fn.ts";
import { h } from "https://esm.sh/preact@10.18.1";
import * as R from "../router.ts";
import { jsx } from "../jsx.ts";
type State = { count: number };
function Count({ count }: State) {
return <h1>Count is {count}</h1>;
}
const handler = pipe(
R.router<State>(),
R.handle(
"GET /count",
({ state }) => {
state.count++;
return jsx(<Count {...state} />);
},
),
R.handle("POST /proxy", async ({ request }) => {
const body = await request.text().catch(() => "https://bee.ignoble.dev");
return fetch(body);
}),
R.use({ count: 0 }),
);
Deno.serve(handler);
import type { VNode } from "https://esm.sh/preact@10.18.1";
import { render } from "https://esm.sh/preact-render-to-string@6.2.2";
import { html } from "./response.ts";
export function jsx(vnode: VNode): Response {
return html(render(vnode));
}
/**
* Responses
*/
export function html(html: string): Response {
return new Response(html, {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
import type { Option } from "fun/option.ts";
import * as A from "fun/array.ts";
import * as O from "fun/option.ts";
/**
* Accepted HTTP Verbs in Route String
*/
type HttpVerbs =
| "GET"
| "HEAD"
| "POST"
| "PUT"
| "DELETE"
| "CONNECT"
| "OPTIONS"
| "TRACE"
| "PATCH";
/**
* Given a const string return a single key object with a string value at that
* key.
*/
type Rec<Key extends string = string> = { readonly [K in Key]: string };
/**
* The format of a route string
*/
type RouteString = `${HttpVerbs} /${string}`;
/**
* Parse variables from RouteString at the type level
*/
type ParseVars<
P extends string,
// deno-lint-ignore ban-types
R extends Record<string, string> = {},
> = P extends `${HttpVerbs} /${infer Part}` ? ParseVars<Part>
: P extends `:${infer Key}/${infer Part}` ? ParseVars<Part, R & Rec<Key>>
: P extends `${infer _}/${infer Part}` ? ParseVars<Part, R>
: P extends `:${infer Key}` ? ParseVars<"", R & Rec<Key>>
: { readonly [K in keyof R]: string };
/**
* A route parser is a function that takes a verb and a split path from a
* request and returns an Option containing possible path variables. None
* implies that the path does not match and some implies that it does.
*/
type RouteParser<V> = (
verb: HttpVerbs,
split: readonly string[],
) => Option<V>;
/**
* Parse a route into (HttpVerb, path[]) => Option<Variables>
*/
export function routeParser<In extends RouteString>(
route: In,
): RouteParser<ParseVars<In>> {
const [verb, rest] = route.split(" ");
const words = rest.split("/");
return (v, s) => {
if (verb !== v.toUpperCase()) {
return O.none;
} else if (words.length !== s.length) {
return O.none;
} else {
const vars: Record<string, string> = {};
for (let i = 0; i < words.length; i++) {
const left = words[i];
const right = s[i];
// Set variable in vars record
if (left.startsWith(":")) {
vars[left.slice(1)] = right;
continue;
}
// Bail early if not a variable and route doesn't match
if (left.toUpperCase() !== right.toUpperCase()) {
return O.none;
}
}
return O.some(vars as ParseVars<In>);
}
};
}
/**
* Context for a request.
*/
export type Context<V, S> = {
readonly request: Request;
readonly variables: V;
readonly state: S;
};
export function context<V, S>(
request: Request,
variables: V,
state: S,
): Context<V, S> {
return { request, variables, state };
}
/**
* Handle a request with Context as input.
*/
export type Handler<Vars, S = unknown> = (
ctx: Context<Vars, S>,
) => Response | Promise<Response>;
/**
* A Route is a combination of a RouteString, a RouteParser, and a Handler.
*/
export type Route<V, S> = {
readonly route: RouteString;
readonly parser: RouteParser<V>;
readonly handler: Handler<V, S>;
};
export function route<V, S>(
route: RouteString,
parser: RouteParser<V>,
handler: Handler<V, S>,
): Route<V, S> {
return { route, parser, handler };
}
/**
* A Router is an Array of Routes that all use the same state.
*/
// deno-lint-ignore no-explicit-any
export type Router<S = unknown> = readonly Route<any, S>[];
export function router<S>(): Router<S> {
return [];
}
/**
* The handle function parses variables out of a RouteString and uses them to
* construct the types for a handler function that is also passed in.
*/
export function handle<R extends RouteString, S>(
routeString: R,
handler: Handler<ParseVars<R>, S>,
): (router: Router<S>) => Router<S> {
const parser = routeParser(routeString);
return A.append(route(routeString, parser, handler));
}
const NotFound = new Response("Not Found", { status: 404 });
/**
* The use function takes an initial state, then a router, and uses it to
* construct a Deno.ServeHandler for use with Deno.serve.
*/
export function use<S>(
state: S,
): (router: Router<S>) => Deno.ServeHandler {
return (router) => (request) => {
const verb = request.method.toUpperCase() as HttpVerbs;
const url = new URL(request.url);
const path = url.pathname.split("/");
for (const route of router) {
const variables = route.parser(verb, path);
if (O.isNone(variables)) {
continue;
}
return route.handler(context(request, variables.value, state));
}
return NotFound;
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment