Skip to content

Instantly share code, notes, and snippets.

@bever1337
Last active May 14, 2022 20:06
Show Gist options
  • Save bever1337/09ba6f0f8ff469acde47743e30191960 to your computer and use it in GitHub Desktop.
Save bever1337/09ba6f0f8ff469acde47743e30191960 to your computer and use it in GitHub Desktop.
A request router for browser and edge environments

Request Response Router

API

Router

A Router is a stateful container for an array of (Request, Response) tuples.

The following:

import { Router } from "request-router/router";

const router = new Router();
router.handle(
  new Request("http://example.com/baz"),
  () => new Response(null, { status: 200 })
);
router.match(new Request("http://example.com/baz"));

Is identical to:

import { matchAll } from "request-router";

const router = [];
router.push([
  new Request("http://example.com/baz"),
  () => new Response(null, { status: 200 }),
]);
matchAll(router, new Request("http://example.com/baz"));

Router constructor

import { Router } from "request-router/router";

const router = new Router();

Router handle

Routes are a tuple of Request and Response resolvers. The request is used for route matching. If requests match, then the Response resolver will be invoked. Response handlers MUST resolve Response or Promise<Response>.

// Static route matching
router.handle(
  new Request("http://example.com/foo"),
  (request) => new Response(null, { status: 200 })
);

router.handle(
  new Request("http://example.com/bar", { method: "PUT" }),
  (request) => new Response(null, { status: 301 })
);

// Dynamic route matching
router.handle(
  (request) => new Request("http://example.com/baz"),
  (request) => new Response(null, { status: 200 })
);

// This response handler will never be invoked
router.handle(
  (request) => undefined,
  (request) => new Response(null, { status: 500 })
);

// Wildcard, always match a route
router.handle(undefined, (request) => new Response(null, { status: 404 }));
// Wildcard is equivalent to, but faster than:
router.handle(
  (request) => request,
  () => new Response(null, { status: 404 })
);

Router match

(request: Request, options?: RouterOptions) => (Response | Promise<Response>)[]

Returns an array of Response or Promise<Response> where the Request portion of the tuple matches the argument to match. Matching is not greedy.

router.match(new Request("http://example.com/foo"));
// [{ status: 200, ... }, { status: 404, ... }]

router.match(new Request("http://example.com/apples"));
// [{ status: 404, ... }]

router.match(new Request("http://example.com/bar"));
// [{ status: 404, ... }]

router.match(new Request("http://example.com/bar", { method: "PUT" }));
// [{ status: 301, ... }, { status: 404, ...}]

router.match(new Request("http://example.com/bar"), { ignoreMethod: true });
// [{ status: 301, ... }, { status: 404, ...}]

getOptions

Internal API used to set default values on input options. See RouterOptions below.

(options?: RouterOptions) => { [key in keyof CacheQueryOptions]-?: CacheQueryOptions[key]; } & { excludeFragment: boolean }

matchAll

See the match method above for more examples. This API is useful if an array of routes is preferred to the Router interface.

(handlers: HandlerTuple[], queryRequest: Request, options?: RouterOptions) => (Response | Promise<Response>)[]

requestsMatch

Internal API used to compare two Requests given RouterOptions. See RouterOptions below for more explanation of options.

(queryRequest: Request, handlerRequest: Request, options?: RouterOptions) =>
  boolean;

(Request, Response) Router interface

HandlerTuple

A HandlerTuple represents a possible Request match and its associated Response resolver. A Router instance maintains an array of HandlerTuple on its handlers property.

type HandlerTuple = [RequestOrHandler, ResponseHandler];

RequestOrHandler

RequestOrHandler optionally provides Request instances for Router matching. It may be three possible values:

  1. RequestHandler - See RequestHandler type below
  2. Request - Matching is always performed against the provided Request
  3. undefined - Indicates the ResponseHandler portion of this tuple is a wildcard to be invoked for every query match
type RequestOrHandler = RequestHandler | Request | undefined;

RequestHandler

The RequestHandler optionally returns a Request for router matching. Returning undefined instructs the router to skip the current (Request, Response) tuple. Its single parameter, request, is the Request object being routed.

type RequestHandler = (request: Request) => Request | undefined;

ResponseHandler

ResponseHandler is invoked when the RequestOrHandler portion of the (Request, Response) tuple was matched. Its single parameter, request, is the Request object being routed. ResponseHandler MUST return a Response or Promise<Response>.

type ResponseHandler = (request: Request) => Response | Promise<Response>;

RouterOptions

Based on CacheQueryOptions. RouterOptions also exposes an implicit option from the Cache API, excludeFragment. Each flag is used to ignore a specific portion of a Request during matching.

type RouterOptions = {
  /**
   * The fragment (AKA hash) portion of a URL.
   * When true, allows `new Request("example.com#foo")` to match `new Request("example.com")
   */
  excludeFragment: boolean;
  /**
   * The search parameters (AKA query) portion of a URL
   * When true, allows `new Request("example.com?foo=bar")` to match `new Request("example.com")
   */
  ignoreSearch: boolean;
  /**
   * The HTTP method of the request
   * When true, allows `new Request("example.com", { method: "PUT" })` to match `new Request("example.com")
   */
  ignoreMethod: boolean;
  /** This flag is currently unused and must always be `true` to earmark for future use. */
  ignoreVary: true;
};

Extras

handleExpression

handleExpression supports using itty-router style expressions for the path portion of a URL. The origin and all other request properties (in other words, every portion of a Request and URL except pathname) will be constructed from the RequestOrHandler passed in after the pathExpression.

Interface:

type HandleExpression = (
  pathExpression: string,

  requestOrHandler:
    | ((
        request: Request,
        options: { params: { [key: string]: string } | undefined }
      ) => Request | undefined)
    | Request,

  responseOrHandler: (
    request: Request,
    options: { params: { [key: string]: string } | undefined }
  ) => Response | Promise<Response>
) => HandlerTuple;

Example:

import { Router } from "request-router/router";
import { handleExpression } from "request-router/extras";

const router = new Router();
router.handlers.push(
  handleExpression(
    "/foo/:barId",
    new Request("https://example.com"),
    (request, { params } = {}) =>
      new Response(`Hello, ${params?.barId ?? "friend"}`)
  )
);
/// @ts-check
/* eslint-env browser */
/**
* @param {string} pathExpression
* @param {((request: Request, options: { params: {[key: string]: string;} | undefined }) => Request | undefined) | Request} requestOrHandler
* @param {((request: Request, options: { params: {[key: string]: string;} | undefined }) => Response | Promise<Response>)} responseOrHandler
* @returns {import(".").HandlerTuple}
*/
export function handleExpression(
pathExpression,
requestOrHandler,
responseOrHandler
) {
/* eslint-disable no-useless-escape */
let pathRegularExpression = RegExp(
`^${
pathExpression
.replace(/(\/?)\*/g, "($1.*)?") // trailing wildcard
.replace(/\/$/, "") // remove trailing slash
.replace(/:(\w+)(\?)?(\.)?/g, "$2(?<$1>[^/]+)$2$3") // named params
.replace(/\.(?=[\w(])/, "\\.") // dot in path
.replace(/\)\.\?\(([^\[]+)\[\^/g, "?)\\.?($1(?<=\\.)[^\\.") // optional image format
}/*$`
);
/* eslint-enable no-useless-escape */
/** @type {{[key: string]: string;} | undefined} */
let params;
return [
/**
* @param {Request} queryRequest
* @returns {Request | undefined}
*/
function requestExpressionHandler(queryRequest) {
if (!queryRequest) {
return undefined;
}
const queryRequestUrl = new URL(queryRequest.url);
const match = queryRequestUrl.pathname.match(pathRegularExpression);
if (!match) {
return undefined;
}
params = match.groups;
/** @type {Request | undefined} */
let unwrappedRequest;
if (typeof requestOrHandler === "function") {
unwrappedRequest = requestOrHandler(queryRequest, { params });
} else if (requestOrHandler) {
unwrappedRequest = requestOrHandler;
}
if (!unwrappedRequest) {
return undefined;
}
return new Request(
new URL(
queryRequestUrl.pathname,
new URL(unwrappedRequest.url).origin
).toString(),
queryRequest
);
},
/**
* @param {Request} request
* @returns {Response | Promise<Response>}
*/
function responseHandler(request) {
if (typeof responseOrHandler === "function") {
return responseOrHandler(request, { params });
}
return responseOrHandler;
},
];
}
/// @ts-check
/* eslint-env browser */
/**
* @typedef {[RequestOrHandler, ResponseHandler]} HandlerTuple
* A pair of Request and Response values or handlers.
*
* @callback RequestHandler
* @param {Request} request
* @returns {Request | undefined}
*
* @typedef {RequestHandler | Request | undefined} RequestOrHandler
*
* @callback ResponseHandler
* @param {Request} request
* @returns {Response | Promise<Response>}
*
* @typedef {CacheQueryOptions & { excludeFragment?: boolean }} RouterOptions
*/
/**
* Applies default CacheQueryOptions over input of undefined or CacheQueryOptions
* @param {RouterOptions} [options]
* @returns {{ [key in keyof CacheQueryOptions]-?: CacheQueryOptions[key]; } & { excludeFragment: boolean }}
*/
export function getOptions({
excludeFragment = true,
ignoreSearch = false,
ignoreMethod = false,
// ignoreVary = false,
} = {}) {
return { excludeFragment, ignoreMethod, ignoreSearch, ignoreVary: true };
}
/**
* @documentation https://www.w3.org/TR/service-workers/#dom-cache-matchall
* @param {HandlerTuple[]} handlers
* @param {string | Request} queryRequest
* @param {RouterOptions} [options]
* @returns {(Response | Promise<Response>)[]}
* Matches queryRequest against Request handlers and returns an array of Responses or pending Responses.
* If the Request portion of the Handler tuple:
* - is undefined, then Response portion is treated as a wildcard and is invoked for every request.
* - is a function, then invoke the request handler with `queryRequest`. Perform Request matching if returned value is not undefined.
* - is a Request, then perform Request matching and conditionally return the Response portion of the tuple
*/
export function matchAll(handlers, queryRequest, options) {
/** @type {Request} - Internal usage of queryRequest */
let r;
if (queryRequest instanceof Request) {
// Spec change: router has no opinion on which methods can be affected by `ignoreMethod`
r = queryRequest;
} else {
// Else assume queryRequest can be stringified
r = new Request(queryRequest);
}
/** @type {(Response | Promise<Response>)[]} */
const responses = [];
/** @type {undefined | Request} */
let handledRequest;
for (let [requestOrHandler, responseHandler] of handlers) {
if (
typeof requestOrHandler === "undefined" ||
((handledRequest = unwrapRequest(requestOrHandler, r)) &&
requestsMatch(r, handledRequest, options))
) {
// `requestOrHandler` was not provided then `responseOrHandler` is wildcard
// OR, let `handledRequest` be the Request of requestOrHandler or returned Request of requestOrHandler AND `handledRequest` was provided and requests match
responses.push(responseHandler(r));
}
}
return responses;
}
/**
* @documentation https://www.w3.org/TR/service-workers/#request-matches-cached-item-algorithm
* @param {Request} queryRequest
* @param {Request} handlerRequest
* @param {RouterOptions} [options]
* @returns {boolean}
*/
export function requestsMatch(queryRequest, handlerRequest, options) {
const o = getOptions(options);
if (
o.ignoreMethod === false &&
queryRequest.method !== handlerRequest.method
) {
// 1. If options.ignoreMethod is false and request’s method does not match requestQuery's method, then return false
return false;
}
// 2. Let queryURL be requestQuery’s url.
const queryURL = new URL(queryRequest.url);
// 3. Let handledURL be request handler’s returned url.
const handlerURL = new URL(handlerRequest.url);
if (o.excludeFragment === true) {
// 4. If options.excludeFragment is true, then set URL fragments to empty string
queryURL.hash = "";
handlerURL.hash = "";
}
if (o.ignoreSearch === true) {
// 5. If options.ignoreSearch is true, then set search URL property to empty string
queryURL.search = "";
handlerURL.search = "";
}
// 6. If queryURL does not equal handledURL, then return false.
// 7. Return true.
return queryURL.toString() === handlerURL.toString();
}
/**
* TODO somehow figure out typescript overload typings and only export one unwrapper
* @param {RequestOrHandler} requestOrHandler
* @param {Request} queryRequest
* @returns {Request | undefined}
*/
export function unwrapRequest(requestOrHandler, queryRequest) {
if (typeof requestOrHandler === "function") {
return requestOrHandler(queryRequest);
}
return requestOrHandler;
}
/// @ts-check
/* eslint-env browser */
import { matchAll } from "./index";
export class Router {
constructor() {
/**
* @type {import(".").HandlerTuple[]}
*/
this.handlers = [];
}
/**
* @param {import(".").RequestOrHandler} request
* @param {import(".").ResponseHandler} response
*/
handle(request, response) {
this.handlers.push([request, response]);
}
/**
* @param {string | Request} request
* @param {import(".").RouterOptions} [options]
*/
match(request, options) {
return matchAll(this.handlers, request, options);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment