Skip to main content
Module

x/workers_router/index.ts

A router for Worker Runtimes such and Cloudflare Workers or Service Workers.
Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
// deno-lint-ignore-file no-explicit-anyimport { Context, EffectsList, executeEffects } from 'https://ghuc.cc/worker-tools/middleware/context.ts';import { internalServerError, notFound } from 'https://ghuc.cc/worker-tools/response-creators/index.ts';import { ResolvablePromise } from 'https://ghuc.cc/worker-tools/resolvable-promise/index.ts';
import type { URLPatternInit, URLPatternComponentResult, URLPatternInput, URLPatternResult } from 'https://ghuc.cc/worker-tools/middleware/context.ts'export type { URLPatternInit, URLPatternComponentResult, URLPatternInput, URLPatternResult }
import { AggregateError } from "./utils/aggregate-error.ts";import { ErrorEvent } from './utils/error-event.ts';
export type Awaitable<T> = T | PromiseLike<T>;
export interface RouteContext extends Context { /** * The match that resulted in the execution of this route. It is the full result produced by the URL Pattern API. * If you are looking for a `params`-like object similar to outer routers, use the `basics` middleware * or `match.pathname.groups`. */ match: URLPatternResult}
export interface ErrorContext extends RouteContext { /** * If the exception is well-known and caused by middleware, this property is populated with a `Response` object * with an appropriate status code and text set. * * You can use it to customize the error response, e.g.: `new Response('...', response)`. */ response: Response,
/** * If an unknown error occurred, the sibling `response` property is set to be an "internal server error" while * the `error` property contains thrown error. */ error?: unknown,}
export type Middleware<RX extends RouteContext, X extends RouteContext> = (x: Awaitable<RX>) => Awaitable<X>
export type Handler<X extends RouteContext> = (request: Request, ctx: X) => Awaitable<Response>;export type ErrorHandler<X extends ErrorContext> = (request: Request, ctx: X) => Awaitable<Response>;
export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
// Internal types... these are not the types you are looking fortype MethodWildcard = 'ANY';type RouteHandler = (x: RouteContext) => Awaitable<Response>type RecoverRouteHandler = (x: ErrorContext) => Awaitable<Response>
interface Route { method: Method | MethodWildcard pattern: URLPattern handler: RouteHandler | RecoverRouteHandler}
/** * Turns a pathname pattern into a `URLPattern` that works across worker runtimes. * * Specifically in the case of Service Workers, this ensures requests to external domains that happen to have the same * pathname aren't matched. * If a worker environment has a location set (e.g. deno with `--location` or CF workers with a location polyfill), * this is essentially a noop since only matching requests can reach deployed workers in the first place. */const toPattern = navigator.userAgent?.includes('Cloudflare-Workers') && self.location?.hostname === 'localhost' ? (pathname: string) => new URLPattern({ pathname }) : (pathname: string) => { const pattern = new URLPattern({ pathname, protocol: self.location?.protocol, hostname: self.location?.hostname, port: self.location?.port, }) // Note that `undefined` becomes a `*` pattern. return pattern; }
export interface WorkerRouterOptions { fatal?: boolean}
// const anyResult = Object.freeze(toPattern('*').exec(new Request('/').url)!);// const anyPathResult = Object.freeze(toPattern('/*').exec(new Request('/').url)!);
export class WorkerRouter<RX extends RouteContext = RouteContext> extends EventTarget implements EventListenerObject { #middleware: Middleware<RouteContext, RX> #routes: Route[] = []; #recoverRoutes: Route[] = []; #fatal: boolean
constructor(middleware?: Middleware<RouteContext, RX> | null, opts: WorkerRouterOptions = {}) { super(); this.#middleware = middleware ?? (_ => _ as RX); this.#fatal = opts?.fatal ?? false; }
get fatal() { return this.#fatal; }
async #route(fqURL: string, ctx: Omit<Context, 'effects' | 'handled'>): Promise<Response> { const result = this.#execPatterns(fqURL, ctx.request) try { if (!result) throw notFound(); const [handler, match] = result; const handle = new ResolvablePromise<Response>() const handled = Promise.resolve(handle) const userCtx = Object.assign(ctx, { match, handled, effects: new EffectsList() }) const response = await handler(userCtx) handle.resolve(ctx.event?.handled?.then(() => response) ?? response) return response; } catch (err) { const recoverResult = this.#execPatterns(fqURL, ctx.request, this.#recoverRoutes) if (recoverResult) { try { const [handler, match] = recoverResult; const [response, error] = err instanceof Response ? [err, undefined] : [internalServerError(), err]; const handle = new ResolvablePromise<Response>() const handled = Promise.resolve(handle) const userCtx = Object.assign(ctx, { response, error, match, handled, effects: new EffectsList() }) const res = await handler(userCtx); handle.resolve(ctx.event?.handled?.then(() => res) ?? res) return res; } catch (recoverErr) { const aggregateErr = new AggregateError([err, recoverErr], 'Route handler and recover handler failed') if (this.#fatal) throw aggregateErr; this.#fireError(aggregateErr); if (recoverErr instanceof Response) return recoverErr; if (err instanceof Response) return err; return internalServerError(); } } if (this.#fatal) throw err; this.#fireError(err); if (err instanceof Response) return err; return internalServerError(); } }
#fireError(error: unknown) { const message = error instanceof Response ? `${error.status} ${error.statusText}` : error instanceof Error ? error.message : '' + error; this.dispatchEvent(new ErrorEvent('error', { message, error })); }
#execPatterns(fqURL: string, request: Request, routes = this.#routes): readonly [RouteHandler, URLPatternResult] | null { for (const { method, pattern, handler } of routes) { if (method !== 'ANY' && method !== request.method.toUpperCase()) continue
const match = pattern.exec(fqURL); if (!match) continue
// @ts-ignore: FIXME return [handler, match] as const; } return null }
#pushRoute( method: Method | MethodWildcard, pattern: URLPattern, handler: Handler<RX>, ) { this.#routes.push({ method, pattern, handler: async (event: RouteContext) => { const ctx = await this.#middleware(event); const response = handler(event.request, ctx); return executeEffects(event.effects, response) }, }) }
#pushMiddlewareRoute<X extends RX>( method: Method | MethodWildcard, pattern: URLPattern, middleware: Middleware<RX, X>, handler: Handler<X>, ) { this.#routes.push({ method, pattern, handler: async (event: RouteContext) => { const ctx = await middleware(this.#middleware(event)) const response = handler(event.request, ctx); return executeEffects(event.effects, response) }, }) }
#registerPattern<X extends RX>( method: Method | MethodWildcard, argsN: number, pattern: URLPattern, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>, ): this { if (argsN === 2) { const handler = middlewareOrHandler as Handler<RX> this.#pushRoute(method, pattern, handler) } else if (argsN === 3) { const middleware = middlewareOrHandler as Middleware<RX, X> this.#pushMiddlewareRoute(method, pattern, middleware, handler!) } else { throw Error(`Router '${method.toLowerCase()}' called with invalid number of arguments`) } return this; }
#registerRecoverPattern<X extends ErrorContext>( method: Method | MethodWildcard, argsN: number, pattern: URLPattern, middlewareOrHandler: Middleware<ErrorContext, X> | ErrorHandler<ErrorContext>, handler?: ErrorHandler<X>, ): this { if (argsN === 2) { const handler = middlewareOrHandler as ErrorHandler<ErrorContext> this.#pushRecoverRoute(method, pattern, handler) } else if (argsN === 3) { const middleware = middlewareOrHandler as Middleware<ErrorContext, X> this.#pushMiddlewareRecoverRoute(method, pattern, middleware, handler!) } else { throw Error(`Router '${method.toLowerCase()}' called with invalid number of arguments`) } return this; }
#pushRecoverRoute( method: Method | MethodWildcard, pattern: URLPattern, handler: ErrorHandler<ErrorContext>, ) { this.#recoverRoutes.push({ method, pattern, handler: (event: ErrorContext) => { const response = handler(event.request, event) return executeEffects(event.effects, response) }, }); }
#pushMiddlewareRecoverRoute<X extends ErrorContext>( method: Method | MethodWildcard, pattern: URLPattern, middleware: Middleware<ErrorContext, X>, handler: Handler<X>, ) { this.#recoverRoutes.push({ method, pattern, handler: async (event: ErrorContext) => { const ctx = await middleware(event) const response = handler(event.request, ctx); return executeEffects(event.effects, response) }, }); }
/** Add a route that matches *any* HTTP method. */ any<X extends RX>(path: string, handler: Handler<X>): this; any<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; any<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('ANY', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Alias for for the more appropriately named `any` method */ all<X extends RX>(path: string, handler: Handler<X>): this; all<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; all<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('ANY', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `GET` method. */ get<X extends RX>(path: string, handler: Handler<X>): this; get<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; get<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('GET', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `POST` method. */ post<X extends RX>(path: string, handler: Handler<X>): this; post<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; post<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('POST', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `PUT` method. */ put<X extends RX>(path: string, handler: Handler<X>): this; put<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; put<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('PUT', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `PATCH` method. */ patch<X extends RX>(path: string, handler: Handler<X>): this; patch<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; patch<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('PATCH', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `DELETE` method. */ delete<X extends RX>(path: string, handler: Handler<X>): this; delete<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; delete<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('DELETE', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `HEAD` method. */ head<X extends RX>(path: string, handler: Handler<X>): this; head<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; head<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('HEAD', arguments.length, toPattern(path), middlewareOrHandler, handler); } /** Add a route that matches the `OPTIONS` method. */ options<X extends RX>(path: string, handler: Handler<X>): this; options<X extends RX>(path: string, middleware: Middleware<RX, X>, handler: Handler<X>): this; options<X extends RX>(path: string, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('OPTIONS', arguments.length, toPattern(path), middlewareOrHandler, handler); }
/** * Add a route that matches *any* method with the provided pattern. * Note that the pattern here is interpreted as a `URLPatternInit` which has important implication for matching. * Mostly, this is for use in Service Workers to intercept requests to external resources. * * The name `external` is a bit of a misnomer. It simply forwards `init` to the `URLPattern` constructor, * instead of being limited to the `pathname` property in the general case. * @deprecated Might change name/API */ external<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; external<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; external<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('ANY', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `GET` * @deprecated Might change name/API */ externalGET<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalGET<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalGET<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('GET', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `POST` * @deprecated Might change name/API */ externalPOST<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalPOST<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalPOST<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('POST', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `PUT` * @deprecated Might change name/API */ externalPUT<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalPUT<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalPUT<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('PUT', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `PATCH` * @deprecated Might change name/API */ externalPATCH<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalPATCH<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalPATCH<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('PATCH', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `DELETE` * @deprecated Might change name/API */ externalDELETE<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalDELETE<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalDELETE<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('DELETE', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `OPTIONS` * @deprecated Might change name/API */ externalOPTIONS<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalOPTIONS<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalOPTIONS<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('OPTIONS', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** Like `.external`, but only matches `HEAD` * @deprecated Might change name/API */ externalHEAD<X extends RX>(init: string | URLPatternInit, handler: Handler<X>): this; externalHEAD<X extends RX>(init: string | URLPatternInit, middleware: Middleware<RX, X>, handler: Handler<X>): this; externalHEAD<X extends RX>(init: string | URLPatternInit, middlewareOrHandler: Middleware<RX, X> | Handler<X>, handler?: Handler<X>): this { return this.#registerPattern('HEAD', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
/** * Use a different `WorkerRouter` for the provided pattern. Keep in mind that: * * - The pattern must end in a wildcard `*` * - The corresponding match is the only part used for matching in the `subRouter` * - Forwards all HTTP methods * - Does not apply any middleware * * #### Why does it not apply middleware? * * There are 2 reasons: First, it interferes with type inference of middleware. * As a developer you'd have to provide the correct types at the point of defining the sub router, * which is at least as cumbersome as providing the middleware itself. * * Second, without this there would be no way to opt a route out of the router-level middleware. * For example you might want to opt out all `/public*` urls from cookie parsing, authentication, etc. * but add a different caching policy instead. * * @param path A pattern ending in a wildcard, e.g. `/items*` * @param subRouter A `WorkerRouter` that handles the remaining part of the URL, e.g. `/:category/:id` * @deprecated The name of this method might change to avoid confusion with `use` method known from other routers. */ use<Y extends RouteContext>(path: string, subRouter: WorkerRouter<Y>): this { // if (this..fatal && !path.endsWith('*')) { // console.warn('Path for \'use\' does not appear to end in a wildcard (*). This is likely to produce unexpected results.'); // }
this.#routes.push({ method: 'ANY', pattern: toPattern(path), handler: subRouter.#routeHandler, }) return this; }
/** * See `.external` and `.use`. * @deprecated Might change name/API */ useExternal<Y extends RouteContext>(init: string | URLPatternInit, subRouter: WorkerRouter<Y>): this { const pattern = new URLPattern(init)
// if (this.#opts.fatal && !pattern.pathname.endsWith('*')) { // console.warn('Pathname pattern for \'use\' does not appear to end in a wildcard (*). This is likely to produce unexpected results.'); // }
this.#routes.push({ method: 'ANY', pattern, handler: subRouter.#routeHandler, }) return this; }
/** * Register a special route to recover from an error during execution of a regular route. * * In addition to the usual context properties, the provided handler receives a `response` and `error` property. * In case of a well-known error (typically caused by middleware), * the `response` contains a Fetch API `Response` object with matching status and status text set. * In case of an unknown error, the `response` is a generic "internal server error" and the `error` property * contains the value caught by the catch block. * * Recover routes don't execute the router-level middleware (which might have caused the error), but * can have middleware specifically for this route. Note that if another error occurs during the execution of * this middleware, there are no more safety nets and an internal server error response is returned. * * If a global `DEBUG` variable is set (or `process.env.NODE_ENV` is set to `development` in case of webpack) * the router will throw on an unhandled error. This is to make it easier to spot problems during development. * Otherwise, the router will not throw but instead dispatch a `error` event on itself before returning an empty * internal server error response. */ recover(path: string, handler: Handler<ErrorContext>): this; recover<X extends ErrorContext>(path: string, middleware: Middleware<ErrorContext, X>, handler: Handler<X>): this; recover<X extends ErrorContext>(path: string, middlewareOrHandler: Middleware<ErrorContext, X> | Handler<ErrorContext>, handler?: Handler<X>): this { return this.#registerRecoverPattern('ANY', arguments.length, toPattern(path), middlewareOrHandler, handler); }
recoverExternal(init: string | URLPatternInit, handler: Handler<ErrorContext>): this; recoverExternal<X extends ErrorContext>(init: string | URLPatternInit, middleware: Middleware<ErrorContext, X>, handler: Handler<X>): this; recoverExternal<X extends ErrorContext>(init: string | URLPatternInit, middlewareOrHandler: Middleware<ErrorContext, X> | Handler<ErrorContext>, handler?: Handler<X>): this { return this.#registerRecoverPattern('ANY', arguments.length, new URLPattern(init), middlewareOrHandler, handler); }
#routeHandler: RouteHandler = (ctx) => { // TODO: are these guaranteed to be ordered correctly?? const values = Object.values(ctx.match?.pathname.groups ?? {}); if (values.length) { const baseURL = new URL(ctx.request.url).origin; const subURL = new URL(values.at(-1)!, baseURL); return this.#route(subURL.href, ctx); } throw TypeError('Pattern not suitable for nested routing. Did you forget to add a wildcard (*)?') }
/** @deprecated Name/API might change */ handle = (request: Request, ctx?: Omit<Context, 'effects'>) => { return this.#route(request.url, { ...ctx, request, waitUntil: ctx?.waitUntil?.bind(ctx) ?? ((_f: any) => { }) }) }
/** * Implements the (ancient) event listener object interface to allow passing to fetch event directly, * e.g. `self.addEventListener('fetch', router)`. */ handleEvent = (object: Event) => { const event = object as any; event.respondWith(this.#route(event.request.url, { request: event.request, waitUntil: event.waitUntil.bind(event), event, })); }
/** * Callback compatible with Cloudflare Worker's `fetch` module export. * E.g. `export default router`. */ fetch = (request: Request, env?: any, ctx?: any): Promise<Response> => { return this.#route(request.url, { request, waitUntil: ctx?.waitUntil?.bind(ctx) ?? ((_f: any) => { }), env, ctx, }); }
/** * Callback that is compatible with Deno's `serve` function. * E.g. `serve(router.serveCallback)`. */ serveCallback = (request: Request, connInfo: any): Promise<Response> => { return this.#route(request.url, { request, waitUntil: (_f: any) => { }, connInfo }); }
// Provide types for error handler: addEventListener( type: 'error', listener: TypedEventListenerOrEventListenerObject<ErrorEvent> | null, options?: boolean | AddEventListenerOptions, ): void; addEventListener(...args: Parameters<EventTarget['addEventListener']>) { super.addEventListener(...args) }
removeEventListener( type: 'error', listener: TypedEventListenerOrEventListenerObject<ErrorEvent> | null, options?: EventListenerOptions | boolean, ): void; removeEventListener(...args: Parameters<EventTarget['removeEventListener']>) { super.removeEventListener(...args) }}
type TypedEventListener<E extends Event> = (evt: E) => void | Promise<void>;type TypedEventListenerObject<E extends Event> = { handleEvent(evt: E): void | Promise<void>; }type TypedEventListenerOrEventListenerObject<E extends Event> = TypedEventListener<E> | TypedEventListenerObject<E>;