├── .gitignore ├── .gitattributes ├── webpack.config.js ├── tsconfig.json ├── src ├── index.ts ├── responses.ts ├── constants.ts ├── routers.ts └── helpers.ts ├── package.json ├── LICENSE ├── examples └── index.js ├── README.md └── min └── router.min.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | 4 | module.exports = { 5 | entry: [ 6 | path.join(__dirname, 'lib', 'index.js'), 7 | ], 8 | output: { 9 | filename: 'router.min.js', 10 | path: path.join(__dirname, 'min'), 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "outDir": "./lib", 7 | "strict": true, 8 | "target": "esnext", 9 | "types": ["@cloudflare/workers-types"] 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Constants from './constants'; 2 | import * as Helpers from './helpers'; 3 | import * as Responses from './responses'; 4 | import * as Routers from './routers'; 5 | 6 | export { Helpers }; 7 | export * from './constants'; 8 | export { RouteHandler, RouteOptions } from './helpers'; 9 | export * from './responses'; 10 | export * from './routers'; 11 | 12 | Object.assign(self || {}, { 13 | CFWorkerRouter: {...Constants, ...Responses, ...Routers, Helpers}, 14 | }); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "cakedan", 3 | "bugs": { 4 | "url": "https://github.com/cakedan/cf-worker-router/issues" 5 | }, 6 | "description": "Easier CloudFlare Worker Request Routing", 7 | "files": [ 8 | "lib/**/*" 9 | ], 10 | "homepage": "https://github.com/cakedan/cf-worker-router#readme", 11 | "keywords": [ 12 | "cloudflare", 13 | "router", 14 | "typescript", 15 | "worker" 16 | ], 17 | "license": "MIT", 18 | "main": "lib/index.js", 19 | "name": "cf-worker-router", 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/cakedan/cf-worker-router" 23 | }, 24 | "types": "lib/index.d.ts", 25 | "version": "0.2.0", 26 | "devDependencies": { 27 | "@cloudflare/workers-types": "^4.20250810.0", 28 | "typescript": "^5.9.2", 29 | "webpack": "^5.101.0", 30 | "webpack-cli": "^6.0.1" 31 | }, 32 | "scripts": { 33 | "build": "tsc && webpack --progress", 34 | "prepare": "npm run build", 35 | "test": "echo \"Error: no test specified\" && exit 1" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Dan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/responses.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatusCodes } from './constants'; 2 | 3 | 4 | export class ApiResponse extends Response { 5 | constructor(body: any, options: ResponseInit = {}) { 6 | options = Object.assign({}, options); 7 | options.headers = Object.assign({'content-type': 'application/json'}, options.headers); 8 | if (!options.status) { 9 | options.status = 200; 10 | } 11 | if (options.statusText === undefined && options.status in HttpStatusCodes) { 12 | options.statusText = HttpStatusCodes[options.status]; 13 | } 14 | 15 | switch ((options.headers as any)['content-type']) { 16 | case 'application/json': { 17 | body = JSON.stringify(body); 18 | }; break; 19 | } 20 | super(body, options); 21 | } 22 | } 23 | 24 | 25 | export interface ApiErrorInit extends ResponseInit { 26 | code?: number, 27 | message?: string, 28 | metadata?: object, 29 | } 30 | 31 | export class ApiError extends ApiResponse { 32 | constructor(options: ApiErrorInit = {}) { 33 | options = Object.assign({status: 400, code: 0}, options); 34 | 35 | const status = options.status!; 36 | if (status < 400 || 600 <= status) { 37 | throw new Error('Invalid Status Code, Errors should be equal to or between 400 and 599.'); 38 | } 39 | if (options.statusText === undefined && status in HttpStatusCodes) { 40 | options.statusText = HttpStatusCodes[status]; 41 | } 42 | if (options.message === undefined) { 43 | options.message = options.statusText; 44 | } 45 | 46 | const body = Object.assign({}, options.metadata, { 47 | code: options.code, 48 | message: options.message, 49 | status: options.status, 50 | }); 51 | super(body, options); 52 | } 53 | } 54 | 55 | 56 | export class ApiRedirect extends ApiResponse { 57 | constructor(url: string, options: ResponseInit = {}) { 58 | options = Object.assign({status: 302}, options); 59 | 60 | const status = options.status!; 61 | if (status < 300 || 400 <= status) { 62 | throw new Error('Invalid Status Code, Redirects should be equal to or between 300 and 399.'); 63 | } 64 | options.headers = Object.assign({}, options.headers, {location: url}); 65 | super(undefined, options); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import { ApiError, ApiRedirect, DomainRouter, FetchRouter } from '../lib'; 2 | 3 | const router = new FetchRouter(); 4 | 5 | // add the cloudflare event listener 6 | addEventListener('fetch', (event) => { 7 | router.onFetch(event); 8 | }); 9 | 10 | // after every response, modify it (like setting CORS headers) 11 | // is optional 12 | router.beforeResponse = (response, event) => { 13 | // create a new Response instance, incase it's immutable like from a fetch request 14 | response = new Response(response.body, response); 15 | response.headers.set('access-control-allow-headers', 'Content-Type, X-Some-Header'); 16 | response.headers.set('access-control-allow-methods', '*'); 17 | response.headers.set('access-control-allow-origin', event.url.origin || '*'); 18 | return response; 19 | }; 20 | 21 | 22 | // same as .route(url, 'GET', handler); 23 | // GET */users/1234 24 | router.route('/users/:userId', async (event) => { 25 | // automatically converts anything not of Response type to ApiResponse 26 | return event.parameters; 27 | }); 28 | 29 | // same as .route(url, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], handler) 30 | // ANY-METHOD */proxy/:url 31 | router.route('/proxy/:url', '*', async (event) => { 32 | if (event.request.headers.get('secret-token') !== 'test') { 33 | return new ApiError({status: 403}); 34 | } 35 | // remove our ip from headers 36 | event.request.headers.delete('cf-connecting-ip'); 37 | event.request.headers.delete('x-real-ip'); 38 | return await fetch(event.parameters.url, event.request); 39 | }); 40 | 41 | // GET redirect.example.com/:url 42 | router.route('redirect.example.com/:url', async (event) => { 43 | return new ApiRedirect(event.parameters.url); 44 | }); 45 | 46 | // GET example.com/string-test/anystringhere 47 | // GET example.com/string-test/anystringhere/andanythingwithslashes 48 | router.route('example.com/string-test/:string...', async (event) => { 49 | return event.parameters; 50 | }); 51 | 52 | // GET example.club/pass 53 | // passes it onto original destination, doesn't call `event.respondWith()` 54 | router.route('example.com/pass', {pass: true}); 55 | 56 | 57 | const subDomain = new DomainRouter(':username.example.com'); 58 | router.addRouter(subDomain); 59 | 60 | // GET some-username.example.com/files/1234 61 | subDomain.get('/files/:fileId', async (event) => { 62 | // {username, fileId} are the parameters 63 | return event.parameters; 64 | }); 65 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const Package = Object.freeze({ 2 | URL: 'https://github.com/cakedan/cf-worker-router', 3 | VERSION: '0.2.0', 4 | }); 5 | 6 | export const HttpMethods = Object.freeze({ 7 | DELETE: 'DELETE', 8 | GET: 'GET', 9 | HEAD: 'HEAD', 10 | OPTIONS: 'OPTIONS', 11 | PATCH: 'PATCH', 12 | POST: 'POST', 13 | PUT: 'PUT', 14 | }); 15 | 16 | export const HttpStatusCodes: {[key: number]: string | undefined} = Object.freeze({ 17 | 100: 'Continue', 18 | 101: 'Switching Protocols', 19 | 102: 'Processing', 20 | 200: 'OK', 21 | 201: 'Created', 22 | 202: 'Accepted', 23 | 203: 'Non Authoritative Information', 24 | 204: 'No Content', 25 | 205: 'Reset Content', 26 | 206: 'Partial Content', 27 | 207: 'Multi Status', 28 | 226: 'IM Used', 29 | 300: 'Multiple Choices', 30 | 301: 'Moved Permanently', 31 | 302: 'Found', 32 | 303: 'See Other', 33 | 304: 'Not Modified', 34 | 305: 'Use Proxy', 35 | 307: 'Temporary Redirect', 36 | 400: 'Bad Request', 37 | 401: 'Unauthorized', 38 | 402: 'Payment Required', 39 | 403: 'Forbidden', 40 | 404: 'Not Found', 41 | 405: 'Method Not Allowed', 42 | 406: 'Not Acceptable', 43 | 407: 'Proxy Authentication Required', 44 | 408: 'Request Timeout', 45 | 409: 'Conflict', 46 | 410: 'Gone', 47 | 411: 'Length Required', 48 | 412: 'Precondition Failed', 49 | 413: 'Request Entity Too Large', 50 | 414: 'Request URI Too Long', 51 | 415: 'Unsupported Media Type', 52 | 416: 'Requested Range Not Satisfiable', 53 | 417: 'Expectation Failed', 54 | 418: 'I\'m a teapot', 55 | 421: 'Misdirected Request', 56 | 422: 'Unprocessable Entity', 57 | 423: 'Locked', 58 | 424: 'Failed Dependency', 59 | 426: 'Upgrade Required', 60 | 428: 'Precondition Required', 61 | 429: 'Too Many Requests', 62 | 431: 'Request Header Fields Too Large', 63 | 449: 'Retry With', 64 | 451: 'Unavailable For Legal Reasons', 65 | 500: 'Internal Server Error', 66 | 501: 'Not Implemented', 67 | 502: 'Bad Gateway', 68 | 503: 'Service Unavailable', 69 | 504: 'Gateway Timeout', 70 | 505: 'HTTP Version Not Supported', 71 | 507: 'Insufficient Storage', 72 | 510: 'Not Extended', 73 | }); 74 | 75 | export const RouteRegexps = Object.freeze({ 76 | PARAMETER: /(?:[:*])(\w+)/g, 77 | PARAMETER_REPLACEMENT: '([^\/]+)', 78 | PARAMETER_WILDCARD_DOTS: /(?:\(\.\*\))(\.{3})/g, 79 | PARAMETER_WILDCARD_REPLACEMENT: '(.*)', 80 | SLASH_OPTIONAL: '(?:\/$|$)', 81 | WILDCARD: /\*/g, 82 | WILDCARD_REPLACEMENT: '(?:.*)', 83 | }); 84 | -------------------------------------------------------------------------------- /src/routers.ts: -------------------------------------------------------------------------------- 1 | import { ApiError, ApiErrorInit, ApiResponse } from './responses'; 2 | import { Router, RouteHandler, RouteOptions, Route, RouterEvent } from './helpers'; 3 | 4 | 5 | export interface FetchRouterOptions { 6 | showServerError?: boolean, 7 | } 8 | 9 | export class FetchRouter extends Router { 10 | showServerError: boolean = true; 11 | 12 | beforeResponse?: (response: Response, event: RouterEvent) => any; 13 | 14 | constructor(options: FetchRouterOptions = {}) { 15 | super(); 16 | options = Object.assign({showServerError: this.showServerError}, options); 17 | 18 | this.showServerError = !!options.showServerError; 19 | } 20 | 21 | addRouter(router: Router) { 22 | this.routes.add(router); 23 | } 24 | 25 | _beforeResponse(response: Response, event: RouterEvent): Response { 26 | if (typeof(this.beforeResponse) === 'function') { 27 | const newResponse = this.beforeResponse(response, event); 28 | if (newResponse) { 29 | if (newResponse instanceof Response) { 30 | response = newResponse; 31 | } else { 32 | response = new ApiResponse(newResponse); 33 | } 34 | } 35 | } 36 | return response; 37 | } 38 | 39 | onFetch(fetchEvent: FetchEvent | Request, env?: Record, context?: ExecutionContext) { 40 | const event = new RouterEvent(fetchEvent, env, context); 41 | 42 | let routes = this.routes.findAll(event.route); 43 | if (!routes.length) { 44 | const response = this._beforeResponse(new ApiError({status: 404}), event); 45 | return event.respondWith(response); 46 | } 47 | 48 | routes = routes.filter((route) => route.methods.includes(event.method)); 49 | if (!routes.length) { 50 | const response = this._beforeResponse(new ApiError({status: 405}), event); 51 | return event.respondWith(response); 52 | } 53 | 54 | const route = routes.shift()!; 55 | if (!route.pass) { 56 | return event.respondWith(this.onRoute(event, route)); 57 | } 58 | } 59 | 60 | async onRoute(event: RouterEvent, route: Route): Promise { 61 | let response: Response; 62 | try { 63 | response = await route.handle(event); 64 | if (!(response instanceof Response)) { 65 | response = new ApiResponse(response); 66 | } 67 | } catch(error) { 68 | const options: ApiErrorInit = {status: 500}; 69 | if (this.showServerError) { 70 | options.metadata = {error: String(error)}; 71 | } 72 | response = new ApiError(options); 73 | } 74 | return this._beforeResponse(response, event); 75 | } 76 | } 77 | 78 | export class DomainRouter extends Router { 79 | readonly domain: string; 80 | 81 | constructor(domain: string = '*', key?: string) { 82 | super(); 83 | this.domain = domain; 84 | this.key = key || domain; 85 | } 86 | 87 | route( 88 | url: Array | string, 89 | methods: Array | string | RouteHandler, 90 | handler?: RouteHandler | RouteOptions, 91 | options: RouteOptions = {}, 92 | ): this { 93 | if (Array.isArray(url)) { 94 | for (let x of url) { 95 | if (!x.startsWith('/')) { 96 | throw new TypeError('DomainRouter routes need to be paths!'); 97 | } 98 | super.route.call(this, this.domain + x, methods, handler, options); 99 | } 100 | } else { 101 | super.route.call(this, this.domain + url, methods, handler, options); 102 | } 103 | return this; 104 | } 105 | 106 | addBlueprint(blueprint: BlueprintRouter): this { 107 | if (!(blueprint instanceof BlueprintRouter)) { 108 | throw new TypeError('Blueprint must be of type BlueprintRouter'); 109 | } 110 | this.routes.add(blueprint); 111 | return this; 112 | } 113 | } 114 | 115 | export class BlueprintRouter extends Router { 116 | readonly path: string; 117 | 118 | constructor(path: string = '', key?: string) { 119 | super(); 120 | this.path = path; 121 | this.key = key || path; 122 | } 123 | 124 | route( 125 | url: Array | string, 126 | methods: Array | string | RouteHandler, 127 | handler?: RouteHandler | RouteOptions, 128 | options: RouteOptions = {}, 129 | ): this { 130 | if (Array.isArray(url)) { 131 | for (let x of url) { 132 | super.route.call(this, this.path + x, methods, handler, options); 133 | } 134 | } else { 135 | super.route.call(this, this.path + url, methods, handler, options); 136 | } 137 | return this; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Worker Router 2 | easier cloudflare request routing 3 | 4 | ## Example Usage 5 | Two ways of adding the router 6 | ```js 7 | import { FetchRouter } from 'cf-worker-router'; 8 | 9 | const router = new FetchRouter(); 10 | 11 | addEventListener('fetch', (event) => { 12 | router.onFetch(event); 13 | }); 14 | ``` 15 | 16 | or 17 | 18 | ```ts 19 | import { FetchRouter } from 'cf-worker-router'; 20 | 21 | const router = new FetchRouter(); 22 | 23 | // this is the only way to get the environmental variables found in event.environment 24 | // this is also the only way to get access to R2 storage, event.environment.R2_BUCKET 25 | export default { 26 | fetch(request: Request, env: Record, context: ExecutionContext) { 27 | return router.onFetch(request, env, context); 28 | } 29 | } 30 | ``` 31 | 32 | ```js 33 | import { ApiError, ApiRedirect, DomainRouter, FetchRouter } from 'cf-worker-router'; 34 | 35 | const router = new FetchRouter(); 36 | 37 | // export the cloudflare event listener 38 | export default { 39 | fetch(request, env, context) { 40 | return router.onFetch(request, env, context); 41 | } 42 | } 43 | 44 | // after every response, modify it (like setting CORS headers) 45 | // is optional 46 | router.beforeResponse = (response, event) => { 47 | // create a new Response instance, incase it's immutable like from a fetch request 48 | response = new Response(response.body, response); 49 | response.headers.set('access-control-allow-headers', 'Content-Type, X-Some-Header'); 50 | response.headers.set('access-control-allow-methods', '*'); 51 | response.headers.set('access-control-allow-origin', event.url.origin || '*'); 52 | return response; 53 | }; 54 | 55 | 56 | // same as .route(url, 'GET', handler); 57 | // GET */users/1234 58 | router.route('/users/:userId', async (event) => { 59 | // automatically converts anything not of Response type to ApiResponse 60 | return event.parameters; 61 | }); 62 | 63 | // same as .route(url, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], handler) 64 | // ANY-METHOD */proxy/:url 65 | router.route('/proxy/:url', '*', async (event) => { 66 | if (event.request.headers.get('secret-token') !== 'test') { 67 | return new ApiError({status: 403}); 68 | } 69 | // remove our ip from headers 70 | event.request.headers.delete('cf-connecting-ip'); 71 | event.request.headers.delete('x-real-ip'); 72 | return await fetch(event.parameters.url, event.request); 73 | }); 74 | 75 | // GET redirect.example.com/:url 76 | router.route('redirect.example.com/:url', async (event) => { 77 | return new ApiRedirect(event.parameters.url); 78 | }); 79 | 80 | // GET example.com/string-test/anystringhere 81 | // GET example.com/string-test/anystringhere/andanythingwithslashes 82 | router.route('example.com/string-test/:string...', async (event) => { 83 | return event.parameters; 84 | }); 85 | 86 | // GET example.club/pass 87 | // passes it onto original destination, doesn't call `event.respondWith()` 88 | router.route('example.com/pass', {pass: true}); 89 | 90 | 91 | const subDomain = new DomainRouter(':username.example.com'); 92 | router.addRouter(subDomain); 93 | 94 | // GET some-username.example.com/files/1234 95 | subDomain.get('/files/:fileId', async (event) => { 96 | // {username, fileId} are the parameters 97 | return event.parameters; 98 | }); 99 | 100 | ``` 101 | 102 | ## Example Usage w/ Minified Version 103 | ```js 104 | // Copy and Paste ./min/router.min.js at the top of the file 105 | // !function(e){... 106 | 107 | // We put our library in self.CFWorkerRouter 108 | const { ApiError, ApiRedirect, DomainRouter, FetchRouter } = CFWorkerRouter; 109 | 110 | const router = new FetchRouter(); 111 | 112 | // add the cloudflare event listener 113 | export default { 114 | fetch(request, env, context) { 115 | return router.onFetch(request, env, context); 116 | } 117 | } 118 | 119 | // after every response, modify it (like setting CORS headers) 120 | // is optional 121 | router.beforeResponse = (response, event) => { 122 | // create a new Response instance, incase it's immutable like from a fetch request 123 | response = new Response(response.body, response); 124 | response.headers.set('access-control-allow-headers', 'Content-Type, X-Some-Header'); 125 | response.headers.set('access-control-allow-methods', '*'); 126 | response.headers.set('access-control-allow-origin', event.url.origin || '*'); 127 | return response; 128 | }; 129 | 130 | 131 | // same as .route(url, 'GET', handler); 132 | // GET */users/1234 133 | router.route('/users/:userId', async (event) => { 134 | // automatically converts anything not of Response type to ApiResponse 135 | return event.parameters; 136 | }); 137 | 138 | // same as .route(url, ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'], handler) 139 | // ANY-METHOD */proxy/:url 140 | router.route('/proxy/:url', '*', async (event) => { 141 | if (event.request.headers.get('secret-token') !== 'test') { 142 | return new ApiError({status: 403}); 143 | } 144 | // remove our ip from headers 145 | event.request.headers.delete('cf-connecting-ip'); 146 | event.request.headers.delete('x-real-ip'); 147 | return await fetch(event.parameters.url, event.request); 148 | }); 149 | 150 | // GET redirect.example.com/:url 151 | router.route('redirect.example.com/:url', async (event) => { 152 | return new ApiRedirect(event.parameters.url); 153 | }); 154 | 155 | // GET example.com/string-test/anystringhere 156 | // GET example.com/string-test/anystringhere/andanythingwithslashes 157 | router.route('example.com/string-test/:string...', async (event) => { 158 | return event.parameters; 159 | }); 160 | 161 | // GET example.club/pass 162 | // passes it onto original destination, doesn't call `event.respondWith()` 163 | router.route('example.com/pass', {pass: true}); 164 | 165 | 166 | const subDomain = new DomainRouter(':username.example.com'); 167 | router.addRouter(subDomain); 168 | 169 | // GET some-username.example.com/files/1234 170 | subDomain.get('/files/:fileId', async (event) => { 171 | // {username, fileId} are the parameters 172 | return event.parameters; 173 | }); 174 | 175 | ``` 176 | -------------------------------------------------------------------------------- /min/router.min.js: -------------------------------------------------------------------------------- 1 | (()=>{"use strict";var e={d:(t,r)=>{for(var s in r)e.o(r,s)&&!e.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:r[s]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},t={};e.r(t),e.d(t,{HttpMethods:()=>n,HttpStatusCodes:()=>a,Package:()=>i,RouteRegexps:()=>u});var r={};e.r(r),e.d(r,{Route:()=>d,RouteMap:()=>p,Router:()=>R,RouterEvent:()=>f,checkHttpMethods:()=>h,extractParameters:()=>l,urlToRegexp:()=>c});var s={};e.r(s),e.d(s,{ApiError:()=>y,ApiRedirect:()=>g,ApiResponse:()=>E});var o={};e.r(o),e.d(o,{BlueprintRouter:()=>m,DomainRouter:()=>A,FetchRouter:()=>T});const i=Object.freeze({URL:"https://github.com/cakedan/cf-worker-router",VERSION:"0.2.0"}),n=Object.freeze({DELETE:"DELETE",GET:"GET",HEAD:"HEAD",OPTIONS:"OPTIONS",PATCH:"PATCH",POST:"POST",PUT:"PUT"}),a=Object.freeze({100:"Continue",101:"Switching Protocols",102:"Processing",200:"OK",201:"Created",202:"Accepted",203:"Non Authoritative Information",204:"No Content",205:"Reset Content",206:"Partial Content",207:"Multi Status",226:"IM Used",300:"Multiple Choices",301:"Moved Permanently",302:"Found",303:"See Other",304:"Not Modified",305:"Use Proxy",307:"Temporary Redirect",400:"Bad Request",401:"Unauthorized",402:"Payment Required",403:"Forbidden",404:"Not Found",405:"Method Not Allowed",406:"Not Acceptable",407:"Proxy Authentication Required",408:"Request Timeout",409:"Conflict",410:"Gone",411:"Length Required",412:"Precondition Failed",413:"Request Entity Too Large",414:"Request URI Too Long",415:"Unsupported Media Type",416:"Requested Range Not Satisfiable",417:"Expectation Failed",418:"I'm a teapot",421:"Misdirected Request",422:"Unprocessable Entity",423:"Locked",424:"Failed Dependency",426:"Upgrade Required",428:"Precondition Required",429:"Too Many Requests",431:"Request Header Fields Too Large",449:"Retry With",451:"Unavailable For Legal Reasons",500:"Internal Server Error",501:"Not Implemented",502:"Bad Gateway",503:"Service Unavailable",504:"Gateway Timeout",505:"HTTP Version Not Supported",507:"Insufficient Storage",510:"Not Extended"}),u=Object.freeze({PARAMETER:/(?:[:*])(\w+)/g,PARAMETER_REPLACEMENT:"([^/]+)",PARAMETER_WILDCARD_DOTS:/(?:\(\.\*\))(\.{3})/g,PARAMETER_WILDCARD_REPLACEMENT:"(.*)",SLASH_OPTIONAL:"(?:/$|$)",WILDCARD:/\*/g,WILDCARD_REPLACEMENT:"(?:.*)"});function h(e){if("string"==typeof e)e="*"===e?Object.values(n):[e];else if(!Array.isArray(e))throw new TypeError("Methods must be a string or a list of strings");if(e=e.map(e=>e.toUpperCase()),!(e=Array.from(new Set(e)).filter(e=>e in n)).length)throw new TypeError("Methods must contain at least one valid http method");return e.sort()}function l(e,t,r={}){return e&&e.length-1===t.length?e.slice(1).reduce((e,r,s)=>(e[t[s]]=decodeURIComponent(r),e),r):null}function c(e){e.startsWith("/")&&(e="^*"+e),e.startsWith("^")||(e="^"+e);const t=[];return{regexp:new RegExp(e.replace(u.WILDCARD,u.WILDCARD_REPLACEMENT).replace(u.PARAMETER,(r,s,o)=>{t.push(s);const i=r.length+o;return"..."===e.slice(i,i+3)?u.PARAMETER_WILDCARD_REPLACEMENT:u.PARAMETER_REPLACEMENT}).replace(u.PARAMETER_WILDCARD_DOTS,u.PARAMETER_WILDCARD_REPLACEMENT)+u.SLASH_OPTIONAL),variables:t}}class d{handler;key;methods;regexp;variables;pass=!1;priority=0;constructor(e,t,r,s={}){if("function"==typeof t?(s=r,r=t,t=[n.GET]):"object"!=typeof t||Array.isArray(t)?("object"==typeof r&&(s=r),t=h(t)):(s=t,t=[n.GET]),s=Object.assign({pass:this.pass,priority:this.priority},s),"function"!=typeof r&&!s.pass)throw new TypeError("Handler has to be of function type or options must have pass as true");this.handler=r,this.methods=t,this.pass=s.pass||this.pass,this.priority=s.priority||this.priority;const{regexp:o,variables:i}=c(e);this.key=this.methods.join(".")+"#"+o,this.regexp=o,this.variables=i}matches(e){return!!e.match(this.regexp)}async handle(e){if(l(e.route.match(this.regexp),this.variables,e.parameters),this.handler)return this.handler(e)}}class p extends Map{routers=new Map;add(e){if(e instanceof d){if(this.has(e.key))throw new TypeError(`Route ${e.key} already exists`);this.set(e.key,e)}else{if(!(e instanceof R))throw new TypeError("Route must be of type Route or Router");{const t=e;if(!t.key)throw new TypeError("Router must have a key");if(this.routers.has(t.key))throw new TypeError(`Router with key ${t.key} already exists`);this.routers.set(t.key,t)}}}findAll(e){const t=[];for(let r of this.values())r.matches(e)&&t.push(r);for(let r of this.routers.values()){const s=r.routes.findAll(e);s.length&&t.push(s)}return t.flat().sort((e,t)=>t.key.length-e.key.length).sort((e,t)=>t.priority-e.priority)}}class R{routes=new p;key=null;route(e,t,r,s={}){if(Array.isArray(e))for(let o of e)this.routes.add(new d(o,t,r,s));else this.routes.add(new d(e,t,r,s));return this}delete(e,t,r={}){return this.route(e,[n.DELETE],t,r)}get(e,t,r={}){return this.route(e,[n.GET],t,r)}head(e,t,r={}){return this.route(e,[n.HEAD],t,r)}options(e,t,r={}){return this.route(e,[n.OPTIONS],t,r)}post(e,t,r={}){return this.route(e,[n.POST],t,r)}put(e,t,r={}){return this.route(e,[n.PUT],t,r)}}class f{fetchEvent=null;route;url;_originalRequest=null;_request=null;context=null;environment={};parameters={};constructor(e,t,r){e instanceof Request?this._originalRequest=e:e instanceof FetchEvent&&(this.fetchEvent=e),t&&(this.environment=t),r&&(this.context=r),this.url=new URL(this.originalRequest.url),this.route=this.url.hostname+this.url.pathname.replace(/^\/+/,"/")}get headers(){return this.originalRequest.headers}get ip(){return this.headers.get("cf-connecting-ip")||this.headers.get("x-real-ip")||""}get ipv4(){return this.headers.get("x-real-ip")||""}get method(){return this.originalRequest.method}get originalRequest(){if(this.fetchEvent)return this.fetchEvent.request;if(this._originalRequest)return this._originalRequest;throw new Error("Invalid FetchEvent or Request was passed in.")}get query(){return this.url.searchParams}get request(){return this._request?this._request:this._request=new Request(this.originalRequest)}respondWith(e){return this.fetchEvent?this.fetchEvent.respondWith(e):Promise.resolve(e)}pass(){return fetch(this._request?this._request:this.originalRequest)}}class E extends Response{constructor(e,t={}){(t=Object.assign({},t)).headers=Object.assign({"content-type":"application/json"},t.headers),t.status||(t.status=200),void 0===t.statusText&&t.status in a&&(t.statusText=a[t.status]),"application/json"===t.headers["content-type"]&&(e=JSON.stringify(e)),super(e,t)}}class y extends E{constructor(e={}){const t=(e=Object.assign({status:400,code:0},e)).status;if(t<400||600<=t)throw new Error("Invalid Status Code, Errors should be equal to or between 400 and 599.");void 0===e.statusText&&t in a&&(e.statusText=a[t]),void 0===e.message&&(e.message=e.statusText),super(Object.assign({},e.metadata,{code:e.code,message:e.message,status:e.status}),e)}}class g extends E{constructor(e,t={}){const r=(t=Object.assign({status:302},t)).status;if(r<300||400<=r)throw new Error("Invalid Status Code, Redirects should be equal to or between 300 and 399.");t.headers=Object.assign({},t.headers,{location:e}),super(void 0,t)}}class T extends R{showServerError=!0;beforeResponse;constructor(e={}){super(),e=Object.assign({showServerError:this.showServerError},e),this.showServerError=!!e.showServerError}addRouter(e){this.routes.add(e)}_beforeResponse(e,t){if("function"==typeof this.beforeResponse){const r=this.beforeResponse(e,t);r&&(e=r instanceof Response?r:new E(r))}return e}onFetch(e,t,r){const s=new f(e,t,r);let o=this.routes.findAll(s.route);if(!o.length){const e=this._beforeResponse(new y({status:404}),s);return s.respondWith(e)}if(o=o.filter(e=>e.methods.includes(s.method)),!o.length){const e=this._beforeResponse(new y({status:405}),s);return s.respondWith(e)}const i=o.shift();if(!i.pass)return s.respondWith(this.onRoute(s,i))}async onRoute(e,t){let r;try{r=await t.handle(e),r instanceof Response||(r=new E(r))}catch(e){const t={status:500};this.showServerError&&(t.metadata={error:String(e)}),r=new y(t)}return this._beforeResponse(r,e)}}class A extends R{domain;constructor(e="*",t){super(),this.domain=e,this.key=t||e}route(e,t,r,s={}){if(Array.isArray(e))for(let o of e){if(!o.startsWith("/"))throw new TypeError("DomainRouter routes need to be paths!");super.route.call(this,this.domain+o,t,r,s)}else super.route.call(this,this.domain+e,t,r,s);return this}addBlueprint(e){if(!(e instanceof m))throw new TypeError("Blueprint must be of type BlueprintRouter");return this.routes.add(e),this}}class m extends R{path;constructor(e="",t){super(),this.path=e,this.key=t||e}route(e,t,r,s={}){if(Array.isArray(e))for(let o of e)super.route.call(this,this.path+o,t,r,s);else super.route.call(this,this.path+e,t,r,s);return this}}Object.assign(self||{},{CFWorkerRouter:{...t,...s,...o,Helpers:r}})})(); -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { HttpMethods, RouteRegexps } from './constants'; 2 | 3 | 4 | 5 | export function checkHttpMethods(methods: Array | string): Array { 6 | if (typeof(methods) === 'string') { 7 | if (methods === '*') { 8 | methods = Object.values(HttpMethods); 9 | } else { 10 | methods = [methods]; 11 | } 12 | } else if (!Array.isArray(methods)) { 13 | throw new TypeError('Methods must be a string or a list of strings'); 14 | } 15 | methods = (methods as Array).map((method) => method.toUpperCase()); 16 | methods = Array.from(new Set(methods)).filter((method) => method in HttpMethods); 17 | 18 | if (!methods.length) { 19 | throw new TypeError('Methods must contain at least one valid http method'); 20 | } 21 | return methods.sort(); 22 | }; 23 | 24 | export function extractParameters( 25 | match: Array | null, 26 | variables: Array, 27 | holder: Record = {}, 28 | ): {[key: string]: string} | null { 29 | if (match && (match.length - 1) === variables.length) { 30 | return match.slice(1).reduce((parameters, value, i) => { 31 | parameters[variables[i]] = decodeURIComponent(value); 32 | return parameters; 33 | }, holder); 34 | } 35 | return null; 36 | }; 37 | 38 | export function urlToRegexp(url: string): {regexp: RegExp, variables: Array} { 39 | if (url.startsWith('/')) { 40 | url = '^*' + url; 41 | } 42 | if (!url.startsWith('^')) { 43 | url = '^' + url; 44 | } 45 | const variables: Array = []; 46 | const regexp = new RegExp( 47 | url.replace(RouteRegexps.WILDCARD, RouteRegexps.WILDCARD_REPLACEMENT) 48 | .replace(RouteRegexps.PARAMETER, (match, variable, index) => { 49 | variables.push(variable); 50 | const indexAfter = match.length + index; 51 | if (url.slice(indexAfter, indexAfter + 3) === '...') { 52 | return RouteRegexps.PARAMETER_WILDCARD_REPLACEMENT; 53 | } else { 54 | return RouteRegexps.PARAMETER_REPLACEMENT; 55 | } 56 | }).replace(RouteRegexps.PARAMETER_WILDCARD_DOTS, RouteRegexps.PARAMETER_WILDCARD_REPLACEMENT) + RouteRegexps.SLASH_OPTIONAL 57 | ); 58 | return {regexp, variables}; 59 | }; 60 | 61 | 62 | export type RouteHandler = (event: RouterEvent) => Promise | any; 63 | 64 | export interface RouteOptions { 65 | pass?: boolean, 66 | priority?: number, 67 | } 68 | 69 | export class Route { 70 | readonly handler?: RouteHandler; 71 | readonly key: string; 72 | readonly methods: Array; 73 | readonly regexp: RegExp; 74 | readonly variables: Array; 75 | 76 | pass: boolean = false; 77 | priority: number = 0; 78 | 79 | constructor( 80 | url: string, 81 | methods: Array | string | RouteHandler, 82 | handler?: RouteHandler | RouteOptions, 83 | options: RouteOptions = {}, 84 | ) { 85 | if (typeof(methods) === 'function') { 86 | options = handler as RouteOptions; 87 | handler = methods as RouteHandler; 88 | methods = [HttpMethods.GET]; 89 | } else if (typeof(methods) === 'object' && !Array.isArray(methods)) { 90 | options = methods as RouteOptions; 91 | methods = [HttpMethods.GET]; 92 | } else { 93 | if (typeof(handler) === 'object') { 94 | options = handler as RouteOptions; 95 | } 96 | methods = checkHttpMethods(methods); 97 | } 98 | options = Object.assign({ 99 | pass: this.pass, 100 | priority: this.priority, 101 | }, options) as RouteOptions; 102 | 103 | if (typeof(handler) !== 'function' && !options.pass) { 104 | throw new TypeError('Handler has to be of function type or options must have pass as true'); 105 | } 106 | 107 | this.handler = handler as RouteHandler; 108 | this.methods = methods as Array; 109 | this.pass = options.pass || this.pass; 110 | this.priority = options.priority || this.priority; 111 | 112 | const {regexp, variables} = urlToRegexp(url); 113 | 114 | this.key = this.methods.join('.') + '#' + regexp; 115 | this.regexp = regexp; 116 | this.variables = variables; 117 | } 118 | 119 | matches(url: string): boolean { 120 | return !!url.match(this.regexp); 121 | } 122 | 123 | async handle(event: RouterEvent): Promise { 124 | const match = event.route.match(this.regexp); 125 | extractParameters(match, this.variables, event.parameters); 126 | if (this.handler) { 127 | return this.handler(event); 128 | } 129 | } 130 | } 131 | 132 | 133 | export class RouteMap extends Map { 134 | readonly routers = new Map(); 135 | 136 | add(route: Route | Router): void { 137 | if (route instanceof Route) { 138 | if (this.has(route.key)) { 139 | throw new TypeError(`Route ${route.key} already exists`); 140 | } 141 | this.set(route.key, route); 142 | } else if (route instanceof Router) { 143 | const router = route; 144 | 145 | if (!router.key) { 146 | throw new TypeError('Router must have a key'); 147 | } 148 | if (this.routers.has(router.key)) { 149 | throw new TypeError(`Router with key ${router.key} already exists`); 150 | } 151 | this.routers.set(router.key, router); 152 | } else { 153 | throw new TypeError('Route must be of type Route or Router'); 154 | } 155 | } 156 | 157 | findAll(url: string): Array { 158 | const routes: Array | Route> = []; 159 | for (let route of this.values()) { 160 | if (route.matches(url)) { 161 | routes.push(route); 162 | } 163 | } 164 | for (let router of this.routers.values()) { 165 | const found = router.routes.findAll(url); 166 | if (found.length) { 167 | routes.push(found); 168 | } 169 | } 170 | return > routes.flat() 171 | .sort((x, y) => y.key.length - x.key.length) 172 | .sort((x, y) => y.priority - x.priority); 173 | } 174 | } 175 | 176 | 177 | export class Router { 178 | readonly routes = new RouteMap(); 179 | 180 | key: null | string = null; 181 | 182 | route( 183 | url: Array | string, 184 | methods: Array | string | RouteHandler, 185 | handler?: RouteHandler | RouteOptions, 186 | options: RouteOptions = {}, 187 | ): this { 188 | if (Array.isArray(url)) { 189 | for (let x of url) { 190 | this.routes.add(new Route(x, methods, handler, options)); 191 | } 192 | } else { 193 | this.routes.add(new Route(url, methods, handler, options)); 194 | } 195 | return this; 196 | } 197 | 198 | delete( 199 | url: Array | string, 200 | handler?: RouteHandler | RouteOptions, 201 | options: RouteOptions = {}, 202 | ) { 203 | return this.route(url, [HttpMethods.DELETE], handler, options); 204 | } 205 | 206 | get( 207 | url: Array | string, 208 | handler?: RouteHandler | RouteOptions, 209 | options: RouteOptions = {}, 210 | ) { 211 | return this.route(url, [HttpMethods.GET], handler, options); 212 | } 213 | 214 | head( 215 | url: Array | string, 216 | handler?: RouteHandler | RouteOptions, 217 | options: RouteOptions = {}, 218 | ) { 219 | return this.route(url, [HttpMethods.HEAD], handler, options); 220 | } 221 | 222 | options( 223 | url: Array | string, 224 | handler?: RouteHandler | RouteOptions, 225 | options: RouteOptions = {}, 226 | ) { 227 | return this.route(url, [HttpMethods.OPTIONS], handler, options); 228 | } 229 | 230 | post( 231 | url: Array | string, 232 | handler?: RouteHandler | RouteOptions, 233 | options: RouteOptions = {}, 234 | ) { 235 | return this.route(url, [HttpMethods.POST], handler, options); 236 | } 237 | 238 | put( 239 | url: Array | string, 240 | handler?: RouteHandler | RouteOptions, 241 | options: RouteOptions = {}, 242 | ) { 243 | return this.route(url, [HttpMethods.PUT], handler, options); 244 | } 245 | } 246 | 247 | 248 | 249 | export class RouterEvent { 250 | readonly fetchEvent: FetchEvent | null = null; 251 | readonly route: string; 252 | readonly url: URL; 253 | 254 | _originalRequest: null | Request = null; 255 | _request: null | Request = null; 256 | context: null | ExecutionContext = null; 257 | environment: Record = {}; 258 | parameters: {[key: string]: any} = {}; 259 | 260 | constructor(event: FetchEvent | Request, env?: Record, context?: ExecutionContext) { 261 | if (event instanceof Request) { 262 | this._originalRequest = event as Request; 263 | } else if (event instanceof FetchEvent) { 264 | this.fetchEvent = event as FetchEvent; 265 | } 266 | 267 | if (env) { 268 | this.environment = env; 269 | } 270 | if (context) { 271 | this.context = context; 272 | } 273 | 274 | this.url = new URL(this.originalRequest.url); 275 | this.route = this.url.hostname + (this.url.pathname).replace(/^\/+/, '/'); 276 | } 277 | 278 | get headers(): Headers { 279 | return this.originalRequest.headers; 280 | } 281 | 282 | get ip(): string { 283 | return this.headers.get('cf-connecting-ip') || this.headers.get('x-real-ip') || ''; 284 | } 285 | 286 | get ipv4(): string { 287 | return this.headers.get('x-real-ip') || ''; 288 | } 289 | 290 | get method(): string { 291 | return this.originalRequest.method; 292 | } 293 | 294 | get originalRequest(): Request { 295 | if (this.fetchEvent) { 296 | return this.fetchEvent.request; 297 | } 298 | if (this._originalRequest) { 299 | return this._originalRequest; 300 | } 301 | throw new Error('Invalid FetchEvent or Request was passed in.'); 302 | } 303 | 304 | get query(): URLSearchParams { 305 | return this.url.searchParams; 306 | } 307 | 308 | get request(): Request { 309 | if (this._request) { 310 | return this._request; 311 | } 312 | return this._request = new Request(this.originalRequest); 313 | } 314 | 315 | respondWith(response: any): any { 316 | if (this.fetchEvent) { 317 | return this.fetchEvent.respondWith(response); 318 | } 319 | return Promise.resolve(response); 320 | } 321 | 322 | pass(): Promise { 323 | return fetch((this._request) ? this._request : this.originalRequest); 324 | } 325 | } 326 | --------------------------------------------------------------------------------