├── .eslintrc.json ├── .github └── workflows │ └── tests.yml ├── .gitignore ├── .prettierignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── browser-router.spec.ts ├── browser-router.ts ├── entity.ts ├── helper.ts ├── index.ts ├── manual.typings.d.ts └── router-group.ts ├── tsconfig.build.json ├── tsconfig.json └── webpack.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["coverage", "node_modules", "dist"], 3 | "env": { 4 | "node": true, 5 | "es2022": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module", 11 | "project": "tsconfig.json" 12 | }, 13 | "extends": [ 14 | "eslint:recommended", 15 | "plugin:@typescript-eslint/recommended", 16 | "plugin:import/recommended", 17 | "plugin:import/typescript", 18 | "plugin:prettier/recommended" 19 | ], 20 | "settings": { 21 | "import/parsers": { 22 | "@typescript-eslint/parser": [".ts"] 23 | }, 24 | "import/resolver": { 25 | "typescript": { 26 | "alwaysTryTypes": true 27 | } 28 | } 29 | }, 30 | "rules": { 31 | "semi": ["warn", "always"], 32 | "complexity": ["error"], 33 | "curly": ["error", "all"], 34 | "no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"], 35 | "no-implicit-coercion": ["error", { "boolean": false }], 36 | "no-new-wrappers": ["error"], 37 | "no-new-object": ["error"], 38 | "new-cap": ["error", { "newIsCap": false, "capIsNew": false }], 39 | "no-array-constructor": ["error"], 40 | "max-params": ["error", 5], 41 | "max-lines": ["error", 500], 42 | "max-statements": ["error", 50], 43 | "one-var": ["error", "never"], 44 | "max-nested-callbacks": ["error", 3], 45 | "no-negated-condition": "error", 46 | "no-case-declarations": 0, 47 | "class-methods-use-this": 0, 48 | "no-param-reassign": 0, 49 | "no-use-before-define": 0, 50 | "no-plusplus": 0, 51 | "no-console": 0, 52 | "prefer-destructuring": 0, 53 | "consistent-return": 0, 54 | "guard-for-in": 0, 55 | "max-classes-per-file": 0, 56 | 57 | "@typescript-eslint/no-unused-vars": ["warn", { "vars": "all", "args": "none" }], 58 | "import/no-named-as-default-member": ["off"], 59 | "import/first": ["warn"], 60 | "import/no-duplicates": ["warn"], 61 | "import/order": ["warn"], 62 | "@typescript-eslint/ban-ts-comment": ["error", { "ts-expect-error": true }], 63 | "@typescript-eslint/no-inferrable-types": 0, 64 | "@typescript-eslint/ban-types": 0, 65 | "@typescript-eslint/explicit-module-boundary-types": 0, 66 | "@typescript-eslint/no-explicit-any": 0, 67 | "prettier/prettier": "warn" 68 | }, 69 | "overrides": [ 70 | { 71 | "files": ["webpack.config.ts"], 72 | "env": { 73 | "browser": false, 74 | "node": true 75 | } 76 | }, 77 | { 78 | "files": ["src/**/*{.,-}{spec,it}.ts"], 79 | "env": { 80 | "jest": true 81 | }, 82 | "rules": { 83 | "@typescript-eslint/no-unused-vars": 0, 84 | "@typescript-eslint/unbound-method": 0, 85 | "@typescript-eslint/no-empty-function": 0, 86 | "max-lines": ["error", 1200] 87 | } 88 | } 89 | ] 90 | } 91 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node: [16, 18] 10 | name: Run Tests on ${{ matrix.node }} 11 | 12 | steps: 13 | - run: echo "🎉 The job was automatically triggered by a ${{ github.event_name }} event." 14 | - run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by GitHub!" 15 | - run: echo "🔎 The name of your branch is ${{ github.ref }} and your repository is ${{ github.repository }}." 16 | - name: Check out repository code 17 | uses: actions/checkout@v3 18 | - run: echo "💡 The ${{ github.repository }} repository has been cloned to the runner." 19 | - run: echo "🖥️ The workflow is now ready to test your code on the runner." 20 | 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: ${{ matrix.node }} 24 | 25 | - name: Install 26 | run: yarn install 27 | 28 | - name: Run Tests 29 | run: yarn test 30 | - name: Submit coverage 31 | env: 32 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 33 | run: cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /coverage 3 | /dist 4 | .DS_Store 5 | /.rpt2_cache 6 | yarn-error.log -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | .tmp 5 | 6 | // we will do this via eslint instead 7 | .js 8 | .jsx 9 | .ts 10 | .tsx 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // This will make VsCode to recommend these extensions by default (the ones related to the tools we use) 3 | "recommendations": [ 4 | // make vscode to verify our eslint rules while coding 5 | // https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint 6 | "dbaeumer.vscode-eslint", 7 | // make vscode to verify our prettier formatting rules while coding 8 | // https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode 9 | "esbenp.prettier-vscode" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.formatOnSave": true, 6 | // we will do this via eslint instead 7 | "[javascript, javascriptreact, typescript, typescriptreact]": { 8 | "editor.formatOnSave": false 9 | }, 10 | 11 | "eslint.alwaysShowStatus": true, 12 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], 13 | "editor.codeActionsOnSave": ["source.fixAll.eslint"] 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 rogerpadilla 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. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prouter 2 | 3 | [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/rogerpadilla/prouter/blob/main/LICENSE) 4 | [![tests](https://github.com/rogerpadilla/prouter/actions/workflows/tests.yml/badge.svg)](https://github.com/rogerpadilla/prouter) 5 | [![coverage status](https://coveralls.io/repos/github/rogerpadilla/prouter/badge.svg)](https://coveralls.io/github/rogerpadilla/prouter) 6 | [![npm version](https://badge.fury.io/js/prouter.svg)](https://www.npmjs.com/prouter) 7 | 8 | Fast, unopinionated, minimalist client-side routing library inspired by the simplicity and flexibility of [express middlewares](https://expressjs.com/en/guide/writing-middleware.html). 9 | 10 | Essentially, give `prouter` a list of path expressions (routes) and a callback function (handler) for each one, and `prouter` will automatically invoke these callbacks according to the active path in the URL. 11 | 12 | ## Why prouter? 13 | 14 | - **Performance:** [fast](https://github.com/rogerpadilla/prouter/blob/master/src/browser-router.spec.ts#L7) and tiny size (currently under 5kb before gzipping) are both must-haves to smoothly run in any mobile or desktop browser. 15 | - **KISS principle everywhere:** do only one thing and do it well, routing! Guards? conditional execution? generic pre and post middlewares? all that and more is easily achievable with prouter (see examples below). 16 | - **Learn once:** express router is very powerful, flexible, and simple, why not bring a similar API to the frontend? Under the hood, prouter uses the same (wonderful) library that `express` for parsing routes [path-to-regexp](https://github.com/pillarjs/path-to-regexp) (so it allows the same flexibility to declare routes). Read more about the concept of middlewares [here](https://expressjs.com/en/guide/writing-middleware.html). 17 | - **Unobtrusive:** it is designed from the beginning to play well with vanilla JavaScript or with any other library or framework. 18 | - **Forward-thinking:** written in TypeScript for the future and transpiled to es5 with UMD format for the present... thus it transparently supports any module style: es6, commonJS, AMD. By default, prouter uses the modern [history](https://developer.mozilla.org/en-US/docs/Web/API/History_API) API for routing. 19 | - Unit tests for every feature are created. 20 | 21 | Do you like Prouter? [please give it a 🌟](https://github.com/rogerpadilla/prouter) 22 | 23 | ## Installation 24 | 25 | ```bash 26 | # With NPM 27 | npm install prouter --save 28 | 29 | # Or with Yarn 30 | yarn prouter --save 31 | 32 | # Or just include it using a 'script' tag in your HTML file 33 | 34 | ``` 35 | 36 | ## Examples 37 | 38 | ### basic 39 | 40 | ```js 41 | // Using es6 modules 42 | import { browserRouter } from 'prouter'; 43 | 44 | // Instantiate the router 45 | const router = browserRouter(); 46 | 47 | // Declare the paths and its respective handlers 48 | router 49 | .use('/', async (req, resp) => { 50 | const people = await personService.find(); 51 | const html = PersonListCmp(people); 52 | document.querySelector('.router-outlet') = html; 53 | // end the request-response cycle 54 | resp.end(); 55 | }) 56 | .use('/about', (req, resp) => { 57 | document.querySelector('.router-outlet') = 58 | `

Some static content for the About page.

`; 59 | // end the request-response cycle 60 | resp.end(); 61 | }); 62 | 63 | // start listening for navigation events 64 | router.listen(); 65 | ``` 66 | 67 | ### guard middleware which conditionally avoid executing next handlers and prevent changing the path in the URL 68 | 69 | ```js 70 | // Using commonJs modules 71 | const prouter = require('prouter'); 72 | 73 | // Instantiate the router 74 | const router = prouter.browserRouter({ 75 | processHashChange: true // this allows to process 'hash' changes in the URL. 76 | }); 77 | 78 | // Declare the paths and its respective handlers 79 | router 80 | .use('*', (req, resp, next) => { 81 | // this handler will run for any routing event, before any other handlers 82 | 83 | const isAllowed = authService.validateHasAccessToUrl(req.path); 84 | 85 | if (!isAllowed) { 86 | showAlert("You haven't rights to access the page: " + destPath); 87 | // end the request-response cycle, avoid executing other handlers 88 | // and prevent changing the path in the URL. 89 | resp.preventNavigation = true; 90 | resp.end(); 91 | return; 92 | } 93 | 94 | // pass control to the next handler 95 | next(); 96 | }) 97 | .use('/', (req, resp) => { 98 | // do some stuff... 99 | // and end the request-response cycle 100 | resp.end(); 101 | }) 102 | .use('/admin', (req, resp) => { 103 | // do some stuff... 104 | // and end the request-response cycle 105 | resp.end(); 106 | }); 107 | 108 | // start listening for navigation events 109 | router.listen(); 110 | 111 | // programmatically try to navigate to any route in your router 112 | router.push('/admin'); 113 | ``` 114 | 115 | ### run a generic middleware (for doing some generic stuff) after running specific handlers 116 | 117 | ```js 118 | import { browserRouter } from 'prouter'; 119 | 120 | // Instantiate the router 121 | const router = browserRouter(); 122 | 123 | // Declare the paths and its respective handlers 124 | router 125 | .use('/', async (req, resp, next) => { 126 | const people = await personService.find(); 127 | const html = PersonListCmp(people); 128 | document.querySelector('.router-outlet') = html; 129 | // pass control to the next handler 130 | next(); 131 | }) 132 | .use('*', (req, resp) => { 133 | // do some (generic) stuff... 134 | // and end the request-response cycle 135 | resp.end(); 136 | }); 137 | 138 | // start listening for navigation events 139 | router.listen(); 140 | ``` 141 | 142 | ### modularize your routing code in different files using Router Group 143 | 144 | ```js 145 | import { browserRouter, routerGroup } from 'prouter'; 146 | 147 | // this can be in a different file for modularization of the routes, 148 | // and then import it in your main routes file and mount it. 149 | const productRouterGroup = routerGroup(); 150 | 151 | productRouterGroup 152 | .use('/', (req, resp) => { 153 | // do some stuff... 154 | // and end the request-response cycle 155 | resp.end(); 156 | }) 157 | .use('/create', (req, resp) => { 158 | // do some stuff... 159 | // and end the request-response cycle 160 | resp.end(); 161 | }) 162 | .use('/:id(\\d+)', (req, resp) => { 163 | const id = req.params.id; 164 | // do some stuff with the 'id'... 165 | // and end the request-response cycle 166 | resp.end(); 167 | }); 168 | 169 | // Instantiate the router 170 | const router = browserRouter(); 171 | 172 | // Declare the paths and its respective handlers 173 | router 174 | .use('*', (req, resp, next) => { 175 | // this handler will run for any routing event, before any other handlers 176 | console.log('request info', req); 177 | // pass control to the next handler 178 | next(); 179 | }) 180 | .use('/', (req, resp) => { 181 | // do some stuff... 182 | // and end the request-response cycle 183 | resp.end(); 184 | }) 185 | // mount the product's group of handlers using this base path 186 | .use('/product', productRouterGroup); 187 | 188 | // start listening for the routing 189 | router.listen(); 190 | 191 | // programmatically navigate to the detail of the product with this ID 192 | router.push('/product/123'); 193 | ``` 194 | 195 | ### full example: modularized routing, generic pre handler acting as a guard, generic post handler 196 | 197 | ```js 198 | import { browserRouter, routerGroup } from 'prouter'; 199 | 200 | // this can be in a different file for modularization of the routes, 201 | // and then import it in your main routes file and mount it. 202 | const productRouterGroup = routerGroup(); 203 | 204 | productRouterGroup 205 | .use('/', (req, resp, next) => { 206 | // do some stuff... 207 | // and pass control to the next handler 208 | next(); 209 | }) 210 | .use('/create', (req, resp, next) => { 211 | // do some stuff... 212 | // and pass control to the next handler 213 | next(); 214 | }) 215 | .use('/:id(\\d+)', (req, resp, next) => { 216 | const id = req.params.id; 217 | // do some stuff with the 'id'... 218 | // and pass control to the next handler 219 | next(); 220 | }); 221 | 222 | // Instantiate the router 223 | const router = browserRouter(); 224 | 225 | // Declare the paths and its respective handlers 226 | router 227 | .use('*', (req, resp, next) => { 228 | 229 | // this handler will run for any routing event, before any other handlers 230 | 231 | const isAllowed = authService.validateHasAccessToUrl(req.path); 232 | 233 | if (!isAllowed) { 234 | showAlert("You haven't rights to access the page: " + destPath); 235 | // end the request-response cycle, avoid executing next handlers 236 | // and prevent changing the path in the URL. 237 | resp.preventNavigation = true; 238 | resp.end(); 239 | return; 240 | } 241 | 242 | // pass control to the next handler 243 | next(); 244 | }) 245 | .use('/', (req, resp, next) => { 246 | 247 | const doInfiniteScroll = () => { 248 | // do infinite scroll ... 249 | }; 250 | 251 | const onNavigation = (navigationEvt) => { 252 | console.log('new path', navigationEvt.oldPath); 253 | console.log('old path', navigationEvt.newPath); 254 | // if navigating, then remove the listener for the window.scroll. 255 | router.off('navigation', onNavigation); 256 | window.removeEventListener('scroll', doInfiniteScroll); 257 | }; 258 | 259 | window.addEventListener('scroll', doInfiniteScroll); 260 | 261 | // subscribe to the navigation event 262 | router.on('navigation', onNavigation); 263 | 264 | // and pass control to the next handler 265 | next(); 266 | }) 267 | .use('/login', () => { 268 | openLoginModal(); 269 | // as this route opens a modal, we would want to prevent navigation in this handler, 270 | // so end the request-response cycle, avoid executing next handlers 271 | // and prevent changing the path in the URL. 272 | resp.preventNavigation = true; 273 | resp.end(); 274 | }) 275 | .use('/admin', (req, resp, next) => { 276 | // do some stuff... 277 | // and pass control to the next handler 278 | next(); 279 | }) 280 | // mount the product's group of handlers using this base path 281 | .use('/product', productRouterGroup) 282 | .use('*', (req, res, next) => { 283 | 284 | // this handler will run for any routing event, after the other handlers 285 | 286 | // req.listening will be true when this callback was called due to a 287 | // client-side navigation (useful to differentiate client-side vs 288 | // server-side rendering - when using a mix of both SSR and CSR) 289 | if (req.listening) { 290 | const title = inferTitleFromPath(req.path, APP_TITLE); 291 | updatePageTitle(title); 292 | } 293 | 294 | // end the request-response cycle 295 | resp.end(); 296 | }); 297 | 298 | // start listening for the routing 299 | router.listen(); 300 | 301 | 302 | // the below code is an example about how you could capture clicks on links, 303 | // and accordingly, trigger routing navigation in your app 304 | // (typically, you would put it in a separated file) 305 | 306 | export function isNavigationPath(path: string) { 307 | return !!path && !path.startsWith('javascript:void'); 308 | } 309 | 310 | export function isExternalPath(path: string) { 311 | return /^https?:\/\//.test(path); 312 | } 313 | 314 | export function isApplicationPath(path: string) { 315 | return isNavigationPath(path) && !isExternalPath(path); 316 | } 317 | 318 | document.body.addEventListener('click', (evt) => { 319 | 320 | const target = evt.target as Element; 321 | let link: Element; 322 | 323 | if (target.nodeName === 'A') { 324 | link = target; 325 | } else { 326 | link = target.closest('a'); 327 | if (!link) { 328 | return; 329 | } 330 | } 331 | 332 | const url = link.getAttribute('href'); 333 | 334 | // do nothing if it is not an app's internal link 335 | if (!isApplicationPath(url)) { 336 | return; 337 | } 338 | 339 | // avoid the default browser's behaviour when clicking on a link 340 | // (i.e. do not reload the page). 341 | evt.preventDefault(); 342 | 343 | // it is a normal app's link, so trigger the routing navigation 344 | router.push(url); 345 | }); 346 | ``` 347 | 348 | ### see more advanced usages in the [unit tests.](https://github.com/rogerpadilla/prouter/blob/master/src/browser-router.spec.ts) 349 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prouter", 3 | "description": "Fast, unopinionated, minimalist client-side routing library inspired by the simplicity and flexibility of express middlewares", 4 | "version": "10.0.27", 5 | "main": "prouter.min.js", 6 | "homepage": "https://github.com/rogerpadilla/prouter", 7 | "bugs": { 8 | "url": "https://github.com/rogerpadilla/prouter/issues" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/rogerpadilla/prouter.git" 13 | }, 14 | "scripts": { 15 | "eslint": "eslint --fix 'src/**/*.ts'", 16 | "lint": "npm run eslint", 17 | "test": "rimraf coverage && npm run lint && jest", 18 | "test.watch": "npm run lint && rimraf coverage && jest --watchAll", 19 | "start": "rimraf dist && webpack --watch", 20 | "build": "rimraf dist && NODE_ENV=production webpack" 21 | }, 22 | "keywords": [ 23 | "client-side", 24 | "browser", 25 | "web", 26 | "mobile", 27 | "router", 28 | "routing", 29 | "library", 30 | "middleware" 31 | ], 32 | "license": "MIT", 33 | "contributors": [ 34 | "Roger Padilla " 35 | ], 36 | "dependencies": { 37 | "path-to-regexp": "~0.1.11" 38 | }, 39 | "devDependencies": { 40 | "@types/jest": "^29.5.14", 41 | "@types/node": "^22.8.4", 42 | "@types/webpack": "^5.28.5", 43 | "@typescript-eslint/eslint-plugin": "^8.12.2", 44 | "@typescript-eslint/parser": "^8.12.2", 45 | "copy-webpack-plugin": "^12.0.2", 46 | "coveralls": "^3.1.1", 47 | "eslint": "^8.57.0", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-import-resolver-typescript": "^3.6.3", 50 | "eslint-plugin-import": "^2.31.0", 51 | "eslint-plugin-prettier": "^5.2.1", 52 | "jest": "^29.7.0", 53 | "jest-environment-jsdom": "^29.7.0", 54 | "prettier": "^3.3.3", 55 | "rimraf": "^6.0.1", 56 | "source-map-loader": "^5.0.0", 57 | "ts-jest": "^29.2.5", 58 | "ts-loader": "^9.5.1", 59 | "ts-node": "^10.9.2", 60 | "typescript": "^5.6.3", 61 | "webpack": "^5.95.0", 62 | "webpack-cli": "^5.1.4" 63 | }, 64 | "prettier": { 65 | "trailingComma": "none", 66 | "tabWidth": 2, 67 | "semi": true, 68 | "singleQuote": true, 69 | "printWidth": 120 70 | }, 71 | "jest": { 72 | "verbose": true, 73 | "testEnvironment": "jsdom", 74 | "roots": [ 75 | "/src" 76 | ], 77 | "transform": { 78 | "^.+\\.tsx?$": "ts-jest" 79 | }, 80 | "testMatch": [ 81 | "**/*.spec.ts" 82 | ], 83 | "moduleFileExtensions": [ 84 | "ts", 85 | "js", 86 | "json", 87 | "node" 88 | ], 89 | "collectCoverage": true, 90 | "coverageReporters": [ 91 | "html", 92 | "lcov" 93 | ], 94 | "coverageDirectory": "coverage" 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/browser-router.spec.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:max-file-line-count 2 | 3 | import { browserRouter } from './browser-router'; 4 | import { ProuterBrowserRouter } from './entity'; 5 | import { routerGroup } from './router-group'; 6 | 7 | describe('browserRouter', () => { 8 | // Ensure each test completes in less than this short amout of milliseconds. 9 | jest.setTimeout(20); 10 | 11 | let router: ProuterBrowserRouter; 12 | 13 | beforeAll(() => { 14 | const htmlElementsCache = {} as Record; 15 | document.querySelector = jest.fn((selector: string) => { 16 | if (!selector) { 17 | return undefined; 18 | } 19 | if (!htmlElementsCache[selector]) { 20 | const newElement = document.createElement('div'); 21 | htmlElementsCache[selector] = newElement; 22 | } 23 | return htmlElementsCache[selector]; 24 | }); 25 | }); 26 | 27 | beforeEach(() => { 28 | history.pushState(undefined, '', '/'); 29 | location.hash = ''; 30 | router = browserRouter(); 31 | }); 32 | 33 | afterEach(() => { 34 | router.stop(); 35 | }); 36 | 37 | it('basic', (done) => { 38 | expect(router.getPath()).toBe('/'); 39 | 40 | router 41 | .use('/', (req, res) => { 42 | expect(req.path).toBe('/'); 43 | expect(req.queryString).toBe(''); 44 | expect(req.query).toEqual({}); 45 | expect(router.getPath()).toBe('/'); 46 | res.end(); 47 | done(); 48 | }) 49 | .listen(); 50 | }); 51 | 52 | it('basic chain - no push', (done) => { 53 | expect(router.getPath()).toBe('/'); 54 | 55 | let msg = ''; 56 | 57 | router 58 | .use('/', (req, res, next) => { 59 | expect(req.path).toBe('/'); 60 | expect(req.queryString).toBe(''); 61 | expect(req.query).toEqual({}); 62 | expect(router.getPath()).toBe('/'); 63 | msg = 'changed'; 64 | next(); 65 | }) 66 | .use('*', (req, res) => { 67 | expect(msg).toBe('changed'); 68 | expect(router.getPath()).toBe('/'); 69 | res.end(); 70 | done(); 71 | }) 72 | .listen(); 73 | }); 74 | 75 | it('basic chain - push', (done) => { 76 | expect(router.getPath()).toBe('/'); 77 | 78 | let msg = ''; 79 | 80 | router.use('/about', (req, res, next) => { 81 | expect(req.path).toBe('/about'); 82 | expect(req.queryString).toBe(''); 83 | expect(req.query).toEqual({}); 84 | expect(router.getPath()).toBe('/'); 85 | msg = 'changed'; 86 | res.end(); 87 | }); 88 | 89 | router.on('navigation', (navigationEvt) => { 90 | expect(router.getPath()).toBe('/about'); 91 | expect(navigationEvt.oldPath).toBe('/'); 92 | expect(navigationEvt.newPath).toBe('/about'); 93 | expect(msg).toBe('changed'); 94 | done(); 95 | }); 96 | 97 | router.push('/about'); 98 | }); 99 | 100 | it('basic chain - push - old browser', (done) => { 101 | const _URL = window.URL; 102 | // Emulates old browsers which doesn't supports URL constructor 103 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 104 | // @ts-expect-error 105 | delete window.URL; 106 | const domCreateElement = document.createElement; 107 | 108 | // Router will use 'createElement("a")' as fallback for parsing paths 109 | // when the URL's constructor is not available (old browsers). 110 | document.createElement = (tag: string) => { 111 | if (tag === 'a') { 112 | // tslint:disable-next-line:no-any 113 | return new _URL('', 'http://example.com') as any; 114 | } 115 | return domCreateElement(tag); 116 | }; 117 | 118 | expect(router.getPath()).toBe('/'); 119 | 120 | let msg = ''; 121 | 122 | router 123 | .use('/about', (req, res, next) => { 124 | expect(req.path).toBe('/about'); 125 | expect(req.queryString).toBe(''); 126 | expect(req.query).toEqual({}); 127 | msg = 'changed'; 128 | next(); 129 | }) 130 | .use('*', (req, res, next) => { 131 | expect(msg).toBe('changed'); 132 | window.URL = _URL; 133 | document.createElement = domCreateElement; 134 | res.end(); 135 | done(); 136 | }); 137 | 138 | router.push('/about'); 139 | }); 140 | 141 | it('process current path when listen', (done) => { 142 | router 143 | .use('/', (req, res) => { 144 | expect(req.listening).toBeFalsy(); 145 | res.end(); 146 | done(); 147 | }) 148 | .listen(); 149 | }); 150 | 151 | it('proper listening - push', (done) => { 152 | router 153 | .use('/something', () => { 154 | fail('This should not be called'); 155 | }) 156 | .use('/about', (req, res) => { 157 | expect(req.listening).toBeTruthy(); 158 | res.end(); 159 | done(); 160 | }) 161 | .listen(); 162 | 163 | router.push('/about'); 164 | }); 165 | 166 | it('parameters', (done) => { 167 | router 168 | .use('/some-path/:id(\\d+)/:tag', (req, res) => { 169 | expect(req.params.id).toBe('16'); 170 | expect(req.params.tag).toBe('abc'); 171 | res.end(); 172 | done(); 173 | }) 174 | .listen(); 175 | 176 | router.push('/some-path/16/abc'); 177 | }); 178 | 179 | it('query', (done) => { 180 | router.use('/something', (req, res) => { 181 | expect(req.queryString).toBe('?first=5&second=6'); 182 | expect(req.query).toEqual({ first: '5', second: '6' }); 183 | res.end(); 184 | done(); 185 | }); 186 | 187 | router.push('/something?first=5&second=6'); 188 | }); 189 | 190 | it('parameters & query', (done) => { 191 | router 192 | .use('/something/:param1/:param2', (req, res) => { 193 | expect(req.params).toEqual({ param1: '16', param2: '18' }); 194 | expect(req.queryString).toBe('?first=5&second=6'); 195 | expect(req.query).toEqual({ first: '5', second: '6' }); 196 | res.end(); 197 | done(); 198 | }) 199 | .listen(); 200 | 201 | router.push('/something/16/18?first=5&second=6'); 202 | }); 203 | 204 | it('divided parameters', (done) => { 205 | router 206 | .use('/something/:param1/other/:param2', (req, res) => { 207 | expect(req.params).toEqual({ param1: '16', param2: '18' }); 208 | expect(req.query).toEqual({ first: '5', second: '6' }); 209 | res.end(); 210 | done(); 211 | }) 212 | .listen(); 213 | 214 | router.push('/something/16/other/18?first=5&second=6'); 215 | }); 216 | 217 | it('do not call if no match', (done) => { 218 | router 219 | .use('/abc/:p1/other/:p2', () => { 220 | fail('This should not be called'); 221 | }) 222 | .use('*', (req, res) => { 223 | res.end(); 224 | done(); 225 | }); 226 | 227 | router.push('/something/16/other/18?q1=5&q2=6'); 228 | }); 229 | 230 | it('pass custom params to next middleware', (done) => { 231 | router 232 | .use('/something/:p1/other/:p2', (req, res, next) => { 233 | expect(req.query).toEqual({ q1: '5', q2: '6' }); 234 | req.query.q3 = '7'; 235 | req.params.customParam = 123; 236 | next(); 237 | }) 238 | .use('*', (req, res) => { 239 | expect(req.query).toEqual({ q1: '5', q2: '6', q3: '7' }); 240 | expect(req.params).toMatchObject({ p1: '16', p2: '18', customParam: 123 }); 241 | res.end(); 242 | done(); 243 | }); 244 | 245 | router.push('/something/16/other/18?q1=5&q2=6'); 246 | }); 247 | 248 | it('order', (done) => { 249 | expect(router.getPath()).toBe('/'); 250 | 251 | let msg = ''; 252 | 253 | router 254 | .use('/about', (req, res, next) => { 255 | expect(req.path).toBe('/about'); 256 | expect(req.queryString).toBe(''); 257 | expect(req.query).toEqual({}); 258 | expect(router.getPath()).toBe('/'); 259 | msg = 'hello'; 260 | next(); 261 | }) 262 | .use('*', (req, res) => { 263 | expect(msg).toBe('hello'); 264 | expect(router.getPath()).toBe('/'); 265 | res.end(); 266 | done(); 267 | }); 268 | 269 | router.push('/about'); 270 | }); 271 | 272 | it('end and prevent navigation', () => { 273 | expect(router.getPath()).toBe('/'); 274 | 275 | router 276 | .use('/about', (req, res, next) => { 277 | expect(req.path).toBe('/about'); 278 | expect(req.queryString).toBe(''); 279 | expect(req.query).toEqual({}); 280 | expect(router.getPath()).toBe('/'); 281 | expect(res.preventNavigation).toBeUndefined(); 282 | res.preventNavigation = true; 283 | next(); 284 | }) 285 | .use('*', (req, res) => { 286 | expect(res.preventNavigation).toBe(true); 287 | res.end(); 288 | }); 289 | 290 | router.push('/about'); 291 | 292 | expect(router.getPath()).toBe('/'); 293 | }); 294 | 295 | it('next() in every callback', (done) => { 296 | expect(router.getPath()).toBe('/'); 297 | 298 | router 299 | .use('/about', (req, res, next) => { 300 | expect(req.path).toBe('/about'); 301 | expect(req.queryString).toBe(''); 302 | expect(req.query).toEqual({}); 303 | expect(router.getPath()).toBe('/'); 304 | next(); 305 | }) 306 | .use('*', (req, res, next) => { 307 | expect(router.getPath()).toBe('/'); 308 | next(); 309 | done(); 310 | }); 311 | 312 | router.push('/about'); 313 | }); 314 | 315 | it('Throws error if try to listen more than once', () => { 316 | router.listen(); 317 | 318 | expect(() => { 319 | router.listen(); 320 | }).toThrowError(); 321 | }); 322 | 323 | it('RouterGroup', (done) => { 324 | const group = routerGroup(); 325 | 326 | group.use('/ask', () => { 327 | done(); 328 | }); 329 | 330 | router.use('/question', group); 331 | 332 | router.push('/question/ask'); 333 | }); 334 | 335 | it('RouterGroup with params', (done) => { 336 | const group = routerGroup(); 337 | 338 | group.use('/:p1/other/:p2', (req, res) => { 339 | expect(req.path).toBe('/something/16/other/18'); 340 | res.end(); 341 | done(); 342 | }); 343 | 344 | router.use('/something', group); 345 | 346 | router.push('/something/16/other/18?q1=5&q2=6'); 347 | }); 348 | 349 | it('Emulate browsers with URL support', (done) => { 350 | // tslint:disable-next-line:no-unnecessary-class 351 | class MyURL { 352 | constructor(path: string) { 353 | const parser = document.createElement('a'); 354 | parser.href = 'http://example.com' + path; 355 | const propsToCopy = ['pathname', 'hash', 'hostname', 'host', 'search'] satisfies (keyof typeof parser)[]; 356 | for (const prop of propsToCopy) { 357 | (this as any)[prop] = parser[prop]; 358 | } 359 | } 360 | } 361 | 362 | const _URL = window.URL; 363 | window.URL = MyURL as typeof _URL; 364 | 365 | router.use('/about', (req, res) => { 366 | res.end(); 367 | window.URL = _URL; 368 | done(); 369 | }); 370 | 371 | router.push('/about'); 372 | }); 373 | 374 | it('should produce navigation event', (done) => { 375 | expect(router.getPath()).toBe('/'); 376 | 377 | router 378 | .use('/hello', (req, res, next) => { 379 | expect(req.path).toBe('/hello'); 380 | expect(req.queryString).toBe(''); 381 | expect(req.query).toEqual({}); 382 | expect(router.getPath()).toBe('/'); 383 | next(); 384 | }) 385 | .listen(); 386 | 387 | router.on('navigation', (navigationEvt) => { 388 | expect(router.getPath()).toBe('/hello'); 389 | expect(navigationEvt.oldPath).toBe('/'); 390 | expect(navigationEvt.newPath).toBe('/hello'); 391 | done(); 392 | }); 393 | 394 | router.push('/hello'); 395 | }); 396 | 397 | it('should not produce navigation event', () => { 398 | expect(router.getPath()).toBe('/'); 399 | 400 | router 401 | .use('/', (req, res, next) => { 402 | expect(req.path).toBe('/'); 403 | expect(req.queryString).toBe(''); 404 | expect(req.query).toEqual({}); 405 | expect(router.getPath()).toBe('/'); 406 | next(); 407 | }) 408 | .listen(); 409 | 410 | router.on('navigation', () => { 411 | fail('Should not navigate since no match in the registered handlers'); 412 | }); 413 | 414 | router.push('/hello'); 415 | }); 416 | 417 | it('should unsubscribe from navigation event', (done) => { 418 | expect(router.getPath()).toBe('/'); 419 | 420 | router 421 | .use('/hello', (req, res, next) => { 422 | expect(req.path).toBe('/hello'); 423 | expect(req.queryString).toBe(''); 424 | expect(req.query).toEqual({}); 425 | expect(router.getPath()).toBe('/'); 426 | next(); 427 | done(); 428 | }) 429 | .listen(); 430 | 431 | const onNavigation = () => { 432 | fail('Should not enter here since unsubscribed'); 433 | }; 434 | 435 | router.on('navigation', onNavigation); 436 | 437 | router.off('navigation', onNavigation); 438 | 439 | router.push('/hello'); 440 | }); 441 | 442 | it('should ignore "hash" changes by default', (done) => { 443 | expect(router.getPath()).toBe('/'); 444 | 445 | let counter = 0; 446 | 447 | router 448 | .use('/', (req, res, next) => { 449 | expect(req.path).toBe('/'); 450 | expect(req.queryString).toBe(''); 451 | expect(req.query).toEqual({}); 452 | counter++; 453 | next(); 454 | }) 455 | .listen(); 456 | 457 | window.onhashchange = () => { 458 | expect(counter).toBe(1); 459 | done(); 460 | }; 461 | 462 | location.hash = 'something'; 463 | }); 464 | 465 | it('should not ignore "hash" changes if configured', (done) => { 466 | const localRouter = browserRouter({ 467 | processHashChange: true 468 | }); 469 | 470 | expect(localRouter.getPath()).toBe('/'); 471 | 472 | let counter = 0; 473 | 474 | localRouter 475 | .use('/', (req, res, next) => { 476 | expect(req.path).toBe('/'); 477 | expect(req.queryString).toBe(''); 478 | expect(req.query).toEqual({}); 479 | counter++; 480 | next(); 481 | }) 482 | .listen(); 483 | 484 | window.onhashchange = () => { 485 | expect(counter).toBe(2); 486 | done(); 487 | }; 488 | 489 | location.hash = 'something'; 490 | }); 491 | 492 | it('should trigger "prouter.onnavigation" event on "history.back"', (done) => { 493 | expect(router.getPath()).toBe('/'); 494 | 495 | history.pushState(undefined, '', '/hello'); 496 | 497 | const init = () => { 498 | router 499 | .use('/', (req, res, next) => { 500 | expect(req.path).toBe('/'); 501 | expect(req.queryString).toBe(''); 502 | expect(req.query).toEqual({}); 503 | expect(router.getPath()).toBe('/'); 504 | window.removeEventListener('popstate', init); 505 | next(); 506 | done(); 507 | }) 508 | .listen(); 509 | }; 510 | 511 | window.addEventListener('popstate', init); 512 | 513 | history.back(); 514 | }); 515 | 516 | it('should trigger "prouter.onnavigation" event on "history.forward"', (done) => { 517 | expect(router.getPath()).toBe('/'); 518 | 519 | let counter = 0; 520 | 521 | history.pushState(undefined, '', '/hello'); 522 | 523 | const init = () => { 524 | counter++; 525 | if (counter < 2) { 526 | history.forward(); 527 | return; 528 | } 529 | router 530 | .use('/hello', (req, res, next) => { 531 | expect(req.path).toBe('/hello'); 532 | expect(req.queryString).toBe(''); 533 | expect(req.query).toEqual({}); 534 | expect(router.getPath()).toBe('/hello'); 535 | window.removeEventListener('popstate', init); 536 | next(); 537 | done(); 538 | }) 539 | .listen(); 540 | }; 541 | 542 | window.addEventListener('popstate', init); 543 | 544 | history.back(); 545 | }); 546 | }); 547 | -------------------------------------------------------------------------------- /src/browser-router.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProuterSubscriptors, 3 | ProuterSubscriptionType, 4 | ProuterSubscriptorCallback, 5 | ProuterNavigationEvent, 6 | ProuterBrowserRouter, 7 | ProuterBrowserOptions, 8 | ProuterParsedHandler, 9 | ProuterRequestCallback, 10 | ProuterGroup, 11 | ProuterRouter, 12 | ProuterProcessPathCallback, 13 | ProuterResponse, 14 | ProuterProcessPathOptions, 15 | ProuterNextMiddleware 16 | } from './entity'; 17 | import { routerHelper } from './helper'; 18 | 19 | export function browserRouter(options: ProuterBrowserOptions = {}) { 20 | const handlers: ProuterParsedHandler[] = []; 21 | let listening = false; 22 | let previousPath = routerHelper.getPath(); 23 | const subscriptors: ProuterSubscriptors = { 24 | navigation: [] 25 | }; 26 | 27 | const onPopState = () => { 28 | const newPath = routerHelper.getPath(); 29 | /* 'popstate' event is also triggered for 'hash' changes (in the URL), 30 | * ignore them if the 'processHashChange' option is not provided and if the 31 | * path didn't changed. */ 32 | if (!options.processHashChange && newPath === previousPath) { 33 | return; 34 | } 35 | br.processCurrentPath(); 36 | triggerOnNavigation({ oldPath: previousPath, newPath }); 37 | previousPath = newPath; 38 | }; 39 | 40 | const triggerOnNavigation = (navigationEvt: ProuterNavigationEvent) => { 41 | subscriptors.navigation.forEach((subscriptor) => { 42 | subscriptor(navigationEvt); 43 | }); 44 | }; 45 | 46 | const br: ProuterBrowserRouter = { 47 | listen() { 48 | if (listening) { 49 | throw new Error('Already listening.'); 50 | } 51 | br.processCurrentPath(); 52 | addEventListener('popstate', onPopState); 53 | listening = true; 54 | }, 55 | 56 | stop() { 57 | removeEventListener('popstate', onPopState); 58 | }, 59 | 60 | use(path: string, callback: ProuterRequestCallback | ProuterGroup): ProuterRouter { 61 | if (typeof callback === 'function') { 62 | const pathExp = routerHelper.stringToRegexp(path); 63 | handlers.push({ path, pathExp, callback }); 64 | } else { 65 | for (const handler of callback.handlers) { 66 | const itPath = path + handler.path; 67 | const pathExp = routerHelper.stringToRegexp(itPath); 68 | handlers.push({ path: itPath, pathExp, callback: handler.callback }); 69 | } 70 | } 71 | return this; 72 | }, 73 | 74 | processPath(path: string, processPathCallback?: ProuterProcessPathCallback) { 75 | const requestProcessors = routerHelper.obtainRequestProcessors(path, handlers); 76 | 77 | if (requestProcessors.length === 0) { 78 | return; 79 | } 80 | 81 | const listeningSnapshop = listening; 82 | let wasProcessPathCallbackCalled: boolean; 83 | let index = 0; 84 | 85 | const response: ProuterResponse = { 86 | end() { 87 | if (processPathCallback && !wasProcessPathCallbackCalled) { 88 | wasProcessPathCallbackCalled = true; 89 | const opts: ProuterProcessPathOptions = { preventNavigation: response.preventNavigation }; 90 | processPathCallback(opts); 91 | } 92 | } 93 | }; 94 | 95 | /** Call the middlewares for the given path. */ 96 | const next: ProuterNextMiddleware = () => { 97 | // If next was called and the last processor was already executed then automatically stop. 98 | if (index === requestProcessors.length) { 99 | response.end(); 100 | return; 101 | } 102 | 103 | const reqProc = requestProcessors[index]; 104 | reqProc.request.listening = listeningSnapshop; 105 | 106 | index++; 107 | 108 | reqProc.callback(reqProc.request, response, next); 109 | }; 110 | 111 | next(); 112 | }, 113 | 114 | getPath: routerHelper.getPath, 115 | 116 | push(newPath: string) { 117 | br.processPath(newPath, (opts) => { 118 | if (!opts || !opts.preventNavigation) { 119 | const oldPath = br.getPath(); 120 | history.pushState(undefined, '', newPath); 121 | triggerOnNavigation({ oldPath, newPath }); 122 | } 123 | }); 124 | }, 125 | 126 | processCurrentPath() { 127 | const path = br.getPath(); 128 | br.processPath(path); 129 | }, 130 | 131 | on(type: ProuterSubscriptionType, callback: ProuterSubscriptorCallback) { 132 | subscriptors[type].push(callback); 133 | }, 134 | 135 | off(type: ProuterSubscriptionType, callback: ProuterSubscriptorCallback) { 136 | subscriptors[type] = subscriptors[type].filter((cb) => { 137 | return cb !== callback; 138 | }); 139 | } 140 | }; 141 | 142 | return br; 143 | } 144 | -------------------------------------------------------------------------------- /src/entity.ts: -------------------------------------------------------------------------------- 1 | export interface ProuterRequestCallback { 2 | // tslint:disable-next-line:no-any 3 | (req: ProuterRequest, resp: ProuterResponse, next: ProuterNextMiddleware): any; 4 | } 5 | 6 | export interface ProuterPath { 7 | readonly path: string; 8 | readonly queryString: string; 9 | readonly query: ProuterStringMap; 10 | } 11 | 12 | export interface ProuterStringMap { 13 | // tslint:disable-next-line:no-any 14 | [prop: string]: any; 15 | } 16 | 17 | export interface ProuterPathKey { 18 | readonly name: string | number; 19 | readonly prefix: string; 20 | readonly delimiter: string; 21 | readonly optional: boolean; 22 | readonly repeat: boolean; 23 | readonly pattern: string; 24 | readonly partial: boolean; 25 | } 26 | 27 | export interface ProuterPathExp extends RegExp { 28 | keys: ProuterPathKey[]; 29 | } 30 | 31 | export interface ProuterHandler { 32 | readonly path: string; 33 | readonly callback: ProuterRequestCallback; 34 | } 35 | 36 | export interface ProuterParsedHandler extends ProuterHandler { 37 | readonly pathExp: ProuterPathExp; 38 | } 39 | 40 | export interface ProuterRequest extends ProuterPath { 41 | params: ProuterStringMap; 42 | listening?: boolean; 43 | } 44 | 45 | export interface ProuterProcessPathOptions { 46 | preventNavigation?: boolean; 47 | } 48 | 49 | export interface ProuterResponse extends ProuterProcessPathOptions { 50 | end(): void; 51 | } 52 | 53 | export interface ProuterRequestProcessor { 54 | readonly request: ProuterRequest; 55 | readonly callback: ProuterRequestCallback; 56 | } 57 | 58 | export interface ProuterProcessPathCallback { 59 | (opts?: ProuterProcessPathOptions): void; 60 | } 61 | 62 | export interface ProuterNextMiddleware { 63 | (): void; 64 | } 65 | 66 | export interface ProuterGroup { 67 | readonly handlers: ProuterHandler[]; 68 | use(path: string, callback: ProuterRequestCallback): ProuterGroup; 69 | } 70 | 71 | export interface ProuterRouter { 72 | use(path: string, callback: ProuterRequestCallback | ProuterGroup): ProuterRouter; 73 | listen(): void; 74 | processPath(path: string, processPathCallback?: ProuterProcessPathCallback): void; 75 | } 76 | 77 | export interface ProuterBrowserOptions { 78 | readonly processHashChange?: boolean; 79 | } 80 | 81 | export interface ProuterBrowserRouter extends ProuterRouter { 82 | processCurrentPath(): void; 83 | getPath(): string; 84 | push(path: string): void; 85 | stop(): void; 86 | on(type: ProuterSubscriptionType, callback: ProuterSubscriptorCallback): void; 87 | off(type: ProuterSubscriptionType, callback: ProuterSubscriptorCallback): void; 88 | } 89 | 90 | export interface ProuterNavigationEvent { 91 | readonly oldPath: string; 92 | readonly newPath: string; 93 | } 94 | 95 | export interface ProuterSubscriptorCallback { 96 | (evt: ProuterNavigationEvent): void; 97 | } 98 | 99 | export interface ProuterSubscriptors { 100 | navigation: ProuterSubscriptorCallback[]; 101 | } 102 | 103 | export type ProuterSubscriptionType = keyof ProuterSubscriptors; 104 | 105 | // Dont delete this dummy, TS do not create the definition of the file if only interfaces 106 | export const prouterSomethingToMakeTsToExportThisFile = 1; 107 | -------------------------------------------------------------------------------- /src/helper.ts: -------------------------------------------------------------------------------- 1 | import * as pathToRegexp from 'path-to-regexp'; 2 | 3 | import { 4 | ProuterPath, 5 | ProuterPathExp, 6 | ProuterRequestProcessor, 7 | ProuterRequest, 8 | ProuterParsedHandler, 9 | ProuterPathKey 10 | } from './entity'; 11 | 12 | export const routerHelper = { 13 | getPath() { 14 | return decodeURI(location.pathname + location.search); 15 | }, 16 | 17 | stringToRegexp(str: string) { 18 | const keys: ProuterPathKey[] = []; 19 | const resp = pathToRegexp(str, keys) as ProuterPathExp; 20 | resp.keys = keys; 21 | return resp; 22 | }, 23 | 24 | parseQuery(str: string) { 25 | const searchObj: { [key: string]: string } = {}; 26 | 27 | if (str === '') { 28 | return searchObj; 29 | } 30 | 31 | const qs = str.slice(1); 32 | const args = qs.split('&'); 33 | 34 | for (const arg of args) { 35 | const paramKv = arg.split('='); 36 | searchObj[decodeURIComponent(paramKv[0])] = decodeURIComponent(paramKv[1]); 37 | } 38 | 39 | return searchObj; 40 | }, 41 | 42 | parsePath(path: string) { 43 | let url: URL | HTMLAnchorElement; 44 | 45 | if (typeof URL === 'function') { 46 | url = new URL(path, 'http://example.com'); 47 | } else { 48 | url = document.createElement('a'); 49 | url.href = 'http://example.com' + path; 50 | } 51 | 52 | const parsedPath: Partial = { 53 | path: url.pathname, 54 | queryString: url.search, 55 | query: routerHelper.parseQuery(url.search) 56 | }; 57 | 58 | return parsedPath; 59 | }, 60 | 61 | /** 62 | * Obtain the request processors for the given path according to the handlers in the router. 63 | */ 64 | obtainRequestProcessors(path: string, handlers: ProuterParsedHandler[]) { 65 | const parsedPath = routerHelper.parsePath(path); 66 | const requestProcessors: ProuterRequestProcessor[] = []; 67 | const req = parsedPath as ProuterRequest; 68 | req.params = {}; 69 | 70 | for (const handler of handlers) { 71 | const result = handler.pathExp.exec(req.path); 72 | 73 | if (result) { 74 | const params = result.slice(1); 75 | const keys = handler.pathExp.keys; 76 | 77 | for (let i = 0; i < params.length; i++) { 78 | req.params[keys[i].name] = decodeURIComponent(params[i]); 79 | } 80 | 81 | requestProcessors.push({ callback: handler.callback, request: req }); 82 | } 83 | } 84 | 85 | return requestProcessors; 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './helper'; 2 | export * from './router-group'; 3 | export * from './browser-router'; 4 | export * from './entity'; 5 | -------------------------------------------------------------------------------- /src/manual.typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'path-to-regexp'; 2 | -------------------------------------------------------------------------------- /src/router-group.ts: -------------------------------------------------------------------------------- 1 | import { ProuterRequestCallback, ProuterGroup } from './entity'; 2 | 3 | export function routerGroup() { 4 | const groupObj: ProuterGroup = { 5 | handlers: [], 6 | 7 | use(path: string, callback: ProuterRequestCallback) { 8 | groupObj.handlers.push({ path, callback }); 9 | return groupObj; 10 | } 11 | }; 12 | 13 | return groupObj; 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.json", 4 | "include": ["src"], 5 | "exclude": ["**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "sourceMap": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "declaration": true, 10 | "declarationDir": "dist", 11 | "lib": ["ESNext", "DOM"], 12 | "skipLibCheck": true 13 | }, 14 | "exclude": ["node_modules", "dist", "coverage"] 15 | } 16 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 2 | import * as path from 'path'; 3 | import { Configuration, ProgressPlugin } from 'webpack'; 4 | import * as CopyWebpackPlugin from 'copy-webpack-plugin'; 5 | 6 | type Mode = 'development' | 'production'; 7 | const mode = (process.env.NODE_ENV as Mode) ?? 'development'; 8 | const isProductionMode = mode === 'production'; 9 | console.debug('*** mode', mode); 10 | 11 | const config: Configuration = { 12 | mode, 13 | profile: true, 14 | bail: isProductionMode, 15 | devtool: 'source-map', 16 | 17 | resolve: { 18 | extensions: ['.ts', '.js'] 19 | }, 20 | 21 | entry: { 22 | 'prouter.min': './src/index.ts' 23 | }, 24 | 25 | output: { 26 | path: path.resolve('dist'), 27 | publicPath: '/', 28 | filename: '[name].js', 29 | chunkFilename: '[id].chunk.js', 30 | library: 'prouter', 31 | libraryTarget: 'umd' 32 | }, 33 | 34 | module: { 35 | rules: [ 36 | /* 37 | * Source map loader support for *.js files 38 | * Extracts SourceMaps for source files that as added as sourceMappingURL comment. 39 | * 40 | * See: https://github.com/webpack/source-map-loader 41 | */ 42 | { 43 | test: /\.js$/, 44 | use: 'source-map-loader', 45 | enforce: 'pre' 46 | }, 47 | 48 | { 49 | test: /\.ts$/, 50 | loader: 'ts-loader', 51 | exclude: /node_modules/, 52 | options: { 53 | configFile: 'tsconfig.build.json' 54 | } 55 | } 56 | ] 57 | }, 58 | 59 | plugins: [new CopyWebpackPlugin({ patterns: ['package.json', 'README.md', 'LICENSE'] })] 60 | }; 61 | 62 | if (isProductionMode) { 63 | config.plugins!.unshift(new ProgressPlugin()); 64 | } 65 | 66 | module.exports = config; 67 | --------------------------------------------------------------------------------