├── .editorconfig ├── .gitattributes ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── SUPPORT.md └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── docs ├── README.md ├── api.md ├── getting-started.md └── redirects.md ├── eslint.config.mjs ├── logo.svg ├── package-lock.json ├── package.json ├── prettier.config.mjs ├── src ├── generate-urls.test.ts ├── generate-urls.ts ├── universal-router-sync.test.ts ├── universal-router-sync.ts ├── universal-router.test.ts └── universal-router.ts ├── tools ├── README.md ├── build.js ├── install.js ├── tsconfig.cjs.json └── tsconfig.esm.json ├── tsconfig.json └── vite.config.mjs /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # http://editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | indent_style = space 10 | indent_size = 2 11 | end_of_line = lf 12 | charset = utf-8 13 | trim_trailing_whitespace = true 14 | insert_final_newline = true 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Automatically normalize line endings for all text-based files 2 | # https://git-scm.com/docs/gitattributes#_end_of_line_conversion 3 | 4 | * text=auto 5 | 6 | # For the following file types, normalize line endings to LF on 7 | # checkin and prevent conversion to CRLF when they are checked out 8 | # (this is required in order to prevent newline related issues like, 9 | # for example, after the build script is run) 10 | 11 | .* text eol=lf 12 | *.js text eol=lf 13 | *.json text eol=lf 14 | *.md text eol=lf 15 | *.svg text eol=lf 16 | *.ts text eol=lf 17 | *.txt text eol=lf 18 | *.yml text eol=lf 19 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Using welcoming and inclusive language 12 | - Being respectful of differing viewpoints and experiences 13 | - Gracefully accepting constructive criticism 14 | - Focusing on what is best for the community 15 | - Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | - The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | - Trolling, insulting/derogatory comments, and personal or political attacks 21 | - Public or private harassment 22 | - Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | - Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at support@kriasoft.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Universal Router 2 | 3 | ♥ [Universal Router](https://github.com/kriasoft/universal-router) and want to 4 | get involved? Thanks! There are plenty of ways you can help! 5 | 6 | Please take a moment to review this document in order to make the contribution 7 | process easy and effective for everyone involved. 8 | 9 | Following these guidelines helps to communicate that you respect the time of 10 | the developers managing and developing this open source project. In return, 11 | they should reciprocate that respect in addressing your issue or assessing 12 | patches and features. 13 | 14 | ## Using the issue tracker 15 | 16 | The [issue tracker](https://github.com/kriasoft/universal-router/issues) is 17 | the preferred channel for [bug reports](#bugs), [features requests](#features) 18 | and [submitting pull requests](#pull-requests), but please respect the following 19 | restrictions: 20 | 21 | - Please **do not** use the issue tracker for personal support requests (use 22 | [Gitter](https://gitter.im/kriasoft/universal-router), 23 | [HackHands](https://hackhands.com/koistya) or 24 | [Codementor](https://www.codementor.io/koistya)). 25 | 26 | - Please **do not** derail or troll issues. Keep the discussion on topic and 27 | respect the opinions of others. 28 | 29 | - Please **do not** open issues or pull requests regarding the code in 30 | [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) 31 | (open them in their respective repositories). 32 | 33 | 34 | 35 | ## Bug reports 36 | 37 | A bug is a _demonstrable problem_ that is caused by the code in the repository. 38 | Good bug reports are extremely helpful - thank you! 39 | 40 | Guidelines for bug reports: 41 | 42 | 1. **Use the GitHub issue search** — check if the issue has already been 43 | reported. 44 | 45 | 2. **Check if the issue has been fixed** — try to reproduce it using the 46 | latest `master` or development branch in the repository. 47 | 48 | 3. **Isolate the problem** — ideally create a [reduced test 49 | case](https://css-tricks.com/reduced-test-cases/) and a live example. 50 | 51 | A good bug report shouldn't leave others needing to chase you up for more 52 | information. Please try to be as detailed as possible in your report. What is 53 | your environment? What steps will reproduce the issue? What browser(s) and OS 54 | experience the problem? What would you expect to be the outcome? All these 55 | details will help people to fix any potential bugs. 56 | 57 | Example: 58 | 59 | > Short and descriptive example bug report title 60 | > 61 | > A summary of the issue and the browser/OS environment in which it occurs. If 62 | > suitable, include the steps required to reproduce the bug. 63 | > 64 | > 1. This is the first step 65 | > 2. This is the second step 66 | > 3. Further steps, etc. 67 | > 68 | > `` - a link to the reduced test case 69 | > 70 | > Any other information you want to share that is relevant to the issue being 71 | > reported. This might include the lines of code that you have identified as 72 | > causing the bug, and potential solutions (and your opinions on their 73 | > merits). 74 | 75 | 76 | 77 | ## Feature requests 78 | 79 | Feature requests are welcome. But take a moment to find out whether your idea 80 | fits with the scope and aims of the project. It's up to _you_ to make a strong 81 | case to convince the project's developers of the merits of this feature. Please 82 | provide as much detail and context as possible. 83 | 84 | 85 | 86 | ## Pull requests 87 | 88 | Good pull requests - patches, improvements, new features - are a fantastic 89 | help. They should remain focused in scope and avoid containing unrelated 90 | commits. 91 | 92 | **Please ask first** before embarking on any significant pull request (e.g. 93 | implementing features, refactoring code, porting to a different language), 94 | otherwise you risk spending a lot of time working on something that the 95 | project's developers might not want to merge into the project. 96 | 97 | Please adhere to the coding conventions used throughout a project (indentation, 98 | accurate comments, etc.) and any other requirements (such as test coverage). 99 | 100 | Adhering to the following process is the best way to get your work 101 | included in the project: 102 | 103 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) the project, clone your 104 | fork, and configure the remotes: 105 | 106 | ```bash 107 | # Clone your fork of the repo into the current directory 108 | git clone https://github.com//universal-router.git 109 | # Navigate to the newly cloned directory 110 | cd universal-router 111 | # Assign the original repo to a remote called "upstream" 112 | git remote add upstream https://github.com/kriasoft/universal-router.git 113 | ``` 114 | 115 | 2. If you cloned a while ago, get the latest changes from upstream: 116 | 117 | ```bash 118 | git checkout master 119 | git pull upstream master 120 | ``` 121 | 122 | 3. Create a new topic branch (off the main project development branch) to 123 | contain your feature, change, or fix: 124 | 125 | ```bash 126 | git checkout -b 127 | ``` 128 | 129 | 4. Commit your changes in logical chunks. Please adhere to these [git commit 130 | message guidelines](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html) 131 | or your code is unlikely be merged into the main project. Use Git's 132 | [interactive rebase](https://help.github.com/articles/about-git-rebase/) 133 | feature to tidy up your commits before making them public. 134 | 135 | 5. Locally merge (or rebase) the upstream development branch into your topic branch: 136 | 137 | ```bash 138 | git pull [--rebase] upstream master 139 | ``` 140 | 141 | 6. Push your topic branch up to your fork: 142 | 143 | ```bash 144 | git push origin 145 | ``` 146 | 147 | 7. [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) 148 | with a clear title and description. 149 | 150 | **IMPORTANT**: By submitting a patch, you agree to allow the project 151 | owners to license your work under the terms of the 152 | [MIT License](https://github.com/kriasoft/universal-router/blob/master/LICENSE.txt). 153 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | open_collective: universal-router 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **I'm submitting a ...** 2 | 3 | 4 | 5 | - [ ] bug report 6 | - [ ] feature request 7 | - [ ] other (Please do not submit support requests here (below)) 8 | 9 | ## Notes: 10 | 11 | - Please **do not** use the issue tracker for personal support requests (use 12 | [Gitter](https://gitter.im/kriasoft/universal-router), 13 | [HackHands](https://hackhands.com/koistya) or 14 | [Codementor](https://www.codementor.io/koistya)). 15 | 16 | - Please **do not** derail or troll issues. Keep the discussion on topic and 17 | respect the opinions of others. 18 | 19 | - Please **do not** open issues or pull requests regarding the code in 20 | [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) 21 | (open them in their respective repositories). 22 | 23 | ## Bug reports 24 | 25 | A bug is a _demonstrable problem_ that is caused by the code in the repository. 26 | Good bug reports are extremely helpful - thank you! 27 | 28 | Guidelines for bug reports: 29 | 30 | 1. **Use the GitHub issue search** — check if the issue has already been 31 | reported. 32 | 33 | 2. **Check if the issue has been fixed** — try to reproduce it using the 34 | latest `master` or development branch in the repository. 35 | 36 | 3. **Isolate the problem** — ideally create a [reduced test 37 | case](https://css-tricks.com/reduced-test-cases/) and a live example. 38 | 39 | A good bug report shouldn't leave others needing to chase you up for more 40 | information. Please try to be as detailed as possible in your report. What is 41 | your environment? What steps will reproduce the issue? What browser(s) and OS 42 | experience the problem? What would you expect to be the outcome? All these 43 | details will help people to fix any potential bugs. 44 | 45 | Example: 46 | 47 | > Short and descriptive example bug report title 48 | > 49 | > A summary of the issue and the browser/OS environment in which it occurs. If 50 | > suitable, include the steps required to reproduce the bug. 51 | > 52 | > 1. This is the first step 53 | > 2. This is the second step 54 | > 3. Further steps, etc. 55 | > 56 | > `` - a link to the reduced test case 57 | > 58 | > Any other information you want to share that is relevant to the issue being 59 | > reported. This might include the lines of code that you have identified as 60 | > causing the bug, and potential solutions (and your opinions on their 61 | > merits). 62 | 63 | ## Feature requests 64 | 65 | Feature requests are welcome. But take a moment to find out whether your idea 66 | fits with the scope and aims of the project. It's up to _you_ to make a strong 67 | case to convince the project's developers of the merits of this feature. Please 68 | provide as much detail and context as possible. 69 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Types of changes 2 | 3 | 4 | 5 | - [ ] Bug fix (non-breaking change which fixes an issue) 6 | - [ ] New feature (non-breaking change which adds functionality) 7 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 8 | 9 | ## Checklist: 10 | 11 | 12 | 13 | 14 | - [ ] My code follows the code style of this project. 15 | - [ ] My change requires a change to the documentation. 16 | - [ ] I have updated the documentation accordingly. 17 | - [ ] I have read the **CONTRIBUTING** document. 18 | - [ ] I have added tests to cover my changes. 19 | - [ ] All new and existing tests passed. 20 | -------------------------------------------------------------------------------- /.github/SUPPORT.md: -------------------------------------------------------------------------------- 1 | # Support 2 | 3 | For personal support requests with Universal Router please use 4 | [Gitter](https://gitter.im/kriasoft/universal-router), 5 | [HackHands](https://hackhands.com/koistya) or 6 | [Codementor](https://www.codementor.io/koistya). 7 | 8 | Please check the respective repository for support regarding the code in 9 | [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp). 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | matrix: 8 | node-version: [18.x, 20.x, 22.x] 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | - name: Use Node.js ${{ matrix.node-version }} 13 | uses: actions/setup-node@v4 14 | with: 15 | node-version: ${{ matrix.node-version }} 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Run Tests 19 | run: npm run coverage -- --reporter=junit --outputFile=test-report.junit.xml 20 | - name: Lint code 21 | run: npm run lint 22 | - name: Check types 23 | run: npm run typecheck 24 | - name: Check code formatting 25 | run: npm run formatcheck 26 | - name: Build 27 | run: npm run build 28 | - name: Upload coverage to Codecov 29 | uses: codecov/codecov-action@v4 30 | with: 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | - name: Upload test results to Codecov 33 | if: ${{ !cancelled() }} 34 | uses: codecov/test-results-action@v1 35 | with: 36 | token: ${{ secrets.CODECOV_TOKEN }} 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files 2 | 3 | # Dependencies 4 | node_modules/ 5 | src/path-to-regexp.ts 6 | 7 | # Compiled output 8 | /dist/ 9 | 10 | # Test coverage 11 | /coverage/ 12 | 13 | # Logs 14 | npm-debug.log* 15 | 16 | # Editors and IDEs 17 | .idea/ 18 | 19 | # Misc 20 | .DS_Store 21 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | "recommendations": [ 5 | "EditorConfig.EditorConfig", 6 | "esbenp.prettier-vscode", 7 | "dbaeumer.vscode-eslint" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | // https://vitest.dev/guide/debugging 8 | { 9 | "type": "node", 10 | "request": "launch", 11 | "name": "Debug Current Test File", 12 | "autoAttachChildProcesses": true, 13 | "skipFiles": ["/**", "**/node_modules/**"], 14 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 15 | "args": ["run", "${relativeFile}"], 16 | "smartStep": true, 17 | "console": "integratedTerminal" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "editor.rulers": [100, 120], 4 | "[javascript]": { "editor.formatOnSave": false }, 5 | "[typescript]": { "editor.formatOnSave": false }, 6 | "tslint.enable": false, 7 | "editor.codeActionsOnSave": { 8 | "source.fixAll.eslint": "explicit" 9 | }, 10 | "cSpell.words": ["Kriasoft"] 11 | } 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Universal Router Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | ## [10.0.0] - 2025-05-05 9 | 10 | - Updated [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) from v6.2.0 to v8.2.0. 11 | See [changelog](https://github.com/pillarjs/path-to-regexp/releases) 12 | (BREAKING CHANGE [#218](https://github.com/kriasoft/universal-router/pull/218)) 13 | - Since [Path To RegExp does not provide an ESM version](https://github.com/pillarjs/path-to-regexp/issues/346), 14 | it is now bundled into the Universal Router package: 15 | ```diff 16 | - const pathToRegexp = require('path-to-regexp') 17 | + import * as pathToRegexp from 'universal-router/path-to-regexp' 18 | ``` 19 | - The import path for generating URLs has changed: 20 | ```diff 21 | - import generateUrls from 'universal-router/generateUrls' 22 | + import generateUrls from 'universal-router/generate-urls' 23 | ``` 24 | 25 | ## [9.2.1] - 2024-11-22 26 | 27 | - Enable `noPropertyAccessFromIndexSignature` and `noUncheckedIndexedAccess` checks ([#216](https://github.com/kriasoft/universal-router/pull/216)) 28 | 29 | ## [9.2.0] - 2023-06-23 30 | 31 | - Bump TypeScript to 4.9.5 and fix types ([#215](https://github.com/kriasoft/universal-router/pull/215)) 32 | 33 | ## [9.1.0] - 2021-06-23 34 | 35 | - Add `uniqueRouteNameSep` option to `generateUrls(router, options)` to allow non-unique route names 36 | among different branches of nested routes and access them by uniquely generated name 37 | ([#194](https://github.com/kriasoft/universal-router/pull/194)) 38 | 39 | ## [9.0.1] - 2020-03-11 40 | 41 | - Fix typings: `router.resolve()` and `context.next()` always return a promise now 42 | ([#187](https://github.com/kriasoft/universal-router/pull/187)) 43 | 44 | ## [9.0.0] - 2020-02-27 45 | 46 | - Update [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from v3 to v6, see 47 | [changelog](https://github.com/pillarjs/path-to-regexp/releases) 48 | (BREAKING CHANGE) 49 | - Remove `context.keys` (BREAKING CHANGE) 50 | - Migrate to [TypeScript](https://www.typescriptlang.org/) 51 | ([#183](https://github.com/kriasoft/universal-router/pull/183)) 52 | 53 | ## [8.3.0] - 2019-09-17 54 | 55 | - Make `generateUrls` compatible with `UniversalRouterSync` 56 | ([#172](https://github.com/kriasoft/universal-router/pull/172)) 57 | 58 | ## [8.2.1] - 2019-07-20 59 | 60 | - Fix `context.next()` to throw `Route not found` instead of `TypeError` 61 | ([#169](https://github.com/kriasoft/universal-router/pull/169)) 62 | 63 | ## [8.2.0] - 2019-05-10 64 | 65 | - Improve TypeScript typings ([#167](https://github.com/kriasoft/universal-router/pull/167)) 66 | 67 | ## [8.1.0] - 2019-02-20 68 | 69 | - Add [synchronous mode](https://github.com/kriasoft/universal-router/blob/v8.1.0/docs/api.md#synchronous-mode) 70 | as an add-on ([#164](https://github.com/kriasoft/universal-router/pull/164)) 71 | 72 | ## [8.0.0] - 2019-01-15 73 | 74 | - Update [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from v2.4.0 to v3.0.0, see 75 | [changelog](https://github.com/pillarjs/path-to-regexp/blob/4eee1e15ba72d93c996bac4ae649a846eb326562/History.md#300--2019-01-13) 76 | (BREAKING CHANGE [#161](https://github.com/kriasoft/universal-router/pull/161)) 77 | - Add [TypeScript](https://www.typescriptlang.org/) typings 78 | ([#159](https://github.com/kriasoft/universal-router/pull/159)) 79 | 80 | ## [7.0.0] - 2018-10-11 81 | 82 | - The router no longer mutate errors to avoid issues with non-extensible objects. 83 | (BREAKING CHANGE [#158](https://github.com/kriasoft/universal-router/pull/158)). 84 | 85 | **Migration from v6 to v7:** 86 | 87 | - If your code relies on `error.context` or `error.code` you still can access them 88 | using `errorHandler` option: 89 | ```js 90 | errorHandler(error, context) { 91 | const code = error.status || 500 92 | console.log(error, context, code) 93 | } 94 | ``` 95 | 96 | ## [6.0.0] - 2018-02-06 97 | 98 | - No special configuration is required for your bundler anymore (say hi to [parcel.js](https://parceljs.org/)). 99 | - Add an option for global error handling ([#147](https://github.com/kriasoft/universal-router/pull/147)). 100 | 101 | **Migration from v5 to v6:** 102 | 103 | - Use `error.code` instead of `error.status` or `error.statusCode` for error handling. 104 | 105 | ## [5.1.0] - 2018-01-16 106 | 107 | - Allow any string to be a valid route name ([#145](https://github.com/kriasoft/universal-router/pull/145)) 108 | 109 | ## [5.0.0] - 2017-10-30 110 | 111 | - Skip nested routes when a middleware route returns `null` 112 | (BREAKING CHANGE [#140](https://github.com/kriasoft/universal-router/pull/140)) 113 | 114 | **Migration from v4 to v5:** 115 | 116 | - If you are using `resolveRoute` option for custom route handling logic then you need 117 | to return `undefined` instead of `null` in cases when a route should not match 118 | - Make sure that your middleware routes which return `null` are working as you expect, 119 | child routes are no longer executed in this case 120 | 121 | ## [4.3.0] - 2017-10-22 122 | 123 | - Update [path-to-regexp](https://github.com/pillarjs/path-to-regexp) from v2.0.0 to v2.1.0, see 124 | [changelog](https://github.com/pillarjs/path-to-regexp/blob/b0b9a92663059d7a7d40d81fa811f0d31e2ba877/History.md#210--2017-10-20) 125 | ([#137](https://github.com/kriasoft/universal-router/pull/137)) 126 | 127 | ## [4.2.1] - 2017-10-06 128 | 129 | - Fix order of `context.keys` when they preserved from parent routes 130 | (i.e. keys order is the same as they appear in a url) 131 | ([#129](https://github.com/kriasoft/universal-router/pull/129)) 132 | 133 | ## [4.2.0] - 2017-09-20 134 | 135 | - Correctly handle trailing slashes in paths of routes 136 | ([#124](https://github.com/kriasoft/universal-router/pull/124))
137 | If you are using trailing slashes in your paths, then the router will match urls only with trailing slashes: 138 | ```js 139 | const routes = [ 140 | { path: '/posts', ... }, // matches both "/posts" and "/posts/" 141 | { path: '/posts/', ... }, // matches only "/posts/" 142 | ] 143 | ``` 144 | - Generate url from first path for routes with an array of paths 145 | ([#124](https://github.com/kriasoft/universal-router/pull/124)) 146 | ```js 147 | const router = new UniversalRouter({ 148 | name: 'page', 149 | path: ['/one', '/two', /RegExp/], // only first path is used for url generation 150 | }) 151 | const url = generateUrls(router) 152 | url('page') // => /one 153 | ``` 154 | 155 | ## [4.1.0] - 2017-09-20 156 | 157 | - Support for using the same param name in array of paths ([#122](https://github.com/kriasoft/universal-router/pull/122)) 158 | 159 | ```js 160 | const router = new UniversalRouter({ 161 | path: ['/one/:parameter', '/two/:parameter'], 162 | action: (context) => context.params, 163 | }) 164 | 165 | router.resolve('/one/a') // => { parameter: 'a' } 166 | router.resolve('/two/b') // => { parameter: 'b' } 167 | ``` 168 | 169 | ## [4.0.0] - 2017-09-15 170 | 171 | - Rename `router.resolve({ path })` to `router.resolve({ pathname })` 172 | (BREAKING CHANGE [#114](https://github.com/kriasoft/universal-router/pull/114)) 173 | - Rename `context.url` to `context.pathname` 174 | (BREAKING CHANGE [#114](https://github.com/kriasoft/universal-router/pull/114)) 175 | - Remove `pretty` option from `generateUrls(router, options)` function in favor of new `encode` option 176 | (BREAKING CHANGE [#111](https://github.com/kriasoft/universal-router/pull/111)) 177 | - Update [path-to-regexp](https://github.com/pillarjs/path-to-regexp) to v2.0.0, see 178 | [changelog](https://github.com/pillarjs/path-to-regexp/blob/1bf805251c8486ea44395cd12afc37f77deec95e/History.md#200--2017-08-23) 179 | (BREAKING CHANGE [#111](https://github.com/kriasoft/universal-router/pull/111)) 180 | - Explicitly handle trailing delimiters (e.g. `/test/` is now treated as `/test/` instead of `/test` when matching) 181 | - No wildcard asterisk (`*`) - use parameters instead (`(.*)`) 182 | - Add support for repeat parameters ([#116](https://github.com/kriasoft/universal-router/pull/116)) 183 | - Add `encode` option to `generateUrls(router, options)` function for pretty encoding 184 | (e.g. pass your own implementation) ([#111](https://github.com/kriasoft/universal-router/pull/111)) 185 | - Preserve `context.keys` values from the parent route ([#111](https://github.com/kriasoft/universal-router/pull/111)) 186 | - Inherit `context.params` and `queryParams` from 187 | [Object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object) 188 | (e.g. `params.hasOwnProperty()` won't throw an exception anymore) 189 | ([#111](https://github.com/kriasoft/universal-router/pull/111)) 190 | - Include the source code of the router in the [npm package](https://www.npmjs.com/package/universal-router) 191 | ([#110](https://github.com/kriasoft/universal-router/pull/110)) 192 | 193 | **Migration from v3 to v4:** 194 | 195 | - Change `router.resolve({ path, ... })` to `router.resolve({ pathname, ... })` 196 | - Remove trailing slashes from all paths of your routes, i.e. 197 | - `path: '/posts/:uri/'` => `path: '/posts/:uri'` 198 | - `path: '/posts/'` => `path: '/posts'` 199 | - `path: '/'` => `path: ''` 200 | - etc. 201 | - Replace `path: '*'` with `path: '(.*)'` if any 202 | - If you are using webpack, change [rule](https://webpack.js.org/configuration/module/#rule) (loader) 203 | for `.js` files to include `.mjs` extension, i.e. 204 | - `test: /\.js$/` => `test: /\.m?js$/` 205 | 206 | ## [3.2.0] - 2017-05-10 207 | 208 | - Add `stringifyQueryParams` option to `generateUrls(router, options)` to generate URL with 209 | [query string](http://en.wikipedia.org/wiki/Query_string) from unknown route params 210 | ([#93](https://github.com/kriasoft/universal-router/pull/93)) 211 | 212 | ## [3.1.0] - 2017-04-20 213 | 214 | - Fix `context.next()` for multiple nested routes 215 | ([#91](https://github.com/kriasoft/universal-router/pull/91)) 216 | - Add `pretty` option for `generateUrls(router, options)` to prettier encoding of URI path segments 217 | ([#88](https://github.com/kriasoft/universal-router/pull/88)) 218 | - Add source maps for minified builds ([#87](https://github.com/kriasoft/universal-router/pull/87)) 219 | - Include UMD builds to the git repository 220 | 221 | ## [3.0.0] - 2017-03-25 222 | 223 | - Update Router API (BREAKING CHANGE) 224 | 225 | ```js 226 | import Router from 'universal-router' 227 | const router = new Router(routes, options) 228 | router.resolve({ path, ...context }) // => Promise 229 | 230 | // previously 231 | import { resolve } from 'universal-router' 232 | resolve(routes, { path, ...context }) // => Promise 233 | ``` 234 | 235 | See [#83](https://github.com/kriasoft/universal-router/pull/83) for more info and examples 236 | 237 | - `context.next()` now iterates only child routes by default (BREAKING CHANGE)
238 | use `context.next(true)` to iterate through the all remaining routes 239 | - Remove `babel-runtime` dependency to decrease library size (BREAKING CHANGE)
240 | Now you need to care about these polyfills yourself: 241 | - [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) 242 | - [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) 243 | - [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) 244 | - Add support for URL Generation 245 | ```js 246 | import generateUrls from 'universal-router/generate-urls' 247 | const url = generateUrls(router) 248 | url(routeName, params) // => String 249 | ``` 250 | - Add support for Dynamic Breadcrumbs, use `context.route.parent` to iterate 251 | - Add support for Declarative Routes, `new Router(routes, { resolveRoute: customResolveRouteFn })` 252 | - Add support for Base URL option, `new Router(routes, { baseUrl: '/base' })` 253 | - Add ability to specify custom context properties once, `new Router(routes, { context: { ... } })` 254 | - Rewrite `matchRoute` function without usage of 255 | [generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*) 256 | to decrease amount of necessary polyfills 257 | - Remove usage of 258 | [String.prototype.startsWith()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/startsWith) 259 | - Add `context.url` with the original url passed to `resolve` method 260 | - Add `context` property to `Route not found` error 261 | 262 | ## [2.0.0] - 2016-10-20 263 | 264 | - Preserve `context.params` values from the parent route ([#57](https://github.com/kriasoft/universal-router/pull/57)) 265 | - Throws an error if no route found ([#62](https://github.com/kriasoft/universal-router/pull/62)) 266 | - Remove obsolete `context.end()` method ([#60](https://github.com/kriasoft/universal-router/pull/60)) 267 | - Remove obsolete `match` alias for `resolve` function ([#59](https://github.com/kriasoft/universal-router/pull/59)) 268 | - Do not throw an error for malformed URI params ([#54](https://github.com/kriasoft/universal-router/pull/54)) 269 | - Handle `null` the same way as `undefined` ([#51](https://github.com/kriasoft/universal-router/pull/51)) 270 | - Return `null` instead of `undefined` to signal no match ([#51](https://github.com/kriasoft/universal-router/pull/51)) 271 | - Support `context.next()` across multiple routes ([#49](https://github.com/kriasoft/universal-router/pull/49)) 272 | - Sequential execution of asynchronous routes ([#49](https://github.com/kriasoft/universal-router/pull/49)) 273 | - Remove errors handler from core ([#48](https://github.com/kriasoft/universal-router/pull/48)) 274 | - Drop support of node.js v5 and below ([#47](https://github.com/kriasoft/universal-router/pull/47)) 275 | 276 | ## [1.2.2] - 2016-05-31 277 | 278 | - Update UMD build to include missing dependencies ([#33](https://github.com/kriasoft/universal-router/pull/33)) 279 | 280 | ## [1.2.1] - 2016-05-12 281 | 282 | - Rename `match()` to `resolve()`. E.g. `import { resovle } from 'universal-router'` 283 | - Fix an issue when the router throws an exception when the top-level route doesn't have `children` property 284 | - Include CommonJS, Harmony Modules, ES5.1 and UMD builds into NPM package 285 | - Include source maps into NPM package 286 | 287 | ## [1.1.0-beta.4] - 2016-04-27 288 | 289 | - Fix optional parameters, e.g. `/products/:id?` ([#27](https://github.com/kriasoft/universal-router/pull/27)) 290 | 291 | ## [1.1.0-beta.3] - 2016-04-08 292 | 293 | - Fix `matchRoute()` yielding the same route twice when it matches to both full and base URLs 294 | 295 | ## [1.1.0-beta.2] - 2016-04-08 296 | 297 | - `match(routes, { path, ...context)` now throws an error if a matching route was not found (BREAKING CHANGE) 298 | - If there is a top-level route with path equal to `/error`, it will be used for error handling by convention 299 | 300 | ## [1.1.0-beta.1] - 2016-04-05 301 | 302 | - Remove `Router` class and `router.dispatch()` method in favor of 303 | `match(routes, { path, ...context })`, where `routes` is just a plain JavaScript objects containing 304 | the list of routes (BREAKING CHANGE) 305 | - Add `context.end()` method to be used from inside route actions 306 | - Update documentation and code samples 307 | 308 | ## [1.0.0-beta.1] - 2016-03-25 309 | 310 | - Rename `react-routing` to `universal-router` (BREAKING CHANGE) 311 | - Remove `router.on(path, ...actions)` in favor of `router.route(path, ...actions)` (BREAKING CHANGE) 312 | - Remove `new Router(on => { ... })` initialization option in favor of `new Router(routes)` (BREAKING CHANGE) 313 | - Fix ESLint warnings 314 | - Update unit tests 315 | - Remove build tools related to project's homepage in favor of [Easystatic](https://easystatic.com) 316 | - Refactor project's homepage layout. See `docs/assets`. 317 | - Clean up `package.json`, update Babel and its plug-ins to the latest versions 318 | - Make the library use `babel-runtime` package instead of an inline runtime 319 | - Add [CHANGELOG.md](CHANGELOG.md) file with the notable changes to this project 320 | 321 | ## [0.0.7] - 2015-12-13 322 | 323 | - Small bug fixes and improvements 324 | 325 | [unreleased]: https://github.com/kriasoft/universal-router/compare/v10.0.0...HEAD 326 | [10.0.0]: https://github.com/kriasoft/universal-router/compare/v9.2.1...v10.0.0 327 | [9.2.1]: https://github.com/kriasoft/universal-router/compare/v9.2.0...v9.2.1 328 | [9.2.0]: https://github.com/kriasoft/universal-router/compare/v9.1.0...v9.2.0 329 | [9.1.0]: https://github.com/kriasoft/universal-router/compare/v9.0.1...v9.1.0 330 | [9.0.1]: https://github.com/kriasoft/universal-router/compare/v9.0.0...v9.0.1 331 | [9.0.0]: https://github.com/kriasoft/universal-router/compare/v8.3.0...v9.0.0 332 | [8.3.0]: https://github.com/kriasoft/universal-router/compare/v8.2.1...v8.3.0 333 | [8.2.1]: https://github.com/kriasoft/universal-router/compare/v8.2.0...v8.2.1 334 | [8.2.0]: https://github.com/kriasoft/universal-router/compare/v8.1.0...v8.2.0 335 | [8.1.0]: https://github.com/kriasoft/universal-router/compare/v8.0.0...v8.1.0 336 | [8.0.0]: https://github.com/kriasoft/universal-router/compare/v7.0.0...v8.0.0 337 | [7.0.0]: https://github.com/kriasoft/universal-router/compare/v6.0.0...v7.0.0 338 | [6.0.0]: https://github.com/kriasoft/universal-router/compare/v5.1.0...v6.0.0 339 | [5.1.0]: https://github.com/kriasoft/universal-router/compare/v5.0.0...v5.1.0 340 | [5.0.0]: https://github.com/kriasoft/universal-router/compare/v4.3.0...v5.0.0 341 | [4.3.0]: https://github.com/kriasoft/universal-router/compare/v4.2.1...v4.3.0 342 | [4.2.1]: https://github.com/kriasoft/universal-router/compare/v4.2.0...v4.2.1 343 | [4.2.0]: https://github.com/kriasoft/universal-router/compare/v4.1.0...v4.2.0 344 | [4.1.0]: https://github.com/kriasoft/universal-router/compare/v4.0.0...v4.1.0 345 | [4.0.0]: https://github.com/kriasoft/universal-router/compare/v3.2.0...v4.0.0 346 | [3.2.0]: https://github.com/kriasoft/universal-router/compare/v3.1.0...v3.2.0 347 | [3.1.0]: https://github.com/kriasoft/universal-router/compare/v3.0.0...v3.1.0 348 | [3.0.0]: https://github.com/kriasoft/universal-router/compare/v2.0.0...v3.0.0 349 | [2.0.0]: https://github.com/kriasoft/universal-router/compare/v1.2.2...v2.0.0 350 | [1.2.2]: https://github.com/kriasoft/universal-router/compare/v1.2.1...v1.2.2 351 | [1.2.1]: https://github.com/kriasoft/universal-router/compare/v1.1.0-beta.4...v1.2.1 352 | [1.1.0-beta.4]: https://github.com/kriasoft/universal-router/compare/v1.1.0-beta.3...v1.1.0-beta.4 353 | [1.1.0-beta.3]: https://github.com/kriasoft/universal-router/compare/v1.1.0-beta.2...v1.1.0-beta.3 354 | [1.1.0-beta.2]: https://github.com/kriasoft/universal-router/compare/v1.1.0-beta.1...v1.1.0-beta.2 355 | [1.1.0-beta.1]: https://github.com/kriasoft/universal-router/compare/v1.0.0-beta.1...v1.1.0-beta.1 356 | [1.0.0-beta.1]: https://github.com/kriasoft/universal-router/compare/v0.0.7...v1.0.0-beta.1 357 | [0.0.7]: https://github.com/kriasoft/universal-router/compare/v0.0.6...v0.0.7 358 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-present Kriasoft. 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Universal Router 2 | 3 | [![NPM version](https://img.shields.io/npm/v/universal-router.svg)](https://www.npmjs.com/package/universal-router) 4 | [![NPM downloads](https://img.shields.io/npm/dw/universal-router.svg)](https://www.npmjs.com/package/universal-router) 5 | [![Library Size](https://img.shields.io/bundlephobia/minzip/universal-router.svg)](https://bundlephobia.com/result?p=universal-router) 6 | [![Online Chat](https://img.shields.io/discord/643523529131950086?label=Chat)](https://discord.gg/2nKEnKq) 7 | 8 | 9 | Visit Universal Router Website 11 | 12 | 13 | A simple middleware-style router that can be used in both client-side and server-side applications. 14 | 15 | Visit **[Quickstart Guide](http://slides.com/koistya/universal-router)** (slides)  |  16 | Join **[#universal-router](https://gitter.im/kriasoft/universal-router)** on Gitter to stay up to date 17 | 18 | ## Features 19 | 20 | - It has [simple code](https://github.com/kriasoft/universal-router/blob/master/src/UniversalRouter.ts) 21 | with only single [path-to-regexp](https://github.com/pillarjs/path-to-regexp) dependency. 22 | - It can be used with any JavaScript framework such as 23 | [React](https://reactjs.org/), [Vue](https://vuejs.org/), [Hyperapp](https://hyperapp.dev/) etc. 24 | - It uses the same middleware approach used in [Express](http://expressjs.com/) and [Koa](http://koajs.com/), 25 | making it easy to learn. 26 | - It supports both [imperative](https://en.wikipedia.org/wiki/Imperative_programming) and 27 | [declarative](https://en.wikipedia.org/wiki/Declarative_programming) routing style. 28 | - Routes are plain JavaScript 29 | [objects](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Object_initializer) 30 | with which you can interact as you like. 31 | 32 | ## What users say about Universal Router 33 | 34 | > Just switched a project over to universal-router. 35 | > Love that the whole thing is a few hundred lines of flexible, easy-to-read code. 36 | > 37 | > -- [Tweet](https://twitter.com/wincent/status/862115805378494464) by **Greg Hurrell** from Facebook 38 | 39 | > It does a great job at trying to be _universal_ — it's not tied to any framework, 40 | > it can be run on both server and client, and it's not even tied to history. 41 | > It's a great library which does one thing: routing. 42 | > 43 | > -- [Comment on Reddit](https://www.reddit.com/r/reactjs/comments/5xhw3o#form-t1_dejkw4p367) 44 | > by **@everdimension** 45 | 46 | ## Installation 47 | 48 | Using [npm](https://www.npmjs.com/package/universal-router): 49 | 50 | ```bash 51 | npm install universal-router --save 52 | ``` 53 | 54 | ## How does it look like? 55 | 56 | ```js 57 | import UniversalRouter from 'https://esm.sh/universal-router' 58 | 59 | const routes = [ 60 | { 61 | path: '', // optional 62 | action: () => `

Home

`, 63 | }, 64 | { 65 | path: '/posts', 66 | action: () => console.log('checking child routes for /posts'), 67 | children: [ 68 | { 69 | path: '', // optional, matches both "/posts" and "/posts/" 70 | action: () => `

Posts

`, 71 | }, 72 | { 73 | path: '/:id', 74 | action: (context) => `

Post #${context.params.id}

`, 75 | }, 76 | ], 77 | }, 78 | ] 79 | 80 | const router = new UniversalRouter(routes) 81 | 82 | router.resolve('/posts').then((html) => { 83 | document.body.innerHTML = html // renders:

Posts

84 | }) 85 | ``` 86 | 87 | Play with an example on [JSFiddle](https://jsfiddle.net/frenzzy/b0w9mjck/102/), 88 | [CodePen](https://codepen.io/frenzzy/pen/aWLKpb?editors=0010), 89 | [JS Bin](https://jsbin.com/kaluden/3/edit?js,output) in your browser or try 90 | [RunKit](https://runkit.com/frenzzy/universal-router-demo) node.js playground. 91 | 92 | ## Documentation 93 | 94 | - [Getting Started](https://github.com/kriasoft/universal-router/blob/master/docs/getting-started.md) 95 | - [Universal Router API](https://github.com/kriasoft/universal-router/blob/master/docs/api.md) 96 | 97 | ## Books and Tutorials 98 | 99 | - 🎓 **[ES6 Training Course](https://es6.io/friend/konstantin)** 100 | by [Wes Bos](https://twitter.com/wesbos) 101 | - 📗 **[You Don't Know JS: ES6 & Beyond](http://amzn.to/2bFss85)** 102 | by [Kyle Simpson](https://github.com/getify) (Dec, 2015) 103 | - 📄 **[You might not need React Router](https://medium.freecodecamp.org/38673620f3d)** 104 | by [Konstantin Tarkus](https://twitter.com/koistya) 105 | - 📄 **[An Introduction to the Redux-First Routing Model](https://medium.freecodecamp.org/98926ebf53cb)** 106 | by [Michael Sargent](https://twitter.com/michaelksarge) 107 | - 📄 **[Getting Started with Relay Modern for Building Isomorphic Web Apps](https://hackernoon.com/ae049e4e23c1)** 108 | by [Konstantin Tarkus](https://twitter.com/koistya) 109 | 110 | ## Browser Support 111 | 112 | We support all ES5-compliant browsers, including Internet Explorer 9 and above, 113 | but depending on your target browsers you may need to include 114 | [polyfills]() for 115 | [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), 116 | [`Promise`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) and 117 | [`Object.assign`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) 118 | before any other code. 119 | 120 | For compatibility with older browsers you may also need to include polyfills for 121 | [`Array.isArray`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) 122 | and [`Object.create`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/create). 123 | 124 | ## Contributing 125 | 126 | Anyone and everyone is welcome to 127 | [contribute](https://github.com/kriasoft/universal-router/blob/master/.github/CONTRIBUTING.md) to this project. 128 | The best way to start is by checking our [open issues](https://github.com/kriasoft/universal-router/issues), 129 | submit a [bug report](https://github.com/kriasoft/universal-router/blob/master/.github/CONTRIBUTING.md#bugs) or 130 | [feature request](https://github.com/kriasoft/universal-router/blob/master/.github/CONTRIBUTING.md#features), 131 | participate in discussions, upvote or downvote the issues you like or dislike, send [pull 132 | requests](https://github.com/kriasoft/universal-router/blob/master/.github/CONTRIBUTING.md#pull-requests). 133 | 134 | ## Support 135 | 136 | - [#universal-router](https://gitter.im/kriasoft/universal-router) on Gitter — 137 | Watch announcements, share ideas and feedback. 138 | - [GitHub Issues](https://github.com/kriasoft/universal-router/issues) — 139 | Check open issues, send feature requests. 140 | - [@koistya](https://twitter.com/koistya) on [Codementor](https://www.codementor.io/koistya), 141 | [HackHands](https://hackhands.com/koistya/) 142 | or [Skype](https://hatscripts.com/addskype?koistya) — Private consulting. 143 | 144 | ## Related Projects 145 | 146 | - [React Starter Kit](https://github.com/kriasoft/react-starter-kit) — 147 | Boilerplate and tooling for building isomorphic web apps with React and Relay. 148 | - [Node.js API Starter Kit](https://github.com/kriasoft/nodejs-api-starter) — 149 | Boilerplate and tooling for building data APIs with Docker, Node.js and GraphQL. 150 | - [ASP.NET Core Starter Kit](https://github.com/kriasoft/aspnet-starter-kit) — 151 | Cross-platform single-page application boilerplate (ASP.NET Core, React, Redux). 152 | - [Babel Starter Kit](https://github.com/kriasoft/babel-starter-kit) — 153 | Boilerplate for authoring JavaScript/React.js libraries. 154 | - [React App SDK](https://github.com/kriasoft/react-app) — 155 | Create React apps with just a single dev dependency and zero configuration. 156 | - [React Static Boilerplate](https://github.com/kriasoft/react-static-boilerplate) — 157 | Single-page application (SPA) starter kit (React, Redux, Webpack, Firebase). 158 | - [History](https://github.com/ReactTraining/history) — 159 | HTML5 History API wrapper library that handle navigation in single-page apps. 160 | - [Redux-First Routing](https://github.com/mksarge/redux-first-routing) — 161 | A minimal, framework-agnostic API for accomplishing Redux-first routing. 162 | 163 | ## Sponsors 164 | 165 | Become a sponsor and get your logo on our README on Github with a link to your site. 166 | [[Become a sponsor](https://opencollective.com/universal-router#sponsor)] 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | ## Backers 200 | 201 | Support us with a monthly donation and help us continue our activities. 202 | [[Become a backer](https://opencollective.com/universal-router#backer)] 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | ## License 236 | 237 | Copyright © 2015-present Kriasoft. 238 | This source code is licensed under the MIT license found in the 239 | [LICENSE.txt](https://github.com/kriasoft/universal-router/blob/master/LICENSE.txt) file. 240 | The documentation to the project is licensed under the 241 | [CC BY-SA 4.0](http://creativecommons.org/licenses/by-sa/4.0/) license. 242 | 243 | --- 244 | 245 | Made with ♥ by 246 | [Konstantin Tarkus](https://github.com/koistya) 247 | ([@koistya](https://twitter.com/koistya), [blog](https://medium.com/@tarkus)), 248 | [Vladimir Kutepov](https://github.com/frenzzy) 249 | and [contributors](https://github.com/kriasoft/universal-router/graphs/contributors) 250 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | **General** 4 | 5 | - [Getting Started](https://github.com/kriasoft/universal-router/blob/master/docs/getting-started.md) 6 | - [Universal Router API](https://github.com/kriasoft/universal-router/blob/master/docs/api.md) 7 | 8 | **Recipes** 9 | 10 | - [Redirects](https://github.com/kriasoft/universal-router/blob/master/docs/redirects.md) 11 | - [Request a recipe](https://github.com/kriasoft/universal-router/issues/new) 12 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # Universal Router API 2 | 3 | ## `const router = new UniversalRouter(routes, options)` 4 | 5 | Creates an universal router instance which have a single 6 | [`router.resolve()`](#routerresolve-pathname-context---promiseany) method. 7 | `UniversalRouter` constructor expects a plain javascript object for the first `routes` argument 8 | with any amount of params where only `path` is required, or array of such objects. 9 | Second `options` argument is optional where you can pass the following: 10 | 11 | - `context` - an object with any data which you want to pass to `resolveRoute` function.\ 12 | See [Context](#context) section below for details. 13 | - `baseUrl` - the base URL of the app. By default is empty string `''`.\ 14 | If all the URLs in your app are relative to some other "base" URL, use this option. 15 | - `resolveRoute` - function for any custom route handling logic.\ 16 | For example you can define this option to work with routes in declarative manner.\ 17 | By default the router calls the `action` method of matched route. 18 | - `errorHandler` - function for global error handling. Called with an 19 | [Error](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) 20 | and [Context](#context) arguments every time the route is not found or threw an error. 21 | 22 | ```js 23 | import UniversalRouter from 'universal-router' 24 | 25 | const routes = { 26 | path: '/page', // string, array of strings, or a regular expression, optional 27 | name: 'page', // unique string, optional 28 | parent: null, // route object or null, automatically filled by the router 29 | children: [], // array of route objects, optional 30 | 31 | // function, optional 32 | action(context, params) { 33 | // action method should return anything except `null` or `undefined` to be resolved by router 34 | // otherwise router will throw `Page not found` error if all matched routes returned nothing 35 | return '

The Page

' 36 | }, 37 | 38 | // ... 39 | } 40 | 41 | const options = { 42 | context: { user: null }, 43 | baseUrl: '/base', 44 | resolveRoute(context, params) { 45 | if (typeof context.route.action === 'function') { 46 | return context.route.action(context, params) 47 | } 48 | return undefined 49 | }, 50 | errorHandler(error, context) { 51 | console.error(error) 52 | console.info(context) 53 | return error.status === 404 54 | ? '

Page Not Found

' 55 | : '

Oops! Something went wrong

' 56 | }, 57 | } 58 | 59 | const router = new UniversalRouter(routes, options) 60 | ``` 61 | 62 | ## `router.resolve({ pathname, ...context })` ⇒ `Promise` 63 | 64 | Traverses the list of routes in the order they are defined until it finds the first route 65 | that matches provided URL path string and whose `action` function returns anything 66 | other than `null` or `undefined`. 67 | 68 | ```js 69 | const router = new UniversalRouter([ 70 | { path: '/one', action: () => 'Page One' }, 71 | { path: '/two', action: () => 'Page Two' }, 72 | ]) 73 | 74 | router.resolve({ pathname: '/one' }).then((result) => console.log(result)) 75 | // => Page One 76 | ``` 77 | 78 | Where `action` is just a regular function that may, or may not, return any arbitrary data 79 | — a string, a React component, anything! 80 | 81 | ## Nested Routes 82 | 83 | Each route may have an optional `children: [ ... ]` property containing the list of child routes: 84 | 85 | ```js 86 | const router = new UniversalRouter({ 87 | path: '/admin', 88 | children: [ 89 | { 90 | path: '', // www.example.com/admin 91 | action: () => 'Admin Page', 92 | }, 93 | { 94 | path: '/users', 95 | children: [ 96 | { 97 | path: '', // www.example.com/admin/users 98 | action: () => 'User List', 99 | }, 100 | { 101 | path: '/:username', // www.example.com/admin/users/john 102 | action: () => 'User Profile', 103 | }, 104 | ], 105 | }, 106 | ], 107 | }) 108 | 109 | router 110 | .resolve({ pathname: '/admin/users/john' }) 111 | .then((result) => console.log(result)) 112 | // => User Profile 113 | ``` 114 | 115 | Setting the `children` property to an empty list will act as a catch-all capture all routes beneath that path 116 | 117 | ```js 118 | const router = new UniversalRouter({ 119 | path: '/admin', 120 | children: [], 121 | action: () => 'Admin Page', 122 | }) 123 | 124 | router 125 | .resolve({ pathname: '/admin/users/john' }) 126 | .then((result) => console.log(result)) 127 | // => Admin Page 128 | router 129 | .resolve({ pathname: '/admin/some/other/page' }) 130 | .then((result) => console.log(result)) 131 | // => Admin Page 132 | ``` 133 | 134 | ## URL Parameters 135 | 136 | **Named route parameters** are captured and added to `context.params`. 137 | 138 | ```js 139 | const router = new UniversalRouter({ 140 | path: '/hello/:username', 141 | action: (context) => `Welcome, ${context.params.username}!`, 142 | }) 143 | 144 | router 145 | .resolve({ pathname: '/hello/john' }) 146 | .then((result) => console.log(result)) 147 | // => Welcome, john! 148 | ``` 149 | 150 | Alternatively, captured parameters can be accessed via the second argument 151 | to an action method like so: 152 | 153 | ```js 154 | const router = new UniversalRouter({ 155 | path: '/hello/:username', 156 | action: (ctx, { username }) => `Welcome, ${username}!`, 157 | }) 158 | 159 | router 160 | .resolve({ pathname: '/hello/john' }) 161 | .then((result) => console.log(result)) 162 | // => Welcome, john! 163 | ``` 164 | 165 | Router preserves the `context.params` values from the parent router. 166 | If the parent and the child have conflicting param names, the child's value take precedence. 167 | 168 | This functionality is powered by [path-to-regexp](https://github.com/pillarjs/path-to-regexp) 169 | npm module and works the same way as the routing solutions in many popular JavaScript frameworks 170 | such as [Express](https://expressjs.com/) and [Koa](https://koajs.com/). 171 | Also check out online [router tester](http://forbeslindesay.github.io/express-route-tester/). 172 | 173 | ## Context 174 | 175 | In addition to a URL path string, any arbitrary data can be passed to the `router.resolve()` method, 176 | that becomes available inside `action` functions. 177 | 178 | ```js 179 | const router = new UniversalRouter({ 180 | path: '/hello', 181 | action(context) { 182 | return `Welcome, ${context.user}!` 183 | }, 184 | }) 185 | 186 | router 187 | .resolve({ pathname: '/hello', user: 'admin' }) 188 | .then((result) => console.log(result)) 189 | // => Welcome, admin! 190 | ``` 191 | 192 | Router supports `context` option in the `UniversalRouter` constructor 193 | to support for specify of custom context properties only once. 194 | 195 | ```js 196 | const context = { 197 | store: {}, 198 | user: 'admin', 199 | // ... 200 | } 201 | 202 | const router = new UniversalRouter(route, { context }) 203 | ``` 204 | 205 | Router always adds following parameters to the `context` object 206 | before passing it to the `resolveRoute` function: 207 | 208 | - `router` - Current router instance. 209 | - `route` - Matched route object. 210 | - `next` - Middleware style function which can continue resolving, 211 | see [Middlewares](#middlewares) section below for details. 212 | - `pathname` - URL which was transmitted to `router.resolve()`. 213 | - `baseUrl` - Base URL path relative to the path of the current route. 214 | - `path` - Matched path. 215 | - `params` - Matched path params, 216 | see [URL Parameters](#url-parameters) section above for details. 217 | 218 | ## Async Routes 219 | 220 | The router works great with asynchronous functions out of the box! 221 | 222 | ```js 223 | const router = new UniversalRouter({ 224 | path: '/hello/:username', 225 | async action({ params }) { 226 | const resp = await fetch(`/api/users/${params.username}`) 227 | const user = await resp.json() 228 | if (user) return `Welcome, ${user.displayName}!` 229 | }, 230 | }) 231 | 232 | router 233 | .resolve({ pathname: '/hello/john' }) 234 | .then((result) => console.log(result)) 235 | // => Welcome, John Brown! 236 | ``` 237 | 238 | Use [Babel](http://babeljs.io/) to transpile your code with `async` / `await` to normal JavaScript. 239 | Alternatively, stick to ES6 Promises: 240 | 241 | ```js 242 | const route = { 243 | path: '/hello/:username', 244 | action({ params }) { 245 | return fetch(`/api/users/${params.username}`) 246 | .then((resp) => resp.json()) 247 | .then((user) => user && `Welcome, ${user.displayName}!`) 248 | }, 249 | } 250 | ``` 251 | 252 | ## Middlewares 253 | 254 | Any route action function may act as a **middleware** by calling `context.next()`. 255 | 256 | ```js 257 | const router = new UniversalRouter({ 258 | path: '', // optional 259 | async action({ next }) { 260 | console.log('middleware: start') 261 | const child = await next() 262 | console.log('middleware: end') 263 | return child 264 | }, 265 | children: [ 266 | { 267 | path: '/hello', 268 | action() { 269 | console.log('route: return a result') 270 | return 'Hello, world!' 271 | }, 272 | }, 273 | ], 274 | }) 275 | 276 | router.resolve({ pathname: '/hello' }) 277 | // Prints: 278 | // middleware: start 279 | // route: return a result 280 | // middleware: end 281 | ``` 282 | 283 | Remember that `context.next()` iterates only child routes, 284 | use `context.next(true)` to iterate through the all remaining routes. 285 | 286 | Note that if the middleware action returns `null` then the router will skip all nested routes 287 | and go to the next sibling route. But if the `action` is missing or returns `undefined` 288 | then the router will try to match the child routes. This can be useful for permissions check. 289 | 290 | ```js 291 | const middlewareRoute = { 292 | path: '/admin', 293 | action(context) { 294 | if (!context.user) { 295 | return null // route does not match (skip all /admin* routes) 296 | } 297 | if (context.user.role !== 'Admin') { 298 | return 'Access denied!' // return a page (for any /admin* urls) 299 | } 300 | return undefined // or `return context.next()` - try to match child routes 301 | }, 302 | children: [ 303 | /* admin routes here */ 304 | ], 305 | } 306 | ``` 307 | 308 | ## Synchronous mode 309 | 310 | For most application a Promise-based asynchronous API is the best choice. 311 | But if you absolutely have to resolve your routes synchronously, 312 | this option is available as an add-on. 313 | 314 | Simply import `universal-router/sync` instead of `universal-router` 315 | and you'll get almost the same API, but without the `Promise` support. 316 | 317 | ```diff 318 | -import UniversalRouter from 'universal-router' 319 | +import UniversalRouterSync from 'universal-router/sync' 320 | ``` 321 | 322 | Now the `resolve` method will synchronously return whatever 323 | the matching route action returned (or throw an error). 324 | 325 | ```js 326 | const router = new UniversalRouterSync([ 327 | { path: '/one', action: () => 'Page One' }, 328 | { path: '/two', action: () => 'Page Two' }, 329 | ]) 330 | 331 | const result = router.resolve({ pathname: '/one' }) 332 | 333 | console.log(result) // => Page One 334 | ``` 335 | 336 | This implies that your `action` functions have to be synchronous too. 337 | 338 | The `context.next` function will be synchronous too and will return whatever the matching action returned. 339 | 340 | ## URL Generation 341 | 342 | In most web applications it's much simpler to just use a string for hyperlinks. 343 | 344 | ```js 345 | const link1 = `Page` 346 | const link2 = `Profile` 347 | const link3 = `Search` 348 | const link4 = `Question` 349 | // etc. 350 | ``` 351 | 352 | However for some types of web applications it may be useful to generate URLs dynamically based on route name. 353 | That's why this feature is available as an add-on with simple API `generateUrls(router, options) ⇒ Function` 354 | where returned function is used for generating urls `url(routeName, params) ⇒ String`. 355 | 356 | ```js 357 | import UniversalRouter from 'universal-router' 358 | import generateUrls from 'universal-router/generateUrls' 359 | 360 | const routes = [ 361 | { name: 'users', path: '/users' }, 362 | { name: 'user', path: '/user/:username' }, 363 | ] 364 | 365 | const router = new UniversalRouter(routes, { baseUrl: '/base' }) 366 | const url = generateUrls(router) 367 | 368 | url('users') // => '/base/users' 369 | url('user', { username: 'john' }) // => '/base/user/john' 370 | ``` 371 | 372 | This approach also works fine for dynamically added routes at runtime. 373 | 374 | ```js 375 | routes.children.push({ path: '/world', name: 'hello' }) 376 | 377 | url('hello') // => '/base/world' 378 | ``` 379 | 380 | Use `encode` option for custom encoding of URI path segments. By default 381 | [encodeURIComponent](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent) 382 | is used. 383 | 384 | ```js 385 | const prettyUrl = generateUrls(router, { encode: (value, token) => value }) 386 | 387 | url('user', { username: ':/' }) // => '/base/user/%3A%2F' 388 | prettyUrl('user', { username: ':/' }) // => '/base/user/:/' 389 | ``` 390 | 391 | Provide a function to `stringifyQueryParams` option to generate URL with 392 | [query string](http://en.wikipedia.org/wiki/Query_string) from unknown route params. 393 | 394 | ```js 395 | const urlWithQueryString = generateUrls(router, { 396 | stringifyQueryParams: (params) => new URLSearchParams(params).toString(), 397 | }) 398 | 399 | const params = { username: 'John', busy: '1' } 400 | url('user', params) // => /base/user/John 401 | urlWithQueryString('user', params) // => /base/user/John?busy=1 402 | ``` 403 | 404 | Or use external library such as [qs](https://github.com/ljharb/qs), 405 | [query-string](https://github.com/sindresorhus/query-string), etc. 406 | 407 | ```js 408 | import qs from 'qs' 409 | generateUrls(router, { stringifyQueryParams: qs.stringify }) 410 | ``` 411 | 412 | Option `uniqueRouteNameSep` allows using non-unique route names among different branches of nested routes. 413 | The router will automatically generate unique names based on parent routes using the specified separator: 414 | 415 | ```js 416 | const router = new UniversalRouter([ 417 | { 418 | name: 'users', 419 | path: '/users', 420 | children: [{ name: 'list', path: '/list' }], 421 | }, 422 | { 423 | name: 'pages', 424 | path: '/pages', 425 | children: [{ name: 'list', path: '/list' }], 426 | }, 427 | ]) 428 | const url = generateUrls(router, { uniqueRouteNameSep: '.' }) 429 | url('users.list') // => /users/list 430 | url('pages.list') // => /pages/list 431 | ``` 432 | 433 | ## Recipes 434 | 435 | - [Redirects](https://github.com/kriasoft/universal-router/blob/master/docs/redirects.md) 436 | - [Request a recipe](https://github.com/kriasoft/universal-router/issues/new) 437 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This router is built around a middleware approach used in Express and Koa, so if you're already 4 | familiar with any of these frameworks, learning Universal Router should be a breeze. The code 5 | samples below assume that you're using ES2015 flavor of JavaScript via [Babel](http://babeljs.io/). 6 | 7 | You can start by installing **Universal Router** library via [npm](https://www.npmjs.com/package/universal-router) 8 | by running: 9 | 10 | ```sh 11 | npm install universal-router --save 12 | ``` 13 | 14 | This module contains a `UniversalRouter` class with a single `router.resolve` method that responsible for traversing 15 | the list of routes, until it finds the first route matching the provided URL path string and whose action method 16 | returns anything other than `null` or `undefined`. Each route is just a plain JavaScript object having `path`, 17 | `action`, and `children` (optional) properties. 18 | 19 | ```js 20 | import UniversalRouter from 'universal-router' 21 | 22 | const routes = [ 23 | { path: '/one', action: () => '

Page One

' }, 24 | { path: '/two', action: () => '

Page Two

' }, 25 | { path: '/*all', action: () => '

Not Found

' }, 26 | ] 27 | 28 | const router = new UniversalRouter(routes) 29 | 30 | router.resolve({ pathname: '/one' }).then((result) => { 31 | document.body.innerHTML = result 32 | // renders:

Page One

33 | }) 34 | ``` 35 | 36 | If you don't want to use npm to manage client packages, the `universal-router` npm package 37 | also provide single-file distributions, which are hosted on a [CDN](https://unpkg.com/): 38 | 39 | ```html 40 | 41 | 42 | 43 | 44 | 45 | 46 | ``` 47 | 48 | **Note**: You may need to include 49 | [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map), 50 | [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) and 51 | [Object.assign](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign) 52 | polyfills for compatibility with older browsers. 53 | 54 | ## Use with React 55 | 56 | ```jsx 57 | import React from 'react' 58 | import ReactDOM from 'react-dom' 59 | import UniversalRouter from 'universal-router' 60 | 61 | const routes = [ 62 | { path: '/one', action: () =>

Page One

}, 63 | { path: '/two', action: () =>

Page Two

}, 64 | { path: '/*all', action: () =>

Not Found

}, 65 | ] 66 | 67 | const router = new UniversalRouter(routes) 68 | 69 | router.resolve({ pathname: '/one' }).then((component) => { 70 | ReactDOM.render(component, document.body) 71 | // renders:

Page One

72 | }) 73 | ``` 74 | 75 | ## Learn more 76 | 77 | - [Universal Router API](https://github.com/kriasoft/universal-router/blob/master/docs/api.md) 78 | - [Redirects](https://github.com/kriasoft/universal-router/blob/master/docs/redirects.md) 79 | -------------------------------------------------------------------------------- /docs/redirects.md: -------------------------------------------------------------------------------- 1 | # Redirects 2 | 3 | The easiest way to add a redirect to your route is to return from the action method something 4 | that you can interpret as a redirect at the end of routes resolving, for example: 5 | 6 | ```js 7 | import UniversalRouter from 'universal-router' 8 | 9 | const router = new UniversalRouter([ 10 | { 11 | path: '/redirect', 12 | action() { 13 | return { redirect: '/target' } // <== request a redirect 14 | }, 15 | }, 16 | { 17 | path: '/target', 18 | action() { 19 | return { content: '

Content

' } 20 | }, 21 | }, 22 | ]) 23 | 24 | router.resolve('/redirect').then((page) => { 25 | if (page.redirect) { 26 | window.location = page.redirect // <== actual redirect here 27 | } else { 28 | document.body.innerHTML = page.content 29 | } 30 | }) 31 | ``` 32 | 33 | The most common use case of redirects is to redirect to the login page for authorization-protected pages: 34 | 35 | ```js 36 | const router = new UniversalRouter([ 37 | { 38 | path: '/login', 39 | action() { 40 | return { content: '

Login

' } 41 | }, 42 | }, 43 | { 44 | path: '/admin', 45 | action(context) { 46 | if (!context.user) { 47 | return { redirect: '/login' } 48 | } 49 | return { content: '

Admin

' } 50 | }, 51 | }, 52 | ]) 53 | 54 | router 55 | .resolve({ 56 | pathname: '/admin', 57 | user: null, // <== is the user logged in? 58 | }) 59 | .then((page) => { 60 | if (page.redirect) { 61 | window.location = page.redirect 62 | } else { 63 | document.body.innerHTML = page.content 64 | } 65 | }) 66 | ``` 67 | 68 | You also can use [middleware](https://github.com/kriasoft/universal-router/blob/master/docs/api.md#middlewares) 69 | approach to protect a bunch of routes: 70 | 71 | ```js 72 | const adminRoutes = { 73 | path: '/admin', 74 | action(context) { 75 | if (!context.user) { 76 | return { redirect: '/login' } // stop and redirect 77 | } 78 | return context.next() // go to children 79 | }, 80 | children: [ 81 | { path: '', action: () => ({ content: '

Admin: Home

' }) }, 82 | { path: '/users', action: () => ({ content: '

Admin: Users

' }) }, 83 | { path: '/posts', action: () => ({ content: '

Admin: Posts

' }) }, 84 | ], 85 | } 86 | ``` 87 | 88 | In case if you prefer [declarative](https://en.wikipedia.org/wiki/Declarative_programming) routing: 89 | 90 | ```js 91 | const routes = [ 92 | { path: '/login', content: '

Login

' }, 93 | { 94 | path: '/admin', 95 | protected: true, // <== protect current and all child routes 96 | children: [ 97 | { path: '', content: '

Admin: Home

' }, 98 | { path: '/users', content: '

Admin: Users

' }, 99 | { path: '/posts', content: '

Admin: Posts

' }, 100 | ], 101 | }, 102 | ] 103 | 104 | const router = new UniversalRouter(routes, { 105 | resolveRoute(context) { 106 | if (context.route.protected && !context.user) { 107 | return { redirect: '/login', from: context.pathname } // <== where the redirect come from? 108 | } 109 | if (context.route.content) { 110 | return { content: context.route.content } 111 | } 112 | return null 113 | }, 114 | }) 115 | 116 | router.resolve({ pathname: '/admin/users', user: null }).then((page) => { 117 | if (page.redirect) { 118 | console.log(`Redirect from ${page.from} to ${page.redirect}`) 119 | window.location = page.redirect 120 | } else { 121 | document.body.innerHTML = page.content 122 | } 123 | }) 124 | ``` 125 | 126 | For client side redirects without a full page reload you may use the browser 127 | [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) 128 | (or libraries like [history](https://github.com/ReactTraining/history)): 129 | 130 | ```js 131 | router.resolve('/redirect').then((page) => { 132 | if (page.redirect) { 133 | const state = { from: page.from } 134 | window.history.pushState(state, '', page.redirect) 135 | } else { 136 | document.body.innerHTML = page.content 137 | } 138 | }) 139 | ``` 140 | 141 | For server side redirect you need to respond with 142 | [3xx http status code](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_Redirection) and a new location: 143 | 144 | ```js 145 | import http from 'http' 146 | import url from 'url' 147 | 148 | const server = http.createServer(async (req, res) => { 149 | const location = url.parse(req.url) 150 | const page = await router.resolve(location.pathname) 151 | 152 | if (page.redirect) { 153 | res.writeHead(301, { Location: page.redirect }) 154 | res.end() 155 | } else { 156 | res.write(`${page.content}`) 157 | res.end() 158 | } 159 | }) 160 | 161 | server.listen(8080) 162 | ``` 163 | 164 | Playground: [JSFiddle](https://jsfiddle.net/frenzzy/2nq9o896/) 165 | 166 | ## Learn more 167 | 168 | - [Universal Router API](https://github.com/kriasoft/universal-router/blob/master/docs/api.md) 169 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import importPlugin from 'eslint-plugin-import' 3 | import simpleImportSort from 'eslint-plugin-simple-import-sort' 4 | import { config, configs } from 'typescript-eslint' 5 | import globals from 'globals' 6 | 7 | export default config( 8 | { 9 | ignores: [ 10 | '**/node_modules/', 11 | '**/dist/', 12 | '**/build/', 13 | '**/coverage/', 14 | 'src/path-to-regexp.ts', 15 | ], 16 | }, 17 | js.configs.recommended, 18 | ...configs.recommendedTypeChecked, 19 | ...configs.stylisticTypeChecked, 20 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 21 | importPlugin.flatConfigs.recommended, 22 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access 23 | importPlugin.flatConfigs.typescript, 24 | { 25 | languageOptions: { 26 | parserOptions: { 27 | projectService: { 28 | allowDefaultProject: ['*.ts', '*.js', '*.mjs', 'tools/*.js'], 29 | }, 30 | tsconfigRootDir: import.meta.name, 31 | }, 32 | }, 33 | settings: { 34 | 'import/resolver': { 35 | node: true, 36 | typescript: { alwaysTryTypes: true, project: ['./tsconfig.json'] }, 37 | }, 38 | }, 39 | plugins: { 'simple-import-sort': simpleImportSort }, 40 | rules: { 41 | '@typescript-eslint/consistent-indexed-object-style': 'off', 42 | '@typescript-eslint/consistent-type-definitions': 'off', 43 | '@typescript-eslint/no-explicit-any': 'off', 44 | '@typescript-eslint/no-unsafe-assignment': 'off', 45 | '@typescript-eslint/prefer-for-of': 'off', 46 | '@typescript-eslint/prefer-nullish-coalescing': 'off', 47 | '@typescript-eslint/prefer-string-starts-ends-with': 'off', 48 | }, 49 | }, 50 | { 51 | files: ['src/**/*.test.ts'], 52 | rules: { 53 | '@typescript-eslint/no-unsafe-member-access': 'off', 54 | '@typescript-eslint/no-unsafe-return': 'off', 55 | }, 56 | }, 57 | { 58 | files: ['tools/**/*.js'], 59 | languageOptions: { globals: { ...globals.node } }, 60 | rules: { 61 | '@typescript-eslint/no-require-imports': 'off', 62 | '@typescript-eslint/no-unsafe-argument': 'off', 63 | '@typescript-eslint/no-unused-vars': 'off', 64 | }, 65 | }, 66 | ) 67 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "universal-router", 3 | "version": "10.0.0", 4 | "private": true, 5 | "description": "Isomorphic router for JavaScript web applications", 6 | "keywords": [ 7 | "isomorphic", 8 | "universal", 9 | "router", 10 | "routing", 11 | "routes", 12 | "route" 13 | ], 14 | "homepage": "https://www.kriasoft.com/universal-router/", 15 | "repository": "github:kriasoft/universal-router", 16 | "license": "MIT", 17 | "author": "Kriasoft (https://www.kriasoft.com)", 18 | "contributors": [ 19 | "Konstantin Tarkus (https://tarkus.me)", 20 | "Vladimir Kutepov" 21 | ], 22 | "sideEffects": false, 23 | "type": "module", 24 | "exports": { 25 | ".": { 26 | "types": "./universal-router.d.ts", 27 | "import": "./universal-router.js", 28 | "require": "./cjs/universal-router.js" 29 | }, 30 | "./sync": { 31 | "types": "./universal-router-sync.d.ts", 32 | "import": "./universal-router-sync.js", 33 | "require": "./cjs/universal-router-sync.js" 34 | }, 35 | "./generateUrls": { 36 | "types": "./generate-urls.d.ts", 37 | "import": "./generate-urls.js", 38 | "require": "./cjs/generate-urls.js" 39 | }, 40 | "./path-to-regexp": { 41 | "types": "./path-to-regexp.d.ts", 42 | "import": "./path-to-regexp.js", 43 | "require": "./cjs/path-to-regexp.js" 44 | } 45 | }, 46 | "main": "universal-router.js", 47 | "scripts": { 48 | "build": "node tools/build.js", 49 | "coverage": "vitest run --coverage", 50 | "format": "prettier . --write", 51 | "formatcheck": "prettier . --check", 52 | "postinstall": "node tools/install.js https://raw.githubusercontent.com/pillarjs/path-to-regexp/v8.2.0/src/index.ts", 53 | "lint": "eslint .", 54 | "sort": "npx sort-package-json", 55 | "sortcheck": "npx sort-package-json --check", 56 | "test": "vitest", 57 | "typecheck": "tsc --noEmit" 58 | }, 59 | "devDependencies": { 60 | "@eslint/js": "^9.26.0", 61 | "@types/node": "^22.15.3", 62 | "@vitest/coverage-v8": "^3.1.2", 63 | "@vitest/ui": "^3.1.2", 64 | "eslint": "^9.26.0", 65 | "eslint-import-resolver-typescript": "^4.3.4", 66 | "eslint-plugin-import": "^2.31.0", 67 | "eslint-plugin-simple-import-sort": "^12.1.1", 68 | "globals": "^16.0.0", 69 | "prettier": "3.5.3", 70 | "typescript": "^5.8.3", 71 | "typescript-eslint": "^8.31.1", 72 | "vitest": "^3.1.2" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/configuration.html 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | semi: false, 7 | singleQuote: true, 8 | } 9 | 10 | export default config 11 | -------------------------------------------------------------------------------- /src/generate-urls.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { describe, test, expect, vi, type Mock } from 'vitest' 11 | import UniversalRouter from './universal-router' 12 | import UniversalRouterSync from './universal-router-sync' 13 | import generateUrls from './generate-urls' 14 | import { parse } from './path-to-regexp' 15 | 16 | describe('generateUrls', () => { 17 | test('requires router', () => { 18 | // @ts-expect-error missing argument 19 | expect(() => generateUrls()).toThrow(/Router is not defined/) 20 | }) 21 | 22 | test('throws when route not found', () => { 23 | const router = new UniversalRouter({ path: '/a', name: 'a' }) 24 | const url = generateUrls(router) 25 | expect(() => url('hello')).toThrow(/Route "hello" not found/) 26 | 27 | router.root.children = [{ path: '/b', name: 'new' }] 28 | expect(url('new')).toBe('/a/b') 29 | }) 30 | 31 | test('throws when route name is not unique', () => { 32 | const router = new UniversalRouter([ 33 | { path: '/a', name: 'example' }, 34 | { path: '/b', name: 'example' }, 35 | ]) 36 | const url = generateUrls(router) 37 | expect(() => url('example')).toThrow(/Route "example" already exists/) 38 | }) 39 | 40 | test('generates url for named routes', () => { 41 | const router1 = new UniversalRouter({ path: '/:name', name: 'user' }) 42 | const url1 = generateUrls(router1) 43 | expect(url1('user', { name: 'koistya' })).toBe('/koistya') 44 | expect(() => url1('user')).toThrow(/Missing parameters: name/) 45 | 46 | const router2 = new UniversalRouter({ path: '/user/:id', name: 'user' }) 47 | const url2 = generateUrls(router2) 48 | expect(url2('user', { id: '123' })).toBe('/user/123') 49 | expect(() => url2('user')).toThrow(/Missing parameters: id/) 50 | 51 | const router3 = new UniversalRouter({ path: '/user/:id' }) 52 | const url3 = generateUrls(router3) 53 | expect(() => url3('user')).toThrow(/Route "user" not found/) 54 | }) 55 | 56 | test('generates url for routes with array of paths', () => { 57 | const router1 = new UniversalRouter({ 58 | path: ['/:name', '/user/:name'], 59 | name: 'user', 60 | }) 61 | const url1 = generateUrls(router1) 62 | expect(url1('user', { name: 'koistya' })).toBe('/koistya') 63 | 64 | const router2 = new UniversalRouter({ 65 | path: ['/user/:id', '/user/(\\d+)'], 66 | name: 'user', 67 | }) 68 | const url2 = generateUrls(router2) 69 | expect(url2('user', { id: '123' })).toBe('/user/123') 70 | 71 | const router3 = new UniversalRouter({ path: [], name: 'user' }) 72 | const url3 = generateUrls(router3) 73 | expect(url3('user')).toBe('/') 74 | }) 75 | 76 | test('generates url for nested routes', () => { 77 | const router = new UniversalRouter({ 78 | path: '', 79 | name: 'a', 80 | children: [ 81 | { 82 | path: '/b/:x', 83 | name: 'b', 84 | children: [ 85 | { 86 | path: '/c/:y', 87 | name: 'c', 88 | }, 89 | { path: '/d' }, 90 | { path: '/e' }, 91 | ], 92 | }, 93 | ], 94 | }) 95 | const url = generateUrls(router) 96 | expect(url('a')).toBe('/') 97 | expect(url('b', { x: '123' })).toBe('/b/123') 98 | expect(url('c', { x: 'i', y: 'j' })).toBe('/b/i/c/j') 99 | 100 | if (Array.isArray(router.root.children)) { 101 | router.root.children.push({ path: '/new', name: 'new' }) 102 | } 103 | expect(url('new')).toBe('/new') 104 | }) 105 | 106 | test('respects baseUrl', () => { 107 | const options = { baseUrl: '/base' } 108 | 109 | const router1 = new UniversalRouter({ path: '', name: 'home' }, options) 110 | const url1 = generateUrls(router1) 111 | expect(url1('home')).toBe('/base') 112 | 113 | const router2 = new UniversalRouter( 114 | { path: '/post/:id', name: 'post' }, 115 | options, 116 | ) 117 | const url2 = generateUrls(router2) 118 | expect(url2('post', { id: '12', x: 'y' })).toBe('/base/post/12') 119 | 120 | const router3 = new UniversalRouter( 121 | { 122 | name: 'a', 123 | children: [ 124 | { 125 | path: '', 126 | name: 'b', 127 | }, 128 | { 129 | path: '/c/:x', 130 | name: 'c', 131 | children: [ 132 | { 133 | path: '/d/:y', 134 | name: 'd', 135 | }, 136 | ], 137 | }, 138 | ], 139 | }, 140 | options, 141 | ) 142 | const url3 = generateUrls(router3) 143 | expect(url3('a')).toBe('/base') 144 | expect(url3('b')).toBe('/base') 145 | expect(url3('c', { x: 'x' })).toBe('/base/c/x') 146 | expect(url3('d', { x: 'x', y: 'y' })).toBe('/base/c/x/d/y') 147 | 148 | if (Array.isArray(router3.root.children)) { 149 | router3.root.children.push({ path: '/new', name: 'new' }) 150 | } 151 | expect(url3('new')).toBe('/base/new') 152 | }) 153 | 154 | test('generates url with trailing slash', () => { 155 | const routes = [ 156 | { name: 'a', path: '/' }, 157 | { 158 | path: '/parent', 159 | children: [ 160 | { name: 'b', path: '/' }, 161 | { name: 'c', path: '/child/' }, 162 | ], 163 | }, 164 | ] 165 | 166 | const router = new UniversalRouter(routes) 167 | const url = generateUrls(router) 168 | expect(url('a')).toBe('/') 169 | expect(url('b')).toBe('/parent/') 170 | expect(url('c')).toBe('/parent/child/') 171 | 172 | const baseRouter = new UniversalRouter(routes, { baseUrl: '/base' }) 173 | const baseUrl = generateUrls(baseRouter) 174 | expect(baseUrl('a')).toBe('/base/') 175 | expect(baseUrl('b')).toBe('/base/parent/') 176 | expect(baseUrl('c')).toBe('/base/parent/child/') 177 | }) 178 | 179 | test('encodes params', () => { 180 | const router = new UniversalRouter({ path: '/:user', name: 'user' }) 181 | 182 | const url = generateUrls(router) 183 | const prettyUrl = generateUrls(router, { 184 | encode(str) { 185 | return encodeURI(str).replace( 186 | /[/?#]/g, 187 | (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`, 188 | ) 189 | }, 190 | }) 191 | 192 | expect(url('user', { user: '#$&+,/:;=?@' })).toBe( 193 | '/%23%24%26%2B%2C%2F%3A%3B%3D%3F%40', 194 | ) 195 | expect(prettyUrl('user', { user: '#$&+,/:;=?@' })).toBe( 196 | '/%23$&+,%2F:;=%3F@', 197 | ) 198 | }) 199 | 200 | test('stringify query params (1)', () => { 201 | const router = new UniversalRouter({ path: '/:user', name: 'user' }) 202 | const stringifyQueryParams: Mock = vi.fn(() => 'qs') 203 | 204 | const url = generateUrls(router, { stringifyQueryParams }) 205 | 206 | expect(url('user', { user: 'tj', busy: '1' })).toBe('/tj?qs') 207 | expect(stringifyQueryParams.mock.calls.length).toBe(1) 208 | expect(stringifyQueryParams.mock.calls[0]?.[0]).toEqual({ busy: '1' }) 209 | }) 210 | 211 | test('stringify query params (2)', () => { 212 | const router = new UniversalRouter({ 213 | path: '/user/:username', 214 | name: 'user', 215 | }) 216 | const stringifyQueryParams: Mock = vi.fn(() => '') 217 | 218 | const url = generateUrls(router, { stringifyQueryParams }) 219 | 220 | expect(url('user', { username: 'tj', busy: '1' })).toBe('/user/tj') 221 | expect(stringifyQueryParams.mock.calls.length).toBe(1) 222 | expect(stringifyQueryParams.mock.calls[0]?.[0]).toEqual({ busy: '1' }) 223 | }) 224 | 225 | test('stringify query params (3)', () => { 226 | const router = new UniversalRouter({ path: '/me', name: 'me' }) 227 | const stringifyQueryParams: Mock = vi.fn(() => '?x=i&y=j&z=k') 228 | 229 | const url = generateUrls(router, { stringifyQueryParams }) 230 | 231 | expect(url('me', { x: 'i', y: 'j', z: 'k' })).toBe('/me?x=i&y=j&z=k') 232 | expect(stringifyQueryParams.mock.calls.length).toBe(1) 233 | expect(stringifyQueryParams.mock.calls[0]?.[0]).toEqual({ 234 | x: 'i', 235 | y: 'j', 236 | z: 'k', 237 | }) 238 | }) 239 | 240 | test('compatible with UniversalRouterSync', () => { 241 | const router = new UniversalRouterSync({ path: '/foo', name: 'bar' }) 242 | const url = generateUrls(router) 243 | expect(url('bar')).toBe('/foo') 244 | }) 245 | 246 | test('unique nested rout names', () => { 247 | const router = new UniversalRouter([ 248 | { 249 | path: '/a', 250 | name: 'a', 251 | children: [{ path: '/x', name: 'x' }], 252 | }, 253 | { 254 | path: '/b', 255 | name: 'b', 256 | children: [ 257 | { path: '/x', name: 'x' }, 258 | { path: '/o', children: [{ path: '/y', name: 'y' }] }, 259 | ], 260 | }, 261 | ]) 262 | const url = generateUrls(router, { uniqueRouteNameSep: '.' }) 263 | expect(url('a')).toBe('/a') 264 | expect(url('a.x')).toBe('/a/x') 265 | expect(url('b')).toBe('/b') 266 | expect(url('b.x')).toBe('/b/x') 267 | expect(url('b.y')).toBe('/b/o/y') 268 | }) 269 | 270 | test('uses stringify when path is a TokenData instance', () => { 271 | const data = parse('/section/:id') 272 | const router = new UniversalRouter({ path: data, name: 'tokenData' }) 273 | const url = generateUrls(router) 274 | expect(url('tokenData', { id: '42' })).toBe('/section/42') 275 | }) 276 | 277 | test('handles group tokens (curly-brace groups) in path', () => { 278 | const router = new UniversalRouter({ path: '/{foo}', name: 'group' }) 279 | const url = generateUrls(router) 280 | expect(url('group')).toBe('/foo') 281 | }) 282 | }) 283 | -------------------------------------------------------------------------------- /src/generate-urls.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import type { ParseOptions, CompileOptions } from './path-to-regexp.js' 11 | import { parse, compile, stringify, TokenData } from './path-to-regexp.js' 12 | import UniversalRouter, { Route, Routes } from './universal-router.js' 13 | 14 | export interface UrlParams { 15 | [paramName: string]: string | string[] 16 | } 17 | 18 | export interface GenerateUrlsOptions extends ParseOptions, CompileOptions { 19 | /** 20 | * Add a query string to generated url based on unknown route params. 21 | */ 22 | stringifyQueryParams?: (params: UrlParams) => string 23 | /** 24 | * Generates a unique route name based on all parent routes with the specified separator. 25 | */ 26 | uniqueRouteNameSep?: string 27 | } 28 | 29 | /** 30 | * Create a url by route name from route path. 31 | */ 32 | type GenerateUrl = (routeName: string, params?: UrlParams) => string 33 | 34 | type Keys = { [key: string]: boolean } 35 | 36 | function cacheRoutes( 37 | routesByName: Map, 38 | route: Route, 39 | routes: Routes | null | undefined, 40 | name?: string, 41 | sep?: string, 42 | ): void { 43 | if (route.name && name && routesByName.has(name)) { 44 | throw new Error(`Route "${name}" already exists`) 45 | } 46 | 47 | if (route.name && name) { 48 | routesByName.set(name, route) 49 | } 50 | 51 | if (routes) { 52 | for (let i = 0; i < routes.length; i++) { 53 | const childRoute = routes[i]! 54 | const childName = childRoute.name 55 | childRoute.parent = route 56 | cacheRoutes( 57 | routesByName, 58 | childRoute, 59 | childRoute.children, 60 | name && sep ? (childName ? name + sep + childName : name) : childName, 61 | sep, 62 | ) 63 | } 64 | } 65 | } 66 | 67 | /** 68 | * Create a function to generate urls by route names. 69 | */ 70 | function generateUrls( 71 | router: UniversalRouter, 72 | options?: GenerateUrlsOptions, 73 | ): GenerateUrl { 74 | if (!router) { 75 | throw new ReferenceError('Router is not defined') 76 | } 77 | 78 | const routesByName = new Map() 79 | const regexpByRoute = new Map< 80 | Route, 81 | { toPath: (params?: UrlParams) => string | undefined; keys: Keys } 82 | >() 83 | const opts: GenerateUrlsOptions = { encode: encodeURIComponent, ...options } 84 | return (routeName: string, params?: UrlParams): string => { 85 | let route = routesByName.get(routeName) 86 | if (!route) { 87 | routesByName.clear() 88 | regexpByRoute.clear() 89 | cacheRoutes( 90 | routesByName, 91 | router.root, 92 | router.root.children, 93 | router.root.name, 94 | opts.uniqueRouteNameSep, 95 | ) 96 | 97 | route = routesByName.get(routeName) 98 | if (!route) { 99 | throw new Error(`Route "${routeName}" not found`) 100 | } 101 | } 102 | 103 | let regexp = regexpByRoute.get(route) 104 | if (!regexp) { 105 | let fullPath = '' 106 | let rt: Route | null | undefined = route 107 | while (rt) { 108 | const path = Array.isArray(rt.path) ? rt.path[0] : rt.path 109 | if (path) { 110 | fullPath = 111 | (path instanceof TokenData ? stringify(path) : path) + fullPath 112 | } 113 | rt = rt.parent 114 | } 115 | const tokens = parse(fullPath, opts) 116 | const toPath = compile(fullPath, opts) 117 | const keys: Keys = Object.create(null) 118 | for (let i = 0; i < tokens.tokens.length; i++) { 119 | const token = tokens.tokens[i] 120 | if (token && token.type !== 'text') { 121 | if (token.type === 'group') { 122 | keys[String(i)] = true 123 | } else { 124 | keys[token.name] = true 125 | } 126 | } 127 | } 128 | regexp = { toPath, keys } 129 | regexpByRoute.set(route, regexp) 130 | } 131 | 132 | let url = router.baseUrl + regexp.toPath(params) || '/' 133 | 134 | if (opts.stringifyQueryParams && params) { 135 | const queryParams: UrlParams = {} 136 | const keys = Object.keys(params) 137 | for (let i = 0; i < keys.length; i++) { 138 | const key = keys[i] 139 | if (key && !regexp.keys[key] && params[key] != null) { 140 | queryParams[key] = params[key] 141 | } 142 | } 143 | const query = opts.stringifyQueryParams(queryParams) 144 | if (query) { 145 | url += query.charAt(0) === '?' ? query : `?${query}` 146 | } 147 | } 148 | 149 | return url 150 | } 151 | } 152 | 153 | export default generateUrls 154 | -------------------------------------------------------------------------------- /src/universal-router-sync.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { describe, test, expect, vi, type Mock } from 'vitest' 11 | import UniversalRouter, { Route } from './universal-router-sync' 12 | import type { RouteError } from './universal-router-sync' 13 | 14 | describe('UniversalRouterSync', () => { 15 | test('requires routes', () => { 16 | // @ts-expect-error missing argument 17 | expect(() => new UniversalRouter()).toThrow(/Invalid routes/) 18 | // @ts-expect-error wrong argument 19 | expect(() => new UniversalRouter(12)).toThrow(/Invalid routes/) 20 | // @ts-expect-error wrong argument 21 | expect(() => new UniversalRouter(null)).toThrow(/Invalid routes/) 22 | }) 23 | 24 | test('supports custom route resolver', () => { 25 | const resolveRoute: Mock = vi.fn((context) => context.route.component) 26 | const action: Mock = vi.fn() 27 | const router = new UniversalRouter( 28 | { 29 | path: '/a', 30 | action, 31 | children: [ 32 | { path: '/:b', component: null, action } as Route, 33 | { path: '/c', component: 'c', action } as Route, 34 | { path: '/d', component: 'd', action } as Route, 35 | ], 36 | }, 37 | { resolveRoute }, 38 | ) 39 | expect(router.resolve('/a/c')).toBe('c') 40 | expect(resolveRoute.mock.calls.length).toBe(3) 41 | expect(action.mock.calls.length).toBe(0) 42 | }) 43 | 44 | test('supports custom error handler', () => { 45 | const errorHandler: Mock = vi.fn(() => 'result') 46 | const router = new UniversalRouter([], { errorHandler }) 47 | expect(router.resolve('/')).toBe('result') 48 | expect(errorHandler.mock.calls.length).toBe(1) 49 | const error = errorHandler.mock.calls[0]?.[0] 50 | const context = errorHandler.mock.calls[0]?.[1] 51 | expect(error).toBeInstanceOf(Error) 52 | expect(error.message).toBe('Route not found') 53 | expect(error.status).toBe(404) 54 | expect(context.pathname).toBe('/') 55 | expect(context.router).toBe(router) 56 | }) 57 | 58 | test('handles route errors', () => { 59 | const errorHandler: Mock = vi.fn(() => 'result') 60 | const route = { 61 | path: '/', 62 | action: (): never => { 63 | throw new Error('custom') 64 | }, 65 | } 66 | const router = new UniversalRouter(route, { errorHandler }) 67 | expect(router.resolve('/')).toBe('result') 68 | expect(errorHandler.mock.calls.length).toBe(1) 69 | const error = errorHandler.mock.calls[0]?.[0] 70 | const context = errorHandler.mock.calls[0]?.[1] 71 | expect(error).toBeInstanceOf(Error) 72 | expect(error.message).toBe('custom') 73 | expect(context.pathname).toBe('/') 74 | expect(context.path).toBe('/') 75 | expect(context.router).toBe(router) 76 | expect(context.route).toBe(route) 77 | }) 78 | 79 | test('throws when route not found', () => { 80 | const router = new UniversalRouter([]) 81 | let err 82 | try { 83 | router.resolve('/') 84 | } catch (e) { 85 | err = e as RouteError 86 | } 87 | expect(err).toBeInstanceOf(Error) 88 | expect(err?.message).toBe('Route not found') 89 | expect(err?.status).toBe(404) 90 | }) 91 | 92 | test("executes the matching route's action method and return its result", () => { 93 | const action: Mock = vi.fn(() => 'b') 94 | const router = new UniversalRouter({ path: '/a', action }) 95 | expect(router.resolve('/a')).toBe('b') 96 | expect(action.mock.calls.length).toBe(1) 97 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 98 | }) 99 | 100 | test('finds the first route whose action method !== undefined or null', () => { 101 | const action1: Mock = vi.fn(() => undefined) 102 | const action2: Mock = vi.fn(() => null) 103 | const action3: Mock = vi.fn(() => 'c') 104 | const action4: Mock = vi.fn(() => 'd') 105 | const router = new UniversalRouter([ 106 | { path: '/a', action: action1 }, 107 | { path: '/a', action: action2 }, 108 | { path: '/a', action: action3 }, 109 | { path: '/a', action: action4 }, 110 | ]) 111 | expect(router.resolve('/a')).toBe('c') 112 | expect(action1.mock.calls.length).toBe(1) 113 | expect(action2.mock.calls.length).toBe(1) 114 | expect(action3.mock.calls.length).toBe(1) 115 | expect(action4.mock.calls.length).toBe(0) 116 | }) 117 | 118 | test('allows to pass context variables to action methods', () => { 119 | const action: Mock = vi.fn(() => true) 120 | const router = new UniversalRouter([{ path: '/a', action }]) 121 | expect(router.resolve({ pathname: '/a', test: 'b' })).toBe(true) 122 | expect(action.mock.calls.length).toBe(1) 123 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 124 | expect(action.mock.calls[0]?.[0]).toHaveProperty('test', 'b') 125 | }) 126 | 127 | test('skips action methods of routes that do not match the URL path', () => { 128 | const action: Mock = vi.fn() 129 | const router = new UniversalRouter([{ path: '/a', action }]) 130 | let err 131 | try { 132 | router.resolve('/b') 133 | } catch (e) { 134 | err = e as RouteError 135 | } 136 | expect(err).toBeInstanceOf(Error) 137 | expect(err?.message).toBe('Route not found') 138 | expect(err?.status).toBe(404) 139 | expect(action.mock.calls.length).toBe(0) 140 | }) 141 | 142 | test('supports asynchronous route actions', async () => { 143 | const router = new UniversalRouter([ 144 | { path: '/a', action: () => Promise.resolve('b') }, 145 | ]) 146 | await expect(router.resolve('/a')).resolves.toBe('b') 147 | }) 148 | 149 | test('captures URL parameters to context.params', () => { 150 | const action: Mock = vi.fn(() => true) 151 | const router = new UniversalRouter([{ path: '/:one/:two', action }]) 152 | expect(router.resolve({ pathname: '/a/b' })).toBe(true) 153 | expect(action.mock.calls.length).toBe(1) 154 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params', { 155 | one: 'a', 156 | two: 'b', 157 | }) 158 | }) 159 | 160 | test('provides all URL parameters to each route', () => { 161 | const action1: Mock = vi.fn() 162 | const action2: Mock = vi.fn(() => true) 163 | const router = new UniversalRouter([ 164 | { 165 | path: '/:one', 166 | action: action1, 167 | children: [ 168 | { 169 | path: '/:two', 170 | action: action2, 171 | }, 172 | ], 173 | }, 174 | ]) 175 | expect(router.resolve({ pathname: '/a/b' })).toBe(true) 176 | expect(action1.mock.calls.length).toBe(1) 177 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 178 | expect(action2.mock.calls.length).toBe(1) 179 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { 180 | one: 'a', 181 | two: 'b', 182 | }) 183 | }) 184 | 185 | test('overrides URL parameters with same name in child routes', () => { 186 | const action1: Mock = vi.fn() 187 | const action2: Mock = vi.fn(() => true) 188 | const router = new UniversalRouter([ 189 | { 190 | path: '/:one', 191 | action: action1, 192 | children: [ 193 | { 194 | path: '/:one', 195 | action: action1, 196 | }, 197 | { 198 | path: '/:two', 199 | action: action2, 200 | }, 201 | ], 202 | }, 203 | ]) 204 | expect(router.resolve({ pathname: '/a/b' })).toBe(true) 205 | expect(action1.mock.calls.length).toBe(2) 206 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 207 | expect(action1.mock.calls[1]?.[0]).toHaveProperty('params', { one: 'b' }) 208 | expect(action2.mock.calls.length).toBe(1) 209 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { 210 | one: 'a', 211 | two: 'b', 212 | }) 213 | }) 214 | 215 | test('does not collect parameters from previous routes', () => { 216 | const action1: Mock = vi.fn(() => undefined) 217 | const action2: Mock = vi.fn(() => undefined) 218 | const action3: Mock = vi.fn(() => true) 219 | const router = new UniversalRouter([ 220 | { 221 | path: '/:one', 222 | action: action1, 223 | children: [ 224 | { 225 | path: '/:two', 226 | action: action1, 227 | }, 228 | ], 229 | }, 230 | { 231 | path: '/:three', 232 | action: action2, 233 | children: [ 234 | { 235 | path: '/:four', 236 | action: action2, 237 | }, 238 | { 239 | path: '/:five', 240 | action: action3, 241 | }, 242 | ], 243 | }, 244 | ]) 245 | expect(router.resolve({ pathname: '/a/b' })).toBe(true) 246 | expect(action1.mock.calls.length).toBe(2) 247 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 248 | expect(action1.mock.calls[1]?.[0]).toHaveProperty('params', { 249 | one: 'a', 250 | two: 'b', 251 | }) 252 | expect(action2.mock.calls.length).toBe(2) 253 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { three: 'a' }) 254 | expect(action2.mock.calls[1]?.[0]).toHaveProperty('params', { 255 | three: 'a', 256 | four: 'b', 257 | }) 258 | expect(action3.mock.calls.length).toBe(1) 259 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('params', { 260 | three: 'a', 261 | five: 'b', 262 | }) 263 | }) 264 | 265 | test('supports next() across multiple routes', () => { 266 | const log: number[] = [] 267 | const router = new UniversalRouter([ 268 | { 269 | path: '/test', 270 | children: [ 271 | { 272 | path: '', 273 | action(): void { 274 | log.push(2) 275 | }, 276 | children: [ 277 | { 278 | path: '', 279 | action({ next }) { 280 | log.push(3) 281 | const result = next() 282 | log.push(6) 283 | return result 284 | }, 285 | children: [ 286 | { 287 | path: '', 288 | action({ next }) { 289 | log.push(4) 290 | const result = next() 291 | log.push(5) 292 | return result 293 | }, 294 | }, 295 | ], 296 | }, 297 | ], 298 | }, 299 | { 300 | path: '', 301 | action(): void { 302 | log.push(7) 303 | }, 304 | children: [ 305 | { 306 | path: '', 307 | action(): void { 308 | log.push(8) 309 | }, 310 | }, 311 | { 312 | path: '', 313 | action(): void { 314 | log.push(9) 315 | }, 316 | }, 317 | ], 318 | }, 319 | ], 320 | action({ next }) { 321 | log.push(1) 322 | const result = next() 323 | log.push(10) 324 | return result 325 | }, 326 | }, 327 | { 328 | path: '/:id', 329 | action(): void { 330 | log.push(11) 331 | }, 332 | }, 333 | { 334 | path: '/test', 335 | action(): string { 336 | log.push(12) 337 | return 'done' 338 | }, 339 | }, 340 | { 341 | path: '/*all', 342 | action(): void { 343 | log.push(13) 344 | }, 345 | }, 346 | ]) 347 | 348 | expect(router.resolve('/test')).toBe('done') 349 | expect(log).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) 350 | }) 351 | 352 | test('supports next(true) across multiple routes', () => { 353 | const log: number[] = [] 354 | const router = new UniversalRouter({ 355 | action({ next }): unknown { 356 | log.push(1) 357 | const result = next() 358 | log.push(9) 359 | return result 360 | }, 361 | children: [ 362 | { 363 | path: '/a/b/c', 364 | action({ next }): Promise { 365 | log.push(2) 366 | const result = next(true) 367 | log.push(8) 368 | return result 369 | }, 370 | }, 371 | { 372 | path: '/a', 373 | action(): void { 374 | log.push(3) 375 | }, 376 | children: [ 377 | { 378 | path: '/b', 379 | action({ next }): Promise { 380 | log.push(4) 381 | const result = next() 382 | log.push(6) 383 | return result 384 | }, 385 | children: [ 386 | { 387 | path: '/c', 388 | action(): void { 389 | log.push(5) 390 | }, 391 | }, 392 | ], 393 | }, 394 | { 395 | path: '/b/c', 396 | action(): string { 397 | log.push(7) 398 | return 'done' 399 | }, 400 | }, 401 | ], 402 | }, 403 | ], 404 | }) 405 | 406 | expect(router.resolve('/a/b/c')).toBe('done') 407 | expect(log).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) 408 | }) 409 | 410 | test('supports parametrized routes', () => { 411 | const action: Mock = vi.fn(() => true) 412 | const router = new UniversalRouter([{ path: '/path/:a/other/:b', action }]) 413 | expect(router.resolve('/path/1/other/2')).toBe(true) 414 | expect(action.mock.calls.length).toBe(1) 415 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params.a', '1') 416 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params.b', '2') 417 | expect(action.mock.calls[0]?.[1]).toHaveProperty('a', '1') 418 | expect(action.mock.calls[0]?.[1]).toHaveProperty('b', '2') 419 | }) 420 | 421 | test('supports nested routes (1)', () => { 422 | const action1: Mock = vi.fn() 423 | const action2: Mock = vi.fn(() => true) 424 | const router = new UniversalRouter([ 425 | { 426 | path: '', 427 | action: action1, 428 | children: [ 429 | { 430 | path: '/a', 431 | action: action2, 432 | }, 433 | ], 434 | }, 435 | ]) 436 | 437 | expect(router.resolve('/a')).toBe(true) 438 | expect(action1.mock.calls.length).toBe(1) 439 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '') 440 | expect(action2.mock.calls.length).toBe(1) 441 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 442 | }) 443 | 444 | test('supports nested routes (2)', () => { 445 | const action1: Mock = vi.fn() 446 | const action2: Mock = vi.fn(() => true) 447 | const router = new UniversalRouter([ 448 | { 449 | path: '/a', 450 | action: action1, 451 | children: [ 452 | { 453 | path: '/b', 454 | action: action2, 455 | }, 456 | ], 457 | }, 458 | ]) 459 | 460 | expect(router.resolve('/a/b')).toBe(true) 461 | expect(action1.mock.calls.length).toBe(1) 462 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 463 | expect(action2.mock.calls.length).toBe(1) 464 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/b') 465 | }) 466 | 467 | test('supports nested routes (3)', () => { 468 | const action1: Mock = vi.fn(() => undefined) 469 | const action2: Mock = vi.fn(() => null) 470 | const action3: Mock = vi.fn(() => true) 471 | const router = new UniversalRouter([ 472 | { 473 | path: '/a', 474 | action: action1, 475 | children: [ 476 | { 477 | path: '/b', 478 | action: action2, 479 | }, 480 | ], 481 | }, 482 | { 483 | path: '/a/b', 484 | action: action3, 485 | }, 486 | ]) 487 | 488 | expect(router.resolve('/a/b')).toBe(true) 489 | expect(action1.mock.calls.length).toBe(1) 490 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '') 491 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 492 | expect(action2.mock.calls.length).toBe(1) 493 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '/a') 494 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/b') 495 | expect(action3.mock.calls.length).toBe(1) 496 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '') 497 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('path', '/a/b') 498 | }) 499 | 500 | test('re-throws an error', () => { 501 | const error = new Error('test error') 502 | const router = new UniversalRouter([ 503 | { 504 | path: '/a', 505 | action(): never { 506 | throw error 507 | }, 508 | }, 509 | ]) 510 | let err 511 | try { 512 | router.resolve('/a') 513 | } catch (e) { 514 | err = e 515 | } 516 | expect(err).toBe(error) 517 | }) 518 | 519 | test('respects baseUrl', () => { 520 | const action: Mock = vi.fn(() => 17) 521 | const routes = { 522 | path: '/a', 523 | children: [ 524 | { 525 | path: '/b', 526 | children: [{ path: '/c', action }], 527 | }, 528 | ], 529 | } 530 | const router = new UniversalRouter(routes, { baseUrl: '/base' }) 531 | expect(router.resolve('/base/a/b/c')).toBe(17) 532 | expect(action.mock.calls.length).toBe(1) 533 | expect(action.mock.calls[0]?.[0]).toHaveProperty('pathname', '/base/a/b/c') 534 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/c') 535 | expect(action.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '/base/a/b') 536 | expect(action.mock.calls[0]?.[0]).toHaveProperty( 537 | 'route', 538 | routes.children[0]?.children[0], 539 | ) 540 | expect(action.mock.calls[0]?.[0]).toHaveProperty('router', router) 541 | 542 | let err 543 | try { 544 | router.resolve('/a/b/c') 545 | } catch (e) { 546 | err = e as RouteError 547 | } 548 | expect(action.mock.calls.length).toBe(1) 549 | expect(err).toBeInstanceOf(Error) 550 | expect(err?.message).toBe('Route not found') 551 | expect(err?.status).toBe(404) 552 | }) 553 | 554 | test('matches routes with trailing slashes', () => { 555 | const router = new UniversalRouter([ 556 | { path: '/', action: (): string => 'a' }, 557 | { path: '/page/', action: (): string => 'b' }, 558 | { 559 | path: '/child', 560 | children: [ 561 | { path: '/', action: (): string => 'c' }, 562 | { path: '/page/', action: (): string => 'd' }, 563 | ], 564 | }, 565 | ]) 566 | expect(router.resolve('/')).toBe('a') 567 | expect(router.resolve('/page/')).toBe('b') 568 | expect(router.resolve('/child/')).toBe('c') 569 | expect(router.resolve('/child/page/')).toBe('d') 570 | }) 571 | 572 | test('skips nested routes when middleware route returns null', () => { 573 | const middleware: Mock = vi.fn(() => null) 574 | const action: Mock = vi.fn(() => 'skipped') 575 | const router = new UniversalRouter([ 576 | { 577 | path: '/match', 578 | action: middleware, 579 | children: [{ action }], 580 | }, 581 | { 582 | path: '/match', 583 | action: (): number => 404, 584 | }, 585 | ]) 586 | 587 | expect(router.resolve('/match')).toBe(404) 588 | expect(action.mock.calls.length).toBe(0) 589 | expect(middleware.mock.calls.length).toBe(1) 590 | }) 591 | 592 | test('matches nested routes when middleware route returns undefined', () => { 593 | const middleware: Mock = vi.fn(() => undefined) 594 | const action: Mock = vi.fn(() => null) 595 | const router = new UniversalRouter([ 596 | { 597 | path: '/match', 598 | action: middleware, 599 | children: [{ action }], 600 | }, 601 | { 602 | path: '/match', 603 | action: (): number => 404, 604 | }, 605 | ]) 606 | 607 | expect(router.resolve('/match')).toBe(404) 608 | expect(action.mock.calls.length).toBe(1) 609 | expect(middleware.mock.calls.length).toBe(1) 610 | }) 611 | 612 | test('handles route not found error correctly', () => { 613 | const router = new UniversalRouter({ 614 | path: '/', 615 | action({ next }): unknown { 616 | return next() 617 | }, 618 | children: [{ path: '/child' }], 619 | }) 620 | 621 | let err 622 | try { 623 | router.resolve('/404') 624 | } catch (e) { 625 | err = e as RouteError 626 | } 627 | expect(err).toBeInstanceOf(Error) 628 | expect(err?.message).toBe('Route not found') 629 | expect(err?.status).toBe(404) 630 | }) 631 | 632 | test('handles malformed URI params', () => { 633 | const router = new UniversalRouter({ 634 | path: '/:a', 635 | action: (ctx): object => ctx.params, 636 | }) 637 | expect(router.resolve('/%AF')).toStrictEqual({ a: '%AF' }) 638 | }) 639 | 640 | test('decodes params correctly', () => { 641 | const router = new UniversalRouter({ 642 | path: '/:a/:b/:c', 643 | action: (ctx): object => ctx.params, 644 | }) 645 | expect(router.resolve('/%2F/%3A/caf%C3%A9')).toStrictEqual({ 646 | a: '/', 647 | b: ':', 648 | c: 'café', 649 | }) 650 | }) 651 | 652 | test('decodes repeated parameters correctly', () => { 653 | const router = new UniversalRouter({ 654 | path: '/*a', 655 | action: (ctx): object => ctx.params, 656 | }) 657 | expect(router.resolve('/x%2Fy/z/%20/%AF')).toStrictEqual({ 658 | a: ['x/y', 'z', ' ', '%AF'], 659 | }) 660 | }) 661 | 662 | test('matches 0 routes (1)', () => { 663 | const action: Mock = vi.fn(() => true) 664 | const route = { path: '/', action } 665 | expect(() => new UniversalRouter(route).resolve('/a')).toThrow( 666 | /Route not found/, 667 | ) 668 | expect(action.mock.calls.length).toBe(0) 669 | }) 670 | 671 | test('matches 0 routes (2)', () => { 672 | const action: Mock = vi.fn(() => true) 673 | const route = { path: '/a', action } 674 | expect(() => new UniversalRouter(route).resolve('/')).toThrow( 675 | /Route not found/, 676 | ) 677 | expect(action.mock.calls.length).toBe(0) 678 | }) 679 | 680 | test('matches 0 routes (3)', () => { 681 | const action: Mock = vi.fn(() => true) 682 | const route = { path: '/a', action, children: [{ path: '/b', action }] } 683 | expect(() => new UniversalRouter(route).resolve('/b')).toThrow( 684 | /Route not found/, 685 | ) 686 | expect(action.mock.calls.length).toBe(0) 687 | }) 688 | 689 | test('matches 0 routes (4)', () => { 690 | const action: Mock = vi.fn(() => true) 691 | const route = { path: 'a', action, children: [{ path: 'b', action }] } 692 | expect(() => new UniversalRouter(route).resolve('ab')).toThrow( 693 | /Route not found/, 694 | ) 695 | expect(action.mock.calls.length).toBe(0) 696 | }) 697 | 698 | test('matches 0 routes (5)', () => { 699 | const action: Mock = vi.fn(() => true) 700 | const route = { action } 701 | expect(() => new UniversalRouter(route).resolve('/a')).toThrow( 702 | /Route not found/, 703 | ) 704 | expect(action.mock.calls.length).toBe(0) 705 | }) 706 | 707 | test('matches 0 routes (6)', () => { 708 | const action: Mock = vi.fn(() => true) 709 | const route = { path: '/', action } 710 | expect(() => new UniversalRouter(route).resolve('')).toThrow( 711 | /Route not found/, 712 | ) 713 | expect(action.mock.calls.length).toBe(0) 714 | }) 715 | 716 | test('matches 0 routes (7)', () => { 717 | const action: Mock = vi.fn(() => true) 718 | const route = { path: '/*a', action, children: [] } 719 | expect(() => new UniversalRouter(route).resolve('')).toThrow( 720 | /Route not found/, 721 | ) 722 | expect(action.mock.calls.length).toBe(0) 723 | }) 724 | 725 | test('matches 1 route (1)', () => { 726 | const action: Mock = vi.fn(() => true) 727 | const route = { 728 | path: '/', 729 | action, 730 | } 731 | expect(new UniversalRouter(route).resolve('/')).toBe(true) 732 | expect(action.mock.calls.length).toBe(1) 733 | const context = action.mock.calls[0]?.[0] 734 | expect(context).toHaveProperty('baseUrl', '') 735 | expect(context).toHaveProperty('path', '/') 736 | expect(context).toHaveProperty('route.path', '/') 737 | }) 738 | 739 | test('matches 1 route (2)', () => { 740 | const action: Mock = vi.fn(() => true) 741 | const route = { 742 | path: '/a', 743 | action, 744 | } 745 | expect(new UniversalRouter(route).resolve('/a')).toBe(true) 746 | expect(action.mock.calls.length).toBe(1) 747 | const context = action.mock.calls[0]?.[0] 748 | expect(context).toHaveProperty('baseUrl', '') 749 | expect(context).toHaveProperty('path', '/a') 750 | expect(context).toHaveProperty('route.path', '/a') 751 | }) 752 | 753 | test('matches 2 routes (1)', () => { 754 | const action: Mock = vi.fn(() => undefined) 755 | const route = { 756 | path: '', 757 | action, 758 | children: [ 759 | { 760 | path: '/a', 761 | action, 762 | }, 763 | ], 764 | } 765 | expect(() => new UniversalRouter(route).resolve('/a')).toThrow( 766 | /Route not found/, 767 | ) 768 | expect(action.mock.calls.length).toBe(2) 769 | const context1 = action.mock.calls[0]?.[0] 770 | expect(context1).toHaveProperty('baseUrl', '') 771 | expect(context1).toHaveProperty('path', '') 772 | expect(context1).toHaveProperty('route.path', '') 773 | const context2 = action.mock.calls[1]?.[0] 774 | expect(context2).toHaveProperty('baseUrl', '') 775 | expect(context2).toHaveProperty('path', '/a') 776 | expect(context2).toHaveProperty('route.path', '/a') 777 | }) 778 | 779 | test('matches 2 routes (2)', () => { 780 | const action: Mock = vi.fn(() => undefined) 781 | const route = { 782 | path: '/a', 783 | action, 784 | children: [ 785 | { 786 | path: '/b', 787 | action, 788 | children: [ 789 | { 790 | path: '/c', 791 | action, 792 | }, 793 | ], 794 | }, 795 | ], 796 | } 797 | expect(() => new UniversalRouter(route).resolve('/a/b/c')).toThrow( 798 | /Route not found/, 799 | ) 800 | expect(action.mock.calls.length).toBe(3) 801 | const context1 = action.mock.calls[0]?.[0] 802 | expect(context1).toHaveProperty('baseUrl', '') 803 | expect(context1).toHaveProperty('route.path', '/a') 804 | const context2 = action.mock.calls[1]?.[0] 805 | expect(context2).toHaveProperty('baseUrl', '/a') 806 | expect(context2).toHaveProperty('route.path', '/b') 807 | const context3 = action.mock.calls[2]?.[0] 808 | expect(context3).toHaveProperty('baseUrl', '/a/b') 809 | expect(context3).toHaveProperty('route.path', '/c') 810 | }) 811 | 812 | test('matches 2 routes (3)', () => { 813 | const action: Mock = vi.fn(() => undefined) 814 | const route = { 815 | path: '', 816 | action, 817 | children: [ 818 | { 819 | path: '', 820 | action, 821 | }, 822 | ], 823 | } 824 | expect(() => new UniversalRouter(route).resolve('/')).toThrow( 825 | /Route not found/, 826 | ) 827 | expect(action.mock.calls.length).toBe(2) 828 | const context1 = action.mock.calls[0]?.[0] 829 | expect(context1).toHaveProperty('baseUrl', '') 830 | expect(context1).toHaveProperty('route.path', '') 831 | const context2 = action.mock.calls[1]?.[0] 832 | expect(context2).toHaveProperty('baseUrl', '') 833 | expect(context2).toHaveProperty('route.path', '') 834 | }) 835 | 836 | test('matches an array of paths', () => { 837 | const action: Mock = vi.fn(() => true) 838 | const route = { path: ['/e', '/f'], action } 839 | expect(new UniversalRouter(route).resolve('/f')).toBe(true) 840 | expect(action.mock.calls.length).toBe(1) 841 | const context = action.mock.calls[0]?.[0] 842 | expect(context).toHaveProperty('baseUrl', '') 843 | expect(context).toHaveProperty('route.path', ['/e', '/f']) 844 | }) 845 | }) 846 | -------------------------------------------------------------------------------- /src/universal-router-sync.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { match } from './path-to-regexp.js' 11 | import type { 12 | Path, 13 | Match, 14 | MatchFunction, 15 | ParseOptions, 16 | MatchOptions, 17 | PathToRegexpOptions, 18 | CompileOptions, 19 | } from './path-to-regexp.js' 20 | 21 | /** 22 | * In addition to a URL path string, any arbitrary data can be passed to 23 | * the `router.resolve()` method, that becomes available inside action functions. 24 | */ 25 | export interface RouterContext { 26 | [propName: string]: any 27 | } 28 | 29 | export interface ResolveContext extends RouterContext { 30 | /** 31 | * URL which was transmitted to `router.resolve()`. 32 | */ 33 | pathname: string 34 | } 35 | 36 | /** 37 | * Params is a key/value object that represents extracted URL parameters. 38 | */ 39 | export interface RouteParams { 40 | [paramName: string]: string | string[] 41 | } 42 | 43 | export type RouteResultSync = T | null | undefined 44 | 45 | export interface RouteContext 46 | extends ResolveContext { 47 | /** 48 | * Current router instance. 49 | */ 50 | router: UniversalRouterSync 51 | /** 52 | * Matched route object. 53 | */ 54 | route: Route 55 | /** 56 | * Base URL path relative to the path of the current route. 57 | */ 58 | baseUrl: string 59 | /** 60 | * Matched path. 61 | */ 62 | path: string 63 | /** 64 | * Matched path params. 65 | */ 66 | params: RouteParams 67 | /** 68 | * Middleware style function which can continue resolving. 69 | */ 70 | next: (resume?: boolean) => RouteResultSync 71 | } 72 | 73 | /** 74 | * A Route is a singular route in your application. It contains a path, an 75 | * action function, and optional children which are an array of Route. 76 | * @template C User context that is made union with RouterContext. 77 | * @template R Result that every action function resolves to. 78 | */ 79 | export interface Route { 80 | /** 81 | * A string, array of strings, or a regular expression. Defaults to an empty string. 82 | */ 83 | path?: Path | Path[] 84 | /** 85 | * A unique string that can be used to generate the route URL. 86 | */ 87 | name?: string 88 | /** 89 | * The link to the parent route is automatically populated by the router. Useful for breadcrumbs. 90 | */ 91 | parent?: Route | null 92 | /** 93 | * An array of Route objects. Nested routes are perfect to be used in middleware routes. 94 | */ 95 | children?: Routes | null 96 | /** 97 | * Action method should return anything except `null` or `undefined` to be resolved by router 98 | * otherwise router will throw `Page not found` error if all matched routes returned nothing. 99 | */ 100 | action?: ( 101 | context: RouteContext, 102 | params: RouteParams, 103 | ) => RouteResultSync 104 | /** 105 | * The route path match function. Used for internal caching. 106 | */ 107 | match?: MatchFunction 108 | } 109 | 110 | /** 111 | * Routes is an array of type Route. 112 | * @template C User context that is made union with RouterContext. 113 | * @template R Result that every action function resolves to. 114 | */ 115 | export type Routes = Route< 116 | R, 117 | C 118 | >[] 119 | 120 | export type ResolveRoute = ( 121 | context: RouteContext, 122 | params: RouteParams, 123 | ) => RouteResultSync 124 | 125 | export type RouteError = Error & { status?: number } 126 | 127 | export type ErrorHandler = ( 128 | error: RouteError, 129 | context: ResolveContext, 130 | ) => RouteResultSync 131 | 132 | export interface RouterOptions 133 | extends ParseOptions, 134 | MatchOptions, 135 | PathToRegexpOptions, 136 | CompileOptions { 137 | context?: C 138 | baseUrl?: string 139 | resolveRoute?: ResolveRoute 140 | errorHandler?: ErrorHandler 141 | } 142 | 143 | export interface RouteMatch { 144 | route: Route 145 | baseUrl: string 146 | path: string 147 | params: RouteParams 148 | } 149 | 150 | function decode(val: string): string { 151 | try { 152 | return decodeURIComponent(val) 153 | } catch { 154 | return val 155 | } 156 | } 157 | 158 | function matchRoute( 159 | route: Route, 160 | baseUrl: string, 161 | options: RouterOptions, 162 | pathname: string, 163 | parentParams?: RouteParams, 164 | ): Iterator, false, Route | false> { 165 | let matchResult: Match 166 | let childMatches: Iterator< 167 | RouteMatch, 168 | false, 169 | Route | false 170 | > | null 171 | let childIndex = 0 172 | 173 | return { 174 | next( 175 | routeToSkip: Route | false, 176 | ): IteratorResult, false> { 177 | if (route === routeToSkip) { 178 | return { done: true, value: false } 179 | } 180 | 181 | if (!matchResult) { 182 | const rt = route 183 | const end = !rt.children 184 | if (!rt.match) { 185 | rt.match = match(rt.path || '', { end, ...options }) 186 | } 187 | matchResult = rt.match(pathname) 188 | 189 | if (matchResult) { 190 | const { path } = matchResult 191 | matchResult.path = 192 | !end && path.charAt(path.length - 1) === '/' ? path.substr(1) : path 193 | matchResult.params = { ...parentParams, ...matchResult.params } 194 | return { 195 | done: false, 196 | value: { 197 | route, 198 | baseUrl, 199 | path: matchResult.path, 200 | params: matchResult.params, 201 | }, 202 | } 203 | } 204 | } 205 | 206 | if (matchResult && route.children) { 207 | while (childIndex < route.children.length) { 208 | if (!childMatches) { 209 | const childRoute = route.children[childIndex]! 210 | childRoute.parent = route 211 | 212 | childMatches = matchRoute( 213 | childRoute, 214 | baseUrl + matchResult.path, 215 | options, 216 | pathname.substr(matchResult.path.length), 217 | matchResult.params, 218 | ) 219 | } 220 | 221 | const childMatch = childMatches.next(routeToSkip) 222 | if (!childMatch.done) { 223 | return { done: false, value: childMatch.value } 224 | } 225 | 226 | childMatches = null 227 | childIndex++ 228 | } 229 | } 230 | 231 | return { done: true, value: false } 232 | }, 233 | } 234 | } 235 | 236 | function resolveRoute( 237 | context: RouteContext, 238 | params: RouteParams, 239 | ): RouteResultSync { 240 | if (typeof context.route.action === 'function') { 241 | return context.route.action(context, params) 242 | } 243 | return undefined 244 | } 245 | 246 | function isChildRoute( 247 | parentRoute: Route | false, 248 | childRoute: Route, 249 | ): boolean { 250 | let route: Route | null | undefined = childRoute 251 | while (route) { 252 | route = route.parent 253 | if (route === parentRoute) { 254 | return true 255 | } 256 | } 257 | return false 258 | } 259 | 260 | class UniversalRouterSync { 261 | root: Route 262 | 263 | baseUrl: string 264 | 265 | options: RouterOptions 266 | 267 | constructor( 268 | routes: Routes | Route, 269 | options?: RouterOptions, 270 | ) { 271 | if (!routes || typeof routes !== 'object') { 272 | throw new TypeError('Invalid routes') 273 | } 274 | 275 | this.options = { decode, ...options } 276 | this.baseUrl = this.options.baseUrl || '' 277 | this.root = Array.isArray(routes) 278 | ? { path: '', children: routes, parent: null } 279 | : routes 280 | this.root.parent = null 281 | } 282 | 283 | /** 284 | * Traverses the list of routes in the order they are defined until it finds 285 | * the first route that matches provided URL path string and whose action function 286 | * returns anything other than `null` or `undefined`. 287 | */ 288 | resolve(pathnameOrContext: string | ResolveContext): RouteResultSync { 289 | const context: ResolveContext = { 290 | router: this, 291 | ...this.options.context, 292 | ...(typeof pathnameOrContext === 'string' 293 | ? { pathname: pathnameOrContext } 294 | : pathnameOrContext), 295 | } 296 | const matchResult = matchRoute( 297 | this.root, 298 | this.baseUrl, 299 | this.options, 300 | context.pathname.substr(this.baseUrl.length), 301 | ) 302 | const resolve = this.options.resolveRoute || resolveRoute 303 | let matches: IteratorResult, false> 304 | let nextMatches: IteratorResult, false> | null 305 | let currentContext = context 306 | 307 | function next( 308 | resume: boolean, 309 | parent: Route | false = !matches.done && matches.value.route, 310 | prevResult?: RouteResultSync, 311 | ): RouteResultSync { 312 | const routeToSkip = 313 | prevResult === null && !matches.done && matches.value.route 314 | matches = nextMatches || matchResult.next(routeToSkip) 315 | nextMatches = null 316 | 317 | if (!resume) { 318 | if (matches.done || !isChildRoute(parent, matches.value.route)) { 319 | nextMatches = matches 320 | return null 321 | } 322 | } 323 | 324 | if (matches.done) { 325 | const error: RouteError = new Error('Route not found') 326 | error.status = 404 327 | throw error 328 | } 329 | 330 | currentContext = { ...context, ...matches.value } 331 | 332 | const result = resolve( 333 | currentContext as RouteContext, 334 | matches.value.params, 335 | ) 336 | if (result !== null && result !== undefined) { 337 | return result 338 | } 339 | return next(resume, parent, result) 340 | } 341 | 342 | context['next'] = next 343 | 344 | try { 345 | return next(true, this.root) 346 | } catch (error) { 347 | if (this.options.errorHandler) { 348 | return this.options.errorHandler(error as RouteError, currentContext) 349 | } 350 | throw error 351 | } 352 | } 353 | } 354 | 355 | export default UniversalRouterSync 356 | -------------------------------------------------------------------------------- /src/universal-router.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { describe, test, expect, vi, type Mock } from 'vitest' 11 | import UniversalRouter, { Route } from './universal-router' 12 | import type { RouteError } from './universal-router' 13 | 14 | describe('UniversalRouterSync', () => { 15 | test('requires routes', () => { 16 | // @ts-expect-error missing argument 17 | expect(() => new UniversalRouter()).toThrow(/Invalid routes/) 18 | // @ts-expect-error wrong argument 19 | expect(() => new UniversalRouter(12)).toThrow(/Invalid routes/) 20 | // @ts-expect-error wrong argument 21 | expect(() => new UniversalRouter(null)).toThrow(/Invalid routes/) 22 | }) 23 | 24 | test('supports custom route resolver', async () => { 25 | const resolveRoute: Mock = vi.fn((context) => context.route.component) 26 | const action: Mock = vi.fn() 27 | const router = new UniversalRouter( 28 | { 29 | path: '/a', 30 | action, 31 | children: [ 32 | { path: '/:b', component: null, action } as Route, 33 | { path: '/c', component: 'c', action } as Route, 34 | { path: '/d', component: 'd', action } as Route, 35 | ], 36 | }, 37 | { resolveRoute }, 38 | ) 39 | await expect(router.resolve('/a/c')).resolves.toBe('c') 40 | expect(resolveRoute.mock.calls.length).toBe(3) 41 | expect(action.mock.calls.length).toBe(0) 42 | }) 43 | 44 | test('supports custom error handler', async () => { 45 | const errorHandler: Mock = vi.fn(() => 'result') 46 | const router = new UniversalRouter([], { errorHandler }) 47 | await expect(router.resolve('/')).resolves.toBe('result') 48 | expect(errorHandler.mock.calls.length).toBe(1) 49 | const error = errorHandler.mock.calls[0]?.[0] 50 | const context = errorHandler.mock.calls[0]?.[1] 51 | expect(error).toBeInstanceOf(Error) 52 | expect(error.message).toBe('Route not found') 53 | expect(error.status).toBe(404) 54 | expect(context.pathname).toBe('/') 55 | expect(context.router).toBe(router) 56 | }) 57 | 58 | test('handles route errors', async () => { 59 | const errorHandler: Mock = vi.fn(() => 'result') 60 | const route = { 61 | path: '/', 62 | action: (): never => { 63 | throw new Error('custom') 64 | }, 65 | } 66 | const router = new UniversalRouter(route, { errorHandler }) 67 | await expect(router.resolve('/')).resolves.toBe('result') 68 | expect(errorHandler.mock.calls.length).toBe(1) 69 | const error = errorHandler.mock.calls[0]?.[0] 70 | const context = errorHandler.mock.calls[0]?.[1] 71 | expect(error).toBeInstanceOf(Error) 72 | expect(error.message).toBe('custom') 73 | expect(context.pathname).toBe('/') 74 | expect(context.path).toBe('/') 75 | expect(context.router).toBe(router) 76 | expect(context.route).toBe(route) 77 | }) 78 | 79 | test('throws when route not found', async () => { 80 | const router = new UniversalRouter([]) 81 | let err 82 | try { 83 | await router.resolve('/') 84 | } catch (e) { 85 | err = e as RouteError 86 | } 87 | expect(err).toBeInstanceOf(Error) 88 | expect(err?.message).toBe('Route not found') 89 | expect(err?.status).toBe(404) 90 | }) 91 | 92 | test("executes the matching route's action method and return its result", async () => { 93 | const action: Mock = vi.fn(() => 'b') 94 | const router = new UniversalRouter({ path: '/a', action }) 95 | await expect(router.resolve('/a')).resolves.toBe('b') 96 | expect(action.mock.calls.length).toBe(1) 97 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 98 | }) 99 | 100 | test('finds the first route whose action method !== undefined or null', async () => { 101 | const action1: Mock = vi.fn(() => undefined) 102 | const action2: Mock = vi.fn(() => null) 103 | const action3: Mock = vi.fn(() => 'c') 104 | const action4: Mock = vi.fn(() => 'd') 105 | const router = new UniversalRouter([ 106 | { path: '/a', action: action1 }, 107 | { path: '/a', action: action2 }, 108 | { path: '/a', action: action3 }, 109 | { path: '/a', action: action4 }, 110 | ]) 111 | await expect(router.resolve('/a')).resolves.toBe('c') 112 | expect(action1.mock.calls.length).toBe(1) 113 | expect(action2.mock.calls.length).toBe(1) 114 | expect(action3.mock.calls.length).toBe(1) 115 | expect(action4.mock.calls.length).toBe(0) 116 | }) 117 | 118 | test('allows to pass context variables to action methods', async () => { 119 | const action: Mock = vi.fn(() => true) 120 | const router = new UniversalRouter([{ path: '/a', action }]) 121 | await expect(router.resolve({ pathname: '/a', test: 'b' })).resolves.toBe( 122 | true, 123 | ) 124 | expect(action.mock.calls.length).toBe(1) 125 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 126 | expect(action.mock.calls[0]?.[0]).toHaveProperty('test', 'b') 127 | }) 128 | 129 | test('skips action methods of routes that do not match the URL path', async () => { 130 | const action: Mock = vi.fn() 131 | const router = new UniversalRouter([{ path: '/a', action }]) 132 | let err 133 | try { 134 | await router.resolve('/b') 135 | } catch (e) { 136 | err = e as RouteError 137 | } 138 | expect(err).toBeInstanceOf(Error) 139 | expect(err?.message).toBe('Route not found') 140 | expect(err?.status).toBe(404) 141 | expect(action.mock.calls.length).toBe(0) 142 | }) 143 | 144 | test('supports asynchronous route actions', async () => { 145 | const router = new UniversalRouter([{ path: '/a', action: () => 'b' }]) 146 | await expect(router.resolve('/a')).resolves.toBe('b') 147 | }) 148 | 149 | test('captures URL parameters to context.params', async () => { 150 | const action: Mock = vi.fn(() => true) 151 | const router = new UniversalRouter([{ path: '/:one/:two', action }]) 152 | await expect(router.resolve({ pathname: '/a/b' })).resolves.toBe(true) 153 | expect(action.mock.calls.length).toBe(1) 154 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params', { 155 | one: 'a', 156 | two: 'b', 157 | }) 158 | }) 159 | 160 | test('provides all URL parameters to each route', async () => { 161 | const action1: Mock = vi.fn() 162 | const action2: Mock = vi.fn(() => true) 163 | const router = new UniversalRouter([ 164 | { 165 | path: '/:one', 166 | action: action1, 167 | children: [ 168 | { 169 | path: '/:two', 170 | action: action2, 171 | }, 172 | ], 173 | }, 174 | ]) 175 | await expect(router.resolve({ pathname: '/a/b' })).resolves.toBe(true) 176 | expect(action1.mock.calls.length).toBe(1) 177 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 178 | expect(action2.mock.calls.length).toBe(1) 179 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { 180 | one: 'a', 181 | two: 'b', 182 | }) 183 | }) 184 | 185 | test('overrides URL parameters with same name in child routes', async () => { 186 | const action1: Mock = vi.fn() 187 | const action2: Mock = vi.fn(() => true) 188 | const router = new UniversalRouter([ 189 | { 190 | path: '/:one', 191 | action: action1, 192 | children: [ 193 | { 194 | path: '/:one', 195 | action: action1, 196 | }, 197 | { 198 | path: '/:two', 199 | action: action2, 200 | }, 201 | ], 202 | }, 203 | ]) 204 | await expect(router.resolve({ pathname: '/a/b' })).resolves.toBe(true) 205 | expect(action1.mock.calls.length).toBe(2) 206 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 207 | expect(action1.mock.calls[1]?.[0]).toHaveProperty('params', { one: 'b' }) 208 | expect(action2.mock.calls.length).toBe(1) 209 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { 210 | one: 'a', 211 | two: 'b', 212 | }) 213 | }) 214 | 215 | test('does not collect parameters from previous routes', async () => { 216 | const action1: Mock = vi.fn(() => undefined) 217 | const action2: Mock = vi.fn(() => undefined) 218 | const action3: Mock = vi.fn(() => true) 219 | const router = new UniversalRouter([ 220 | { 221 | path: '/:one', 222 | action: action1, 223 | children: [ 224 | { 225 | path: '/:two', 226 | action: action1, 227 | }, 228 | ], 229 | }, 230 | { 231 | path: '/:three', 232 | action: action2, 233 | children: [ 234 | { 235 | path: '/:four', 236 | action: action2, 237 | }, 238 | { 239 | path: '/:five', 240 | action: action3, 241 | }, 242 | ], 243 | }, 244 | ]) 245 | await expect(router.resolve({ pathname: '/a/b' })).resolves.toBe(true) 246 | expect(action1.mock.calls.length).toBe(2) 247 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('params', { one: 'a' }) 248 | expect(action1.mock.calls[1]?.[0]).toHaveProperty('params', { 249 | one: 'a', 250 | two: 'b', 251 | }) 252 | expect(action2.mock.calls.length).toBe(2) 253 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('params', { three: 'a' }) 254 | expect(action2.mock.calls[1]?.[0]).toHaveProperty('params', { 255 | three: 'a', 256 | four: 'b', 257 | }) 258 | expect(action3.mock.calls.length).toBe(1) 259 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('params', { 260 | three: 'a', 261 | five: 'b', 262 | }) 263 | }) 264 | 265 | test('supports next() across multiple routes', async () => { 266 | const log: number[] = [] 267 | const router = new UniversalRouter([ 268 | { 269 | path: '/test', 270 | children: [ 271 | { 272 | path: '', 273 | action(): void { 274 | log.push(2) 275 | }, 276 | children: [ 277 | { 278 | path: '', 279 | action({ next }): Promise { 280 | log.push(3) 281 | return next().then(() => { 282 | log.push(6) 283 | }) 284 | }, 285 | children: [ 286 | { 287 | path: '', 288 | action({ next }): Promise { 289 | log.push(4) 290 | return next().then(() => { 291 | log.push(5) 292 | }) 293 | }, 294 | }, 295 | ], 296 | }, 297 | ], 298 | }, 299 | { 300 | path: '', 301 | action(): void { 302 | log.push(7) 303 | }, 304 | children: [ 305 | { 306 | path: '', 307 | action(): void { 308 | log.push(8) 309 | }, 310 | }, 311 | { 312 | path: '', 313 | action(): void { 314 | log.push(9) 315 | }, 316 | }, 317 | ], 318 | }, 319 | ], 320 | async action({ next }) { 321 | log.push(1) 322 | const result = await next() 323 | log.push(10) 324 | return result 325 | }, 326 | }, 327 | { 328 | path: '/:id', 329 | action(): void { 330 | log.push(11) 331 | }, 332 | }, 333 | { 334 | path: '/test', 335 | action(): string { 336 | log.push(12) 337 | return 'done' 338 | }, 339 | }, 340 | { 341 | path: '/*all', 342 | action(): void { 343 | log.push(13) 344 | }, 345 | }, 346 | ]) 347 | 348 | await expect(router.resolve('/test')).resolves.toBe('done') 349 | expect(log).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) 350 | }) 351 | 352 | test('supports next(true) across multiple routes', async () => { 353 | const log: number[] = [] 354 | const router = new UniversalRouter({ 355 | action({ next }): Promise { 356 | log.push(1) 357 | return next().then((result) => { 358 | log.push(9) 359 | return result 360 | }) 361 | }, 362 | children: [ 363 | { 364 | path: '/a/b/c', 365 | action({ next }): Promise { 366 | log.push(2) 367 | return next(true).then((result) => { 368 | log.push(8) 369 | return result 370 | }) 371 | }, 372 | }, 373 | { 374 | path: '/a', 375 | action(): void { 376 | log.push(3) 377 | }, 378 | children: [ 379 | { 380 | path: '/b', 381 | action({ next }): Promise { 382 | log.push(4) 383 | return next().then((result) => { 384 | log.push(6) 385 | return result 386 | }) 387 | }, 388 | children: [ 389 | { 390 | path: '/c', 391 | action(): void { 392 | log.push(5) 393 | }, 394 | }, 395 | ], 396 | }, 397 | { 398 | path: '/b/c', 399 | action(): string { 400 | log.push(7) 401 | return 'done' 402 | }, 403 | }, 404 | ], 405 | }, 406 | ], 407 | }) 408 | 409 | await expect(router.resolve('/a/b/c')).resolves.toBe('done') 410 | expect(log).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]) 411 | }) 412 | 413 | test('supports parametrized routes', async () => { 414 | const action: Mock = vi.fn(() => true) 415 | const router = new UniversalRouter([{ path: '/path/:a/other/:b', action }]) 416 | await expect(router.resolve('/path/1/other/2')).resolves.toBe(true) 417 | expect(action.mock.calls.length).toBe(1) 418 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params.a', '1') 419 | expect(action.mock.calls[0]?.[0]).toHaveProperty('params.b', '2') 420 | expect(action.mock.calls[0]?.[1]).toHaveProperty('a', '1') 421 | expect(action.mock.calls[0]?.[1]).toHaveProperty('b', '2') 422 | }) 423 | 424 | test('supports nested routes (1)', async () => { 425 | const action1: Mock = vi.fn() 426 | const action2: Mock = vi.fn(() => true) 427 | const router = new UniversalRouter([ 428 | { 429 | path: '', 430 | action: action1, 431 | children: [ 432 | { 433 | path: '/a', 434 | action: action2, 435 | }, 436 | ], 437 | }, 438 | ]) 439 | 440 | await expect(router.resolve('/a')).resolves.toBe(true) 441 | expect(action1.mock.calls.length).toBe(1) 442 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '') 443 | expect(action2.mock.calls.length).toBe(1) 444 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 445 | }) 446 | 447 | test('supports nested routes (2)', async () => { 448 | const action1: Mock = vi.fn() 449 | const action2: Mock = vi.fn(() => true) 450 | const router = new UniversalRouter([ 451 | { 452 | path: '/a', 453 | action: action1, 454 | children: [ 455 | { 456 | path: '/b', 457 | action: action2, 458 | }, 459 | ], 460 | }, 461 | ]) 462 | 463 | await expect(router.resolve('/a/b')).resolves.toBe(true) 464 | expect(action1.mock.calls.length).toBe(1) 465 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 466 | expect(action2.mock.calls.length).toBe(1) 467 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/b') 468 | }) 469 | 470 | test('supports nested routes (3)', async () => { 471 | const action1: Mock = vi.fn(() => undefined) 472 | const action2: Mock = vi.fn(() => null) 473 | const action3: Mock = vi.fn(() => true) 474 | const router = new UniversalRouter([ 475 | { 476 | path: '/a', 477 | action: action1, 478 | children: [ 479 | { 480 | path: '/b', 481 | action: action2, 482 | }, 483 | ], 484 | }, 485 | { 486 | path: '/a/b', 487 | action: action3, 488 | }, 489 | ]) 490 | 491 | await expect(router.resolve('/a/b')).resolves.toBe(true) 492 | expect(action1.mock.calls.length).toBe(1) 493 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '') 494 | expect(action1.mock.calls[0]?.[0]).toHaveProperty('path', '/a') 495 | expect(action2.mock.calls.length).toBe(1) 496 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '/a') 497 | expect(action2.mock.calls[0]?.[0]).toHaveProperty('path', '/b') 498 | expect(action3.mock.calls.length).toBe(1) 499 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '') 500 | expect(action3.mock.calls[0]?.[0]).toHaveProperty('path', '/a/b') 501 | }) 502 | 503 | test('re-throws an error', async () => { 504 | const error = new Error('test error') 505 | const router = new UniversalRouter([ 506 | { 507 | path: '/a', 508 | action(): never { 509 | throw error 510 | }, 511 | }, 512 | ]) 513 | let err 514 | try { 515 | await router.resolve('/a') 516 | } catch (e) { 517 | err = e 518 | } 519 | expect(err).toBe(error) 520 | }) 521 | 522 | test('respects baseUrl', async () => { 523 | const action: Mock = vi.fn(() => 17) 524 | const routes = { 525 | path: '/a', 526 | children: [ 527 | { 528 | path: '/b', 529 | children: [{ path: '/c', action }], 530 | }, 531 | ], 532 | } 533 | const router = new UniversalRouter(routes, { baseUrl: '/base' }) 534 | await expect(router.resolve('/base/a/b/c')).resolves.toBe(17) 535 | expect(action.mock.calls.length).toBe(1) 536 | expect(action.mock.calls[0]?.[0]).toHaveProperty('pathname', '/base/a/b/c') 537 | expect(action.mock.calls[0]?.[0]).toHaveProperty('path', '/c') 538 | expect(action.mock.calls[0]?.[0]).toHaveProperty('baseUrl', '/base/a/b') 539 | expect(action.mock.calls[0]?.[0]).toHaveProperty( 540 | 'route', 541 | routes.children[0]?.children[0], 542 | ) 543 | expect(action.mock.calls[0]?.[0]).toHaveProperty('router', router) 544 | 545 | let err 546 | try { 547 | await router.resolve('/a/b/c') 548 | } catch (e) { 549 | err = e as RouteError 550 | } 551 | expect(action.mock.calls.length).toBe(1) 552 | expect(err).toBeInstanceOf(Error) 553 | expect(err?.message).toBe('Route not found') 554 | expect(err?.status).toBe(404) 555 | }) 556 | 557 | test('matches routes with trailing slashes', async () => { 558 | const router = new UniversalRouter([ 559 | { path: '/', action: (): string => 'a' }, 560 | { path: '/page/', action: (): string => 'b' }, 561 | { 562 | path: '/child', 563 | children: [ 564 | { path: '/', action: (): string => 'c' }, 565 | { path: '/page/', action: (): string => 'd' }, 566 | ], 567 | }, 568 | ]) 569 | await expect(router.resolve('/')).resolves.toBe('a') 570 | await expect(router.resolve('/page/')).resolves.toBe('b') 571 | await expect(router.resolve('/child/')).resolves.toBe('c') 572 | await expect(router.resolve('/child/page/')).resolves.toBe('d') 573 | }) 574 | 575 | test('skips nested routes when middleware route returns null', async () => { 576 | const middleware: Mock = vi.fn(() => null) 577 | const action: Mock = vi.fn(() => 'skipped') 578 | const router = new UniversalRouter([ 579 | { 580 | path: '/match', 581 | action: middleware, 582 | children: [{ action }], 583 | }, 584 | { 585 | path: '/match', 586 | action: (): number => 404, 587 | }, 588 | ]) 589 | 590 | await expect(router.resolve('/match')).resolves.toBe(404) 591 | expect(action.mock.calls.length).toBe(0) 592 | expect(middleware.mock.calls.length).toBe(1) 593 | }) 594 | 595 | test('matches nested routes when middleware route returns undefined', async () => { 596 | const middleware: Mock = vi.fn(() => undefined) 597 | const action: Mock = vi.fn(() => null) 598 | const router = new UniversalRouter([ 599 | { 600 | path: '/match', 601 | action: middleware, 602 | children: [{ action }], 603 | }, 604 | { 605 | path: '/match', 606 | action: (): number => 404, 607 | }, 608 | ]) 609 | 610 | await expect(router.resolve('/match')).resolves.toBe(404) 611 | expect(action.mock.calls.length).toBe(1) 612 | expect(middleware.mock.calls.length).toBe(1) 613 | }) 614 | 615 | test('handles route not found error correctly', async () => { 616 | const router = new UniversalRouter({ 617 | path: '/', 618 | action({ next }): unknown { 619 | return next() 620 | }, 621 | children: [{ path: '/child' }], 622 | }) 623 | 624 | let err 625 | try { 626 | await router.resolve('/404') 627 | } catch (e) { 628 | err = e as RouteError 629 | } 630 | expect(err).toBeInstanceOf(Error) 631 | expect(err?.message).toBe('Route not found') 632 | expect(err?.status).toBe(404) 633 | }) 634 | 635 | test('handles malformed URI params', async () => { 636 | const router = new UniversalRouter({ 637 | path: '/:a', 638 | action: (ctx): object => ctx.params, 639 | }) 640 | await expect(router.resolve('/%AF')).resolves.toStrictEqual({ a: '%AF' }) 641 | }) 642 | 643 | test('decodes params correctly', async () => { 644 | const router = new UniversalRouter({ 645 | path: '/:a/:b/:c', 646 | action: (ctx): object => ctx.params, 647 | }) 648 | await expect(router.resolve('/%2F/%3A/caf%C3%A9')).resolves.toStrictEqual({ 649 | a: '/', 650 | b: ':', 651 | c: 'café', 652 | }) 653 | }) 654 | 655 | test('decodes repeated parameters correctly', async () => { 656 | const router = new UniversalRouter({ 657 | path: '/*a', 658 | action: (ctx): object => ctx.params, 659 | }) 660 | await expect(router.resolve('/x%2Fy/z/%20/%AF')).resolves.toStrictEqual({ 661 | a: ['x/y', 'z', ' ', '%AF'], 662 | }) 663 | }) 664 | 665 | test('matches 0 routes (1)', async () => { 666 | const action: Mock = vi.fn(() => true) 667 | const route = { path: '/', action } 668 | await expect(new UniversalRouter(route).resolve('/a')).rejects.toThrow( 669 | /Route not found/, 670 | ) 671 | expect(action.mock.calls.length).toBe(0) 672 | }) 673 | 674 | test('matches 0 routes (2)', async () => { 675 | const action: Mock = vi.fn(() => true) 676 | const route = { path: '/a', action } 677 | await expect(new UniversalRouter(route).resolve('/')).rejects.toThrow( 678 | /Route not found/, 679 | ) 680 | expect(action.mock.calls.length).toBe(0) 681 | }) 682 | 683 | test('matches 0 routes (3)', async () => { 684 | const action: Mock = vi.fn(() => true) 685 | const route = { path: '/a', action, children: [{ path: '/b', action }] } 686 | await expect(new UniversalRouter(route).resolve('/b')).rejects.toThrow( 687 | /Route not found/, 688 | ) 689 | expect(action.mock.calls.length).toBe(0) 690 | }) 691 | 692 | test('matches 0 routes (4)', async () => { 693 | const action: Mock = vi.fn(() => true) 694 | const route = { path: 'a', action, children: [{ path: 'b', action }] } 695 | await expect(new UniversalRouter(route).resolve('ab')).rejects.toThrow( 696 | /Route not found/, 697 | ) 698 | expect(action.mock.calls.length).toBe(0) 699 | }) 700 | 701 | test('matches 0 routes (5)', async () => { 702 | const action: Mock = vi.fn(() => true) 703 | const route = { action } 704 | await expect(new UniversalRouter(route).resolve('/a')).rejects.toThrow( 705 | /Route not found/, 706 | ) 707 | expect(action.mock.calls.length).toBe(0) 708 | }) 709 | 710 | test('matches 0 routes (6)', async () => { 711 | const action: Mock = vi.fn(() => true) 712 | const route = { path: '/', action } 713 | await expect(new UniversalRouter(route).resolve('')).rejects.toThrow( 714 | /Route not found/, 715 | ) 716 | expect(action.mock.calls.length).toBe(0) 717 | }) 718 | 719 | test('matches 0 routes (7)', async () => { 720 | const action: Mock = vi.fn(() => true) 721 | const route = { path: '/*a', action, children: [] } 722 | await expect(new UniversalRouter(route).resolve('')).rejects.toThrow( 723 | /Route not found/, 724 | ) 725 | expect(action.mock.calls.length).toBe(0) 726 | }) 727 | 728 | test('matches 1 route (1)', async () => { 729 | const action: Mock = vi.fn(() => true) 730 | const route = { 731 | path: '/', 732 | action, 733 | } 734 | await expect(new UniversalRouter(route).resolve('/')).resolves.toBe(true) 735 | expect(action.mock.calls.length).toBe(1) 736 | const context = action.mock.calls[0]?.[0] 737 | expect(context).toHaveProperty('baseUrl', '') 738 | expect(context).toHaveProperty('path', '/') 739 | expect(context).toHaveProperty('route.path', '/') 740 | }) 741 | 742 | test('matches 1 route (2)', async () => { 743 | const action: Mock = vi.fn(() => true) 744 | const route = { 745 | path: '/a', 746 | action, 747 | } 748 | await expect(new UniversalRouter(route).resolve('/a')).resolves.toBe(true) 749 | expect(action.mock.calls.length).toBe(1) 750 | const context = action.mock.calls[0]?.[0] 751 | expect(context).toHaveProperty('baseUrl', '') 752 | expect(context).toHaveProperty('path', '/a') 753 | expect(context).toHaveProperty('route.path', '/a') 754 | }) 755 | 756 | test('matches 2 routes (1)', async () => { 757 | const action: Mock = vi.fn(() => undefined) 758 | const route = { 759 | path: '', 760 | action, 761 | children: [ 762 | { 763 | path: '/a', 764 | action, 765 | }, 766 | ], 767 | } 768 | await expect(new UniversalRouter(route).resolve('/a')).rejects.toThrow( 769 | /Route not found/, 770 | ) 771 | expect(action.mock.calls.length).toBe(2) 772 | const context1 = action.mock.calls[0]?.[0] 773 | expect(context1).toHaveProperty('baseUrl', '') 774 | expect(context1).toHaveProperty('path', '') 775 | expect(context1).toHaveProperty('route.path', '') 776 | const context2 = action.mock.calls[1]?.[0] 777 | expect(context2).toHaveProperty('baseUrl', '') 778 | expect(context2).toHaveProperty('path', '/a') 779 | expect(context2).toHaveProperty('route.path', '/a') 780 | }) 781 | 782 | test('matches 2 routes (2)', async () => { 783 | const action: Mock = vi.fn(() => undefined) 784 | const route = { 785 | path: '/a', 786 | action, 787 | children: [ 788 | { 789 | path: '/b', 790 | action, 791 | children: [ 792 | { 793 | path: '/c', 794 | action, 795 | }, 796 | ], 797 | }, 798 | ], 799 | } 800 | await expect(new UniversalRouter(route).resolve('/a/b/c')).rejects.toThrow( 801 | /Route not found/, 802 | ) 803 | expect(action.mock.calls.length).toBe(3) 804 | const context1 = action.mock.calls[0]?.[0] 805 | expect(context1).toHaveProperty('baseUrl', '') 806 | expect(context1).toHaveProperty('route.path', '/a') 807 | const context2 = action.mock.calls[1]?.[0] 808 | expect(context2).toHaveProperty('baseUrl', '/a') 809 | expect(context2).toHaveProperty('route.path', '/b') 810 | const context3 = action.mock.calls[2]?.[0] 811 | expect(context3).toHaveProperty('baseUrl', '/a/b') 812 | expect(context3).toHaveProperty('route.path', '/c') 813 | }) 814 | 815 | test('matches 2 routes (3)', async () => { 816 | const action: Mock = vi.fn(() => undefined) 817 | const route = { 818 | path: '', 819 | action, 820 | children: [ 821 | { 822 | path: '', 823 | action, 824 | }, 825 | ], 826 | } 827 | await expect(new UniversalRouter(route).resolve('/')).rejects.toThrow( 828 | /Route not found/, 829 | ) 830 | expect(action.mock.calls.length).toBe(2) 831 | const context1 = action.mock.calls[0]?.[0] 832 | expect(context1).toHaveProperty('baseUrl', '') 833 | expect(context1).toHaveProperty('route.path', '') 834 | const context2 = action.mock.calls[1]?.[0] 835 | expect(context2).toHaveProperty('baseUrl', '') 836 | expect(context2).toHaveProperty('route.path', '') 837 | }) 838 | 839 | test('matches an array of paths', async () => { 840 | const action: Mock = vi.fn(() => true) 841 | const route = { path: ['/e', '/f'], action } 842 | await expect(new UniversalRouter(route).resolve('/f')).resolves.toBe(true) 843 | expect(action.mock.calls.length).toBe(1) 844 | const context = action.mock.calls[0]?.[0] 845 | expect(context).toHaveProperty('baseUrl', '') 846 | expect(context).toHaveProperty('route.path', ['/e', '/f']) 847 | }) 848 | }) 849 | -------------------------------------------------------------------------------- /src/universal-router.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { match } from './path-to-regexp.js' 11 | import type { 12 | Path, 13 | Match, 14 | MatchFunction, 15 | ParseOptions, 16 | MatchOptions, 17 | PathToRegexpOptions, 18 | CompileOptions, 19 | } from './path-to-regexp.js' 20 | 21 | /** 22 | * In addition to a URL path string, any arbitrary data can be passed to 23 | * the `router.resolve()` method, that becomes available inside action functions. 24 | */ 25 | export interface RouterContext { 26 | [propName: string]: any 27 | } 28 | 29 | export interface ResolveContext extends RouterContext { 30 | /** 31 | * URL which was transmitted to `router.resolve()`. 32 | */ 33 | pathname: string 34 | } 35 | 36 | /** 37 | * Params is a key/value object that represents extracted URL parameters. 38 | */ 39 | export interface RouteParams { 40 | [paramName: string]: string | string[] 41 | } 42 | 43 | export type RouteResult = 44 | | T 45 | | null 46 | | undefined 47 | | Promise 48 | 49 | export interface RouteContext 50 | extends ResolveContext { 51 | /** 52 | * Current router instance. 53 | */ 54 | router: UniversalRouter 55 | /** 56 | * Matched route object. 57 | */ 58 | route: Route 59 | /** 60 | * Base URL path relative to the path of the current route. 61 | */ 62 | baseUrl: string 63 | /** 64 | * Matched path. 65 | */ 66 | path: string 67 | /** 68 | * Matched path params. 69 | */ 70 | params: RouteParams 71 | /** 72 | * Middleware style function which can continue resolving. 73 | */ 74 | next: (resume?: boolean) => Promise 75 | } 76 | 77 | /** 78 | * A Route is a singular route in your application. It contains a path, an 79 | * action function, and optional children which are an array of Route. 80 | * @template C User context that is made union with RouterContext. 81 | * @template R Result that every action function resolves to. 82 | * If the action returns a Promise, R can be the type the Promise resolves to. 83 | */ 84 | export interface Route { 85 | /** 86 | * A string, array of strings, or a regular expression. Defaults to an empty string. 87 | */ 88 | path?: Path | Path[] 89 | /** 90 | * A unique string that can be used to generate the route URL. 91 | */ 92 | name?: string 93 | /** 94 | * The link to the parent route is automatically populated by the router. Useful for breadcrumbs. 95 | */ 96 | parent?: Route | null 97 | /** 98 | * An array of Route objects. Nested routes are perfect to be used in middleware routes. 99 | */ 100 | children?: Routes | null 101 | /** 102 | * Action method should return anything except `null` or `undefined` to be resolved by router 103 | * otherwise router will throw `Page not found` error if all matched routes returned nothing. 104 | */ 105 | action?: (context: RouteContext, params: RouteParams) => RouteResult 106 | /** 107 | * The route path match function. Used for internal caching. 108 | */ 109 | match?: MatchFunction 110 | } 111 | 112 | /** 113 | * Routes is an array of type Route. 114 | * @template C User context that is made union with RouterContext. 115 | * @template R Result that every action function resolves to. 116 | * If the action returns a Promise, R can be the type the Promise resolves to. 117 | */ 118 | export type Routes = Route< 119 | R, 120 | C 121 | >[] 122 | 123 | export type ResolveRoute = ( 124 | context: RouteContext, 125 | params: RouteParams, 126 | ) => RouteResult 127 | 128 | export type RouteError = Error & { status?: number } 129 | 130 | export type ErrorHandler = ( 131 | error: RouteError, 132 | context: ResolveContext, 133 | ) => RouteResult 134 | 135 | export interface RouterOptions 136 | extends ParseOptions, 137 | MatchOptions, 138 | PathToRegexpOptions, 139 | CompileOptions { 140 | context?: C 141 | baseUrl?: string 142 | resolveRoute?: ResolveRoute 143 | errorHandler?: ErrorHandler 144 | } 145 | 146 | export interface RouteMatch { 147 | route: Route 148 | baseUrl: string 149 | path: string 150 | params: RouteParams 151 | } 152 | 153 | function decode(val: string): string { 154 | try { 155 | return decodeURIComponent(val) 156 | } catch { 157 | return val 158 | } 159 | } 160 | 161 | function matchRoute( 162 | route: Route, 163 | baseUrl: string, 164 | options: RouterOptions, 165 | pathname: string, 166 | parentParams?: RouteParams, 167 | ): Iterator, false, Route | false> { 168 | let matchResult: Match 169 | let childMatches: Iterator< 170 | RouteMatch, 171 | false, 172 | Route | false 173 | > | null 174 | let childIndex = 0 175 | 176 | return { 177 | next( 178 | routeToSkip: Route | false, 179 | ): IteratorResult, false> { 180 | if (route === routeToSkip) { 181 | return { done: true, value: false } 182 | } 183 | 184 | if (!matchResult) { 185 | const rt = route 186 | const end = !rt.children 187 | if (!rt.match) { 188 | rt.match = match(rt.path || '', { end, ...options }) 189 | } 190 | matchResult = rt.match(pathname) 191 | 192 | if (matchResult) { 193 | const { path } = matchResult 194 | matchResult.path = 195 | !end && path.charAt(path.length - 1) === '/' ? path.substr(1) : path 196 | matchResult.params = { ...parentParams, ...matchResult.params } 197 | return { 198 | done: false, 199 | value: { 200 | route, 201 | baseUrl, 202 | path: matchResult.path, 203 | params: matchResult.params, 204 | }, 205 | } 206 | } 207 | } 208 | 209 | if (matchResult && route.children) { 210 | while (childIndex < route.children.length) { 211 | if (!childMatches) { 212 | const childRoute = route.children[childIndex]! 213 | childRoute.parent = route 214 | 215 | childMatches = matchRoute( 216 | childRoute, 217 | baseUrl + matchResult.path, 218 | options, 219 | pathname.substr(matchResult.path.length), 220 | matchResult.params, 221 | ) 222 | } 223 | 224 | const childMatch = childMatches.next(routeToSkip) 225 | if (!childMatch.done) { 226 | return { done: false, value: childMatch.value } 227 | } 228 | 229 | childMatches = null 230 | childIndex++ 231 | } 232 | } 233 | 234 | return { done: true, value: false } 235 | }, 236 | } 237 | } 238 | 239 | function resolveRoute( 240 | context: RouteContext, 241 | params: RouteParams, 242 | ): RouteResult { 243 | if (typeof context.route.action === 'function') { 244 | return context.route.action(context, params) 245 | } 246 | return undefined 247 | } 248 | 249 | function isChildRoute( 250 | parentRoute: Route | false, 251 | childRoute: Route, 252 | ): boolean { 253 | let route: Route | null | undefined = childRoute 254 | while (route) { 255 | route = route.parent 256 | if (route === parentRoute) { 257 | return true 258 | } 259 | } 260 | return false 261 | } 262 | 263 | class UniversalRouter { 264 | root: Route 265 | 266 | baseUrl: string 267 | 268 | options: RouterOptions 269 | 270 | constructor( 271 | routes: Routes | Route, 272 | options?: RouterOptions, 273 | ) { 274 | if (!routes || typeof routes !== 'object') { 275 | throw new TypeError('Invalid routes') 276 | } 277 | 278 | this.options = { decode, ...options } 279 | this.baseUrl = this.options.baseUrl || '' 280 | this.root = Array.isArray(routes) 281 | ? { path: '', children: routes, parent: null } 282 | : routes 283 | this.root.parent = null 284 | } 285 | 286 | /** 287 | * Traverses the list of routes in the order they are defined until it finds 288 | * the first route that matches provided URL path string and whose action function 289 | * returns anything other than `null` or `undefined`. 290 | */ 291 | resolve(pathnameOrContext: string | ResolveContext): Promise> { 292 | const context: ResolveContext = { 293 | router: this, 294 | ...this.options.context, 295 | ...(typeof pathnameOrContext === 'string' 296 | ? { pathname: pathnameOrContext } 297 | : pathnameOrContext), 298 | } 299 | const matchResult = matchRoute( 300 | this.root, 301 | this.baseUrl, 302 | this.options, 303 | context.pathname.substr(this.baseUrl.length), 304 | ) 305 | const resolve = this.options.resolveRoute || resolveRoute 306 | let matches: IteratorResult, false> 307 | let nextMatches: IteratorResult, false> | null 308 | let currentContext = context 309 | 310 | function next( 311 | resume: boolean, 312 | parent: Route | false = !matches.done && matches.value.route, 313 | prevResult?: RouteResult, 314 | ): Promise> { 315 | const routeToSkip = 316 | prevResult === null && !matches.done && matches.value.route 317 | matches = nextMatches || matchResult.next(routeToSkip) 318 | nextMatches = null 319 | 320 | if (!resume) { 321 | if (matches.done || !isChildRoute(parent, matches.value.route)) { 322 | nextMatches = matches 323 | return Promise.resolve(null) 324 | } 325 | } 326 | 327 | if (matches.done) { 328 | const error: RouteError = new Error('Route not found') 329 | error.status = 404 330 | return Promise.reject(error) 331 | } 332 | 333 | currentContext = { ...context, ...matches.value } 334 | 335 | return Promise.resolve( 336 | resolve(currentContext as RouteContext, matches.value.params), 337 | ).then((result) => { 338 | if (result !== null && result !== undefined) { 339 | return result 340 | } 341 | return next(resume, parent, result) 342 | }) 343 | } 344 | 345 | context['next'] = next 346 | 347 | return Promise.resolve() 348 | .then(() => next(true, this.root)) 349 | .catch((error: RouteError) => { 350 | if (this.options.errorHandler) { 351 | return this.options.errorHandler(error, currentContext) 352 | } 353 | throw error 354 | }) 355 | } 356 | } 357 | 358 | export default UniversalRouter 359 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # Build Automation Tools 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/kriasoft/universal-router/ci.yml)](https://github.com/kriasoft/universal-router/actions/workflows/ci.yml) 4 | [![Coverage Status](https://img.shields.io/codecov/c/github/kriasoft/universal-router.svg)](https://codecov.io/gh/kriasoft/universal-router) 5 | 6 | Compile the lib into the **./dist** folder: 7 | 8 | ```bash 9 | npm run build 10 | ``` 11 | 12 | Find problematic patterns in code using [ESLint](https://eslint.org/): 13 | 14 | ```bash 15 | npm run lint 16 | ``` 17 | 18 | Format the code using [Prettier](https://prettier.io/): 19 | 20 | ```bash 21 | npm run format 22 | ``` 23 | 24 | Run unit tests using [Vitest](https://vitest.dev/): 25 | 26 | ```bash 27 | npm run test 28 | ``` 29 | -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import fs from 'node:fs/promises' 11 | import cp from 'node:child_process' 12 | 13 | async function build() { 14 | try { 15 | // Clean up the output directory 16 | await fs.rm('dist', { recursive: true, force: true }) 17 | await fs.mkdir('dist', { recursive: true }) 18 | 19 | // Copy source code, readme and license 20 | await Promise.all([ 21 | fs.copyFile('README.md', 'dist/README.md'), 22 | fs.copyFile('LICENSE.txt', 'dist/LICENSE.txt'), 23 | ]) 24 | 25 | // Compile .ts to .js 26 | cp.execSync('tsc --project tools/tsconfig.esm.json', { stdio: 'inherit' }) 27 | cp.execSync('tsc --project tools/tsconfig.cjs.json', { stdio: 'inherit' }) 28 | 29 | // Create package.json file 30 | const pkg = await fs.readFile('package.json', 'utf8') 31 | await fs.writeFile( 32 | 'dist/package.json', 33 | JSON.stringify( 34 | { 35 | ...JSON.parse(pkg), 36 | private: undefined, 37 | scripts: undefined, 38 | devDependencies: undefined, 39 | }, 40 | null, 41 | 2, 42 | ), 43 | ) 44 | } catch (error) { 45 | console.error('Build failed:', error) 46 | process.exit(1) 47 | } 48 | } 49 | 50 | await build() 51 | -------------------------------------------------------------------------------- /tools/install.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Universal Router (https://www.kriasoft.com/universal-router/) 3 | * 4 | * Copyright (c) 2015-present Kriasoft. 5 | * 6 | * This source code is licensed under the MIT license found in the 7 | * LICENSE.txt file in the root directory of this source tree. 8 | */ 9 | 10 | import { writeFile, mkdir } from 'node:fs/promises' 11 | import { fileURLToPath } from 'node:url' 12 | import { dirname, resolve } from 'node:path' 13 | 14 | const [, , urlArg] = process.argv 15 | 16 | if (!urlArg) { 17 | console.error('Usage: node tools/install.js ') 18 | process.exit(1) 19 | } 20 | 21 | // We're downloading path-to-regexp directly from GitHub because the ESM version 22 | // is not available in the npm package. See the issue for more details: 23 | // https://github.com/pillarjs/path-to-regexp/issues/346 24 | 25 | const url = new URL(urlArg) 26 | const filename = 'src/path-to-regexp.ts' 27 | const outputPath = resolve( 28 | dirname(fileURLToPath(import.meta.url)), 29 | '../', 30 | filename, 31 | ) 32 | 33 | try { 34 | const res = await fetch(url) 35 | if (!res.ok) 36 | throw new Error(`Failed to fetch: ${res.status} ${res.statusText}`) 37 | 38 | const content = await res.text() 39 | const result = 40 | `/*! Path-to-RegExp | MIT License | https://github.com/pillarjs/path-to-regexp */` + 41 | `// @ts-nocheck\n\n${content}` 42 | 43 | await mkdir(dirname(outputPath), { recursive: true }) 44 | await writeFile(outputPath, result, 'utf8') 45 | } catch (err) { 46 | console.error(`Install failed: ${err.message}`) 47 | process.exit(1) 48 | } 49 | -------------------------------------------------------------------------------- /tools/tsconfig.cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5", 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "declaration": false, 8 | "sourceMap": false, 9 | "inlineSources": false, 10 | "outDir": "../dist/cjs" 11 | }, 12 | "exclude": ["../src/**/*.test.ts"] 13 | } 14 | -------------------------------------------------------------------------------- /tools/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "exclude": ["../src/**/*.test.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "module": "Preserve", 7 | "moduleResolution": "bundler", 8 | "strict": true, 9 | "declaration": true, 10 | "noEmitOnError": true, 11 | "noPropertyAccessFromIndexSignature": true, 12 | "noUncheckedIndexedAccess": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "sourceMap": true, 16 | "inlineSources": true 17 | }, 18 | "include": ["src"] 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | 4 | export default defineConfig({ 5 | test: { 6 | coverage: { 7 | include: ['src'], 8 | exclude: ['src/path-to-regexp.ts'], 9 | }, 10 | }, 11 | }) 12 | --------------------------------------------------------------------------------