├── .c8rc.json ├── .circleci └── config.yml ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── bench ├── README.md ├── express.js ├── http.js ├── next-connect.js ├── package.json ├── run.sh └── runall.sh ├── examples ├── nextjs-13 │ ├── .eslintrc.json │ ├── .gitignore │ ├── .vscode │ │ └── settings.json │ ├── README.md │ ├── next.config.js │ ├── package-lock.json │ ├── package.json │ ├── public │ │ ├── next.svg │ │ └── vercel.svg │ ├── src │ │ ├── app │ │ │ ├── api │ │ │ │ └── users │ │ │ │ │ ├── [id] │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ ├── favicon.ico │ │ │ ├── globals.css │ │ │ ├── layout.tsx │ │ │ ├── page.module.css │ │ │ └── page.tsx │ │ └── utils │ │ │ ├── api.ts │ │ │ ├── common.ts │ │ │ └── middleware.ts │ └── tsconfig.json └── nextjs │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── public │ ├── favicon.ico │ └── vercel.svg │ ├── src │ ├── middleware.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── api-routes.tsx │ │ ├── api │ │ │ ├── edge-users │ │ │ │ ├── [id].ts │ │ │ │ └── index.ts │ │ │ └── users │ │ │ │ ├── [id].ts │ │ │ │ └── index.ts │ │ ├── edge-api-routes.tsx │ │ ├── gssp-users │ │ │ ├── [id].tsx │ │ │ └── index.tsx │ │ └── index.tsx │ ├── styles │ │ ├── globals.css │ │ └── styles.module.css │ └── utils │ │ ├── api.ts │ │ ├── common.ts │ │ └── edge-api.ts │ └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── edge.ts ├── express.ts ├── index.ts ├── node.ts ├── regexparam.d.ts ├── router.ts └── types.ts ├── test ├── edge.test.ts ├── express.test.ts ├── index.test.ts ├── node.test.ts └── router.test.ts └── tsconfig.json /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*.ts"], 3 | "extension": ["ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | node: circleci/node@5.0.2 4 | codecov: codecov/codecov@3.2.3 5 | 6 | jobs: 7 | build_and_test: 8 | docker: 9 | - image: "cimg/base:stable" 10 | steps: 11 | - checkout 12 | - node/install: 13 | install-yarn: false 14 | node-version: "18.4" 15 | - node/install-packages: 16 | pkg-manager: npm 17 | - run: 18 | command: npm run test 19 | name: Run tests 20 | - run: 21 | command: npm run build 22 | name: Build app 23 | - run: 24 | command: npm run coverage 25 | name: Report coverage 26 | 27 | workflows: 28 | build_test_coverage: 29 | jobs: 30 | - build_and_test: 31 | post-steps: 32 | - codecov/upload 33 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.js] 9 | end_of_line = lf 10 | indent_style = space 11 | indent_size = 2 -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": "latest", 14 | "sourceType": "module" 15 | }, 16 | "ignorePatterns": ["dist"], 17 | "plugins": ["@typescript-eslint", "prettier"], 18 | "rules": { 19 | "prettier/prettier": "error", 20 | "@typescript-eslint/explicit-module-boundary-types": "off", 21 | "@typescript-eslint/ban-ts-comment": "warn", 22 | "@typescript-eslint/no-unused-vars": "error", 23 | "@typescript-eslint/consistent-type-imports": "error" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | /coverage.lcov 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (https://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # TypeScript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | # next.js build output 62 | .next 63 | 64 | dist/ 65 | 66 | */package-lock.json -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.preferences.importModuleSpecifierEnding": "js", 3 | "typescript.preferences.importModuleSpecifierEnding": "js" 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.0-next.4 4 | 5 | - fix: correct clone() type by @hoangvvo in https://github.com/hoangvvo/next-connect/commit/da2a5b19b471a8ff893825c6f7e03fba26617ed8 6 | 7 | ## 1.0.0-next.3 8 | 9 | - router class rearchitecture by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/207 10 | - feat: add expressWrapper util by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/208 11 | 12 | ## 1.0.0-next.2 13 | 14 | - Edge Runtime Router by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/200 15 | 16 | ## 1.0.0-next.1 17 | 18 | - security: always return generic 500 error by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/197 19 | - Always attach req.params by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/198 20 | - Add benchmark and update readme by @hoangvvo in https://github.com/hoangvvo/next-connect/pull/199 21 | 22 | ## 1.0.0-next.0 23 | 24 | See https://github.com/hoangvvo/next-connect/releases/tag/v1.0.0-next.0. 25 | 26 | ## 0.12.2 27 | 28 | - Add back next() in error handler 29 | 30 | ## 0.12.1 31 | 32 | - breaking: flow change: handle() does not use onError (ad857bedb0996312ad3a5ea966ce3a60417429a6) 33 | - fix: make sure handler is resolvable (#178) 34 | 35 | ## 0.11.1 36 | 37 | - Bump deps and switch to NPM 38 | - typescript: added export for types (#176) 39 | 40 | ## 0.11.0 41 | 42 | - Allow regular expressions to be used for route. (#157) 43 | 44 | ## 0.10.2 45 | 46 | - Export the options interface (#152) 47 | 48 | ## 0.10.1 49 | 50 | - Make NextConnect compatible with NextApiHandler (#128) 51 | - docs(README): fix typo (#123) 52 | - Mark sideEffects false and update README (21c9c73fe3746e66033fd51e2aa01d479e267ad6) 53 | 54 | ## 0.10.0 55 | 56 | - Express Router compatibility (#121) 57 | - Add ESModule export (#122) 58 | 59 | ## 0.9.1 60 | 61 | - Deprecate apply() for run() (#108) 62 | 63 | ## 0.9.0 64 | 65 | - Add all() to match any methods (#105) 66 | 67 | ## 0.8.1 68 | 69 | - Fix handler return type (#75) 70 | 71 | ## 0.8.0 72 | 73 | - Fix TypeScript signature and support both API and non-API pages (#70) (Breaking) 74 | 75 | ## 0.7.1 76 | 77 | - Call trouter#find with pathname (#62) 78 | 79 | ## 0.7.0 80 | 81 | - feat: add support for handler generics (#57) 82 | - Consider base when mounting subapp (#61) 83 | 84 | ## 0.6.6 85 | 86 | - Fix uncaught error in async middleware (#50) 87 | 88 | ## 0.6.5 89 | 90 | - Fix uncaught error in non-async functions (#45) 91 | 92 | ## 0.6.4 93 | 94 | - Add TypeScript definition (#44) 95 | 96 | ## 0.6.3 97 | 98 | - Refactor for performance (#42) 99 | 100 | ## 0.6.2 101 | 102 | - Fix catching async function error 103 | 104 | ## 0.6.1 105 | 106 | - Fix "API resolved without sending a response" 107 | - Handle error properly in .apply 108 | 109 | ## 0.6.0 110 | 111 | - Use Trouter (#25) 112 | - Add onError and onNoMatch (#26) 113 | 114 | ### Breaking changes 115 | 116 | Error middleware (`.use(err, req, res, next)` and `.error(err, req, res)`) is deprecated. Use `options.onError` instead. 117 | 118 | ## 0.5.2 119 | 120 | - Fix cleared stack (#23) 121 | 122 | ## 0.5.1 123 | 124 | - Fix next-connect fail to work if multiple instances are used (015aa37bdd6ba9b50a97cf9d6c8eebc25f111fd0) 125 | 126 | ## 0.5.0 127 | 128 | - Rewrite (Optimize codebase) and allow multiple handles in use() and error() (#13) 129 | - Update README.md (6ef206903393d37c7f34f05005ff97738695b9b3) 130 | 131 | ## 0.4.0 132 | 133 | - Add support for non-api pages (#11) 134 | 135 | ## 0.3.0 136 | 137 | - Enable reusing middleware (#8) 138 | 139 | ## 0.2.0 140 | 141 | - Render 404 when headers are not sent (No response) (#7) 142 | - Add other HTTP methods (#6) 143 | 144 | ## 0.1.0 145 | 146 | - Rewrite core (#3) 147 | 148 | We can now `default export` `handler` instead of `handler.export()` 149 | 150 | - Improve readme (#2) 151 | 152 | ## 0.0.1 153 | 154 | - Initial commit 155 | - Add Test and CircleCI (#1) 156 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to next-connect 2 | 3 | :+1::tada: Thank you for being here. It is people like you that make `next-connect` great and help shape a better open-source community. 4 | 5 | Following this guideline improves communication and organization, which helps save your and other developers' times and effort in future development. 6 | 7 | ## What `next-connect` is looking for 8 | 9 | I welcome all contributions from the community. There are many ways to contribute: 10 | 11 | - :art: Submit PR to fix bugs, enhance/add existed/new features. 12 | - :children_crossing: Submit bug reports and feature requests. 13 | - :pencil: Improve documentation and writing examples. 14 | 15 | ## How to contribute 16 | 17 | ### Bug reports 18 | 19 | If you are submitting a :bug: bug report, please: 20 | 21 | - Use a clear and descriptive title. Describe the behavior you observed and the expected behavior. 22 | - Describe the exact steps which reproduce the problem. A minimal reproduction repo is greatly appreciated. 23 | - Include Node version, OS, or other information that may be helpful in the troubleshooting. 24 | 25 | ### Process on submitting a PR 26 | 27 | _Generally, all pull requests should have references to an issue._ 28 | 29 | If you are :sparkles: **adding a new feature** or :zap: **improving an algorithm**, please first [create an issue](../../issues/new) for discussion. 30 | 31 | The steps to submit a PR are: 32 | 33 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. 34 | 35 | 2. Install all dependencies and dev dependencies by `npm install`. 36 | 37 | 3. Make changes and commit (following [commit message styleguides](#commit-message)). 38 | 39 | 4. Make sure your code is linted by running `npm run lint`. 40 | 41 | 5. [Create a pull request](https://help.github.com/en/articles/creating-a-pull-request-from-a-fork) 42 | 43 | ## Styleguides 44 | 45 | ### Javascript style 46 | 47 | Please run `npm run lint` and fix any linting warnings. 48 | 49 | ### Commit message 50 | 51 | - Use the present tense and imperative mood ("Add feature" instead of "Adds feature" or "Added feature") 52 | 53 | :heart: Thank you, 54 | Hoang Vo 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Hoang Vo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-connect 2 | 3 | [![npm](https://badgen.net/npm/v/next-connect)](https://www.npmjs.com/package/next-connect) 4 | [![CircleCI](https://circleci.com/gh/hoangvvo/next-connect.svg?style=svg)](https://circleci.com/gh/hoangvvo/next-connect) 5 | [![codecov](https://codecov.io/gh/hoangvvo/next-connect/branch/main/graph/badge.svg?token=LGIyl3Ti4H)](https://codecov.io/gh/hoangvvo/next-connect) 6 | [![minified size](https://badgen.net/bundlephobia/min/next-connect)](https://bundlephobia.com/result?p=next-connect) 7 | [![download/year](https://badgen.net/npm/dy/next-connect)](https://www.npmjs.com/package/next-connect) 8 | 9 | The promise-based method routing and middleware layer for [Next.js](https://nextjs.org/) [API Routes](#nextjs-api-routes), [Edge API Routes](#nextjs-edge-api-routes), [Middleware](#nextjs-middleware), [Next.js App Router](#nextjs-app-router), and [getServerSideProps](#nextjs-getserversideprops). 10 | 11 | ## Features 12 | 13 | - Async middleware 14 | - [Lightweight](https://bundlephobia.com/scan-results?packages=express,next-connect,koa,micro) => Suitable for serverless environment 15 | - [way faster](https://github.com/hoangvvo/next-connect/tree/main/bench) than Express.js. Compatible with Express.js via [a wrapper](#expressjs-compatibility). 16 | - Works with async handlers (with error catching) 17 | - TypeScript support 18 | 19 | ## Installation 20 | 21 | ```sh 22 | npm install next-connect@next 23 | ``` 24 | 25 | ## Usage 26 | 27 | Also check out the [examples](./examples/) folder. 28 | 29 | ### Next.js API Routes 30 | 31 | `next-connect` can be used in [API Routes](https://nextjs.org/docs/api-routes/introduction). 32 | 33 | ```typescript 34 | // pages/api/user/[id].ts 35 | import type { NextApiRequest, NextApiResponse } from "next"; 36 | import { createRouter, expressWrapper } from "next-connect"; 37 | import cors from "cors"; 38 | 39 | const router = createRouter(); 40 | 41 | router 42 | // Use express middleware in next-connect with expressWrapper function 43 | .use(expressWrapper(passport.session())) 44 | // A middleware example 45 | .use(async (req, res, next) => { 46 | const start = Date.now(); 47 | await next(); // call next in chain 48 | const end = Date.now(); 49 | console.log(`Request took ${end - start}ms`); 50 | }) 51 | .get((req, res) => { 52 | const user = getUser(req.query.id); 53 | res.json({ user }); 54 | }) 55 | .put((req, res) => { 56 | if (req.user.id !== req.query.id) { 57 | throw new ForbiddenError("You can't update other user's profile"); 58 | } 59 | const user = await updateUser(req.body.user); 60 | res.json({ user }); 61 | }); 62 | 63 | export const config = { 64 | runtime: "edge", 65 | }; 66 | 67 | export default router.handler({ 68 | onError: (err, req, res) => { 69 | console.error(err.stack); 70 | res.status(err.statusCode || 500).end(err.message); 71 | }, 72 | }); 73 | ``` 74 | 75 | ### Next.js Edge API Routes 76 | 77 | `next-connect` can be used in [Edge API Routes](https://nextjs.org/docs/api-routes/edge-api-routes) 78 | 79 | ```typescript 80 | // pages/api/user/[id].ts 81 | import type { NextFetchEvent, NextRequest } from "next/server"; 82 | import { createEdgeRouter } from "next-connect"; 83 | import cors from "cors"; 84 | 85 | const router = createEdgeRouter(); 86 | 87 | router 88 | // A middleware example 89 | .use(async (req, event, next) => { 90 | const start = Date.now(); 91 | await next(); // call next in chain 92 | const end = Date.now(); 93 | console.log(`Request took ${end - start}ms`); 94 | }) 95 | .get((req) => { 96 | const id = req.nextUrl.searchParams.get("id"); 97 | const user = getUser(id); 98 | return NextResponse.json({ user }); 99 | }) 100 | .put((req) => { 101 | const id = req.nextUrl.searchParams.get("id"); 102 | if (req.user.id !== id) { 103 | throw new ForbiddenError("You can't update other user's profile"); 104 | } 105 | const user = await updateUser(req.body.user); 106 | return NextResponse.json({ user }); 107 | }); 108 | 109 | export default router.handler({ 110 | onError: (err, req, event) => { 111 | console.error(err.stack); 112 | return new NextResponse("Something broke!", { 113 | status: err.statusCode || 500, 114 | }); 115 | }, 116 | }); 117 | ``` 118 | 119 | ### Next.js App Router 120 | 121 | `next-connect` can be used in [Next.js 13 Route Handler](https://beta.nextjs.org/docs/routing/route-handlers). The way handlers are written is almost the same to [Next.js Edge API Routes](#nextjs-edge-api-routes) by using `createEdgeRouter`. 122 | 123 | ```typescript 124 | // app/api/user/[id]/route.ts 125 | 126 | import type { NextFetchEvent, NextRequest } from "next/server"; 127 | import { createEdgeRouter } from "next-connect"; 128 | import cors from "cors"; 129 | 130 | interface RequestContext { 131 | params: { 132 | id: string; 133 | }; 134 | } 135 | 136 | const router = createEdgeRouter(); 137 | 138 | router 139 | // A middleware example 140 | .use(async (req, event, next) => { 141 | const start = Date.now(); 142 | await next(); // call next in chain 143 | const end = Date.now(); 144 | console.log(`Request took ${end - start}ms`); 145 | }) 146 | .get((req) => { 147 | const id = req.params.id; 148 | const user = getUser(id); 149 | return NextResponse.json({ user }); 150 | }) 151 | .put((req) => { 152 | const id = req.params.id; 153 | if (req.user.id !== id) { 154 | throw new ForbiddenError("You can't update other user's profile"); 155 | } 156 | const user = await updateUser(req.body.user); 157 | return NextResponse.json({ user }); 158 | }); 159 | 160 | export async function GET(request: NextRequest, ctx: RequestContext) { 161 | return router.run(request, ctx); 162 | } 163 | 164 | export async function PUT(request: NextRequest, ctx: RequestContext) { 165 | return router.run(request, ctx); 166 | } 167 | ``` 168 | 169 | ### Next.js Middleware 170 | 171 | `next-connect` can be used in [Next.js Middleware](https://nextjs.org/docs/advanced-features/middleware) 172 | 173 | ```typescript 174 | // middleware.ts 175 | import { NextResponse } from "next/server"; 176 | import type { NextRequest, NextFetchEvent } from "next/server"; 177 | import { createEdgeRouter } from "next-connect"; 178 | 179 | const router = createEdgeRouter(); 180 | 181 | router.use(async (request, event, next) => { 182 | // logging request example 183 | console.log(`${request.method} ${request.url}`); 184 | return next(); 185 | }); 186 | 187 | router.get("/about", (request) => { 188 | return NextResponse.redirect(new URL("/about-2", request.url)); 189 | }); 190 | 191 | router.use("/dashboard", (request) => { 192 | if (!isAuthenticated(request)) { 193 | return NextResponse.redirect(new URL("/login", request.url)); 194 | } 195 | return NextResponse.next(); 196 | }); 197 | 198 | router.all(() => { 199 | // default if none of the above matches 200 | return NextResponse.next(); 201 | }); 202 | 203 | export function middleware(request: NextRequest, event: NextFetchEvent) { 204 | return router.run(request, event); 205 | } 206 | 207 | export const config = { 208 | matcher: [ 209 | /* 210 | * Match all request paths except for the ones starting with: 211 | * - api (API routes) 212 | * - _next/static (static files) 213 | * - _next/image (image optimization files) 214 | * - favicon.ico (favicon file) 215 | */ 216 | "/((?!api|_next/static|_next/image|favicon.ico).*)", 217 | ], 218 | }; 219 | ``` 220 | 221 | ### Next.js getServerSideProps 222 | 223 | `next-connect` can be used in [getServerSideProps](https://nextjs.org/docs/basic-features/data-fetching/get-server-side-props). 224 | 225 | ```jsx 226 | // pages/users/[id].js 227 | import { createRouter } from "next-connect"; 228 | 229 | export default function Page({ user, updated }) { 230 | return ( 231 |
232 | {updated &&

User has been updated

} 233 |
{JSON.stringify(user)}
234 |
{/* User update form */}
235 |
236 | ); 237 | } 238 | 239 | const router = createRouter() 240 | .use(async (req, res, next) => { 241 | // this serve as the error handling middleware 242 | try { 243 | return await next(); 244 | } catch (e) { 245 | return { 246 | props: { error: e.message }, 247 | }; 248 | } 249 | }) 250 | .get(async (req, res) => { 251 | const user = await getUser(req.params.id); 252 | if (!user) { 253 | // https://nextjs.org/docs/api-reference/data-fetching/get-server-side-props#notfound 254 | return { props: { notFound: true } }; 255 | } 256 | return { props: { user } }; 257 | }) 258 | .put(async (req, res) => { 259 | const user = await updateUser(req); 260 | return { props: { user, updated: true } }; 261 | }); 262 | 263 | export async function getServerSideProps({ req, res }) { 264 | return router.run(req, res); 265 | } 266 | ``` 267 | 268 | ## API 269 | 270 | The following APIs are rewritten in term of `NodeRouter` (`createRouter`), but they apply to `EdgeRouter` (`createEdgeRouter`) as well. 271 | 272 | ### router = createRouter() 273 | 274 | Create an instance Node.js router. 275 | 276 | ### router.use(base, ...fn) 277 | 278 | `base` (optional) - match all routes to the right of `base` or match all if omitted. (Note: If used in Next.js, this is often omitted) 279 | 280 | `fn`(s) can either be: 281 | 282 | - functions of `(req, res[, next])` 283 | - **or** a router instance 284 | 285 | ```javascript 286 | // Mount a middleware function 287 | router1.use(async (req, res, next) => { 288 | req.hello = "world"; 289 | await next(); // call to proceed to the next in chain 290 | console.log("request is done"); // call after all downstream handler has run 291 | }); 292 | 293 | // Or include a base 294 | router2.use("/foo", fn); // Only run in /foo/** 295 | 296 | // mount an instance of router 297 | const sub1 = createRouter().use(fn1, fn2); 298 | const sub2 = createRouter().use("/dashboard", auth); 299 | const sub3 = createRouter() 300 | .use("/waldo", subby) 301 | .get(getty) 302 | .post("/baz", posty) 303 | .put("/", putty); 304 | router3 305 | // - fn1 and fn2 always run 306 | // - auth runs only on /dashboard 307 | .use(sub1, sub2) 308 | // `subby` runs on ANY /foo/waldo?/* 309 | // `getty` runs on GET /foo/* 310 | // `posty` runs on POST /foo/baz 311 | // `putty` runs on PUT /foo 312 | .use("/foo", sub3); 313 | ``` 314 | 315 | ### router.METHOD(pattern, ...fns) 316 | 317 | `METHOD` is an HTTP method (`GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE`) in lowercase. 318 | 319 | `pattern` (optional) - match routes based on [supported pattern](https://github.com/lukeed/regexparam#regexparam-) or match any if omitted. 320 | 321 | `fn`(s) are functions of `(req, res[, next])`. 322 | 323 | ```javascript 324 | router.get("/api/user", (req, res, next) => { 325 | res.json(req.user); 326 | }); 327 | router.post("/api/users", (req, res, next) => { 328 | res.end("User created"); 329 | }); 330 | router.put("/api/user/:id", (req, res, next) => { 331 | // https://nextjs.org/docs/routing/dynamic-routes 332 | res.end(`User ${req.params.id} updated`); 333 | }); 334 | 335 | // Next.js already handles routing (including dynamic routes), we often 336 | // omit `pattern` in `.METHOD` 337 | router.get((req, res, next) => { 338 | res.end("This matches whatever route"); 339 | }); 340 | ``` 341 | 342 | > **Note** 343 | > You should understand Next.js [file-system based routing](https://nextjs.org/docs/routing/introduction). For example, having a `router.put("/api/foo", handler)` inside `page/api/index.js` _does not_ serve that handler at `/api/foo`. 344 | 345 | ### router.all(pattern, ...fns) 346 | 347 | Same as [.METHOD](#methodpattern-fns) but accepts _any_ methods. 348 | 349 | ### router.handler(options) 350 | 351 | Create a handler to handle incoming requests. 352 | 353 | **options.onError** 354 | 355 | Accepts a function as a catch-all error handler; executed whenever a handler throws an error. 356 | By default, it responds with a generic `500 Internal Server Error` while logging the error to `console`. 357 | 358 | ```javascript 359 | function onError(err, req, res) { 360 | logger.log(err); 361 | // OR: console.error(err); 362 | 363 | res.status(500).end("Internal server error"); 364 | } 365 | 366 | export default router.handler({ onError }); 367 | ``` 368 | 369 | **options.onNoMatch** 370 | 371 | Accepts a function of `(req, res)` as a handler when no route is matched. 372 | By default, it responds with a `404` status and a `Route [Method] [Url] not found` body. 373 | 374 | ```javascript 375 | function onNoMatch(req, res) { 376 | res.status(404).end("page is not found... or is it!?"); 377 | } 378 | 379 | export default router.handler({ onNoMatch }); 380 | ``` 381 | 382 | ### router.run(req, res) 383 | 384 | Runs `req` and `res` through the middleware chain and returns a **promise**. It resolves with the value returned from handlers. 385 | 386 | ```js 387 | router 388 | .use(async (req, res, next) => { 389 | return (await next()) + 1; 390 | }) 391 | .use(async () => { 392 | return (await next()) + 2; 393 | }) 394 | .use(async () => { 395 | return 3; 396 | }); 397 | 398 | console.log(await router.run(req, res)); 399 | // The above will print "6" 400 | ``` 401 | 402 | If an error in thrown within the chain, `router.run` will reject. You can also add a try-catch in the first middleware to catch the error before it rejects the `.run()` call: 403 | 404 | ```js 405 | router 406 | .use(async (req, res, next) => { 407 | return next().catch(errorHandler); 408 | }) 409 | .use(thisMiddlewareMightThrow); 410 | 411 | await router.run(req, res); 412 | ``` 413 | 414 | ## Common errors 415 | 416 | There are some pitfalls in using `next-connect`. Below are things to keep in mind to use it correctly. 417 | 418 | 1. **Always** `await next()` 419 | 420 | If `next()` is not awaited, errors will not be caught if they are thrown in async handlers, leading to `UnhandledPromiseRejection`. 421 | 422 | ```javascript 423 | // OK: we don't use async so no need to await 424 | router 425 | .use((req, res, next) => { 426 | next(); 427 | }) 428 | .use((req, res, next) => { 429 | next(); 430 | }) 431 | .use(() => { 432 | throw new Error("💥"); 433 | }); 434 | 435 | // BAD: This will lead to UnhandledPromiseRejection 436 | router 437 | .use(async (req, res, next) => { 438 | next(); 439 | }) 440 | .use(async (req, res, next) => { 441 | next(); 442 | }) 443 | .use(async () => { 444 | throw new Error("💥"); 445 | }); 446 | 447 | // GOOD 448 | router 449 | .use(async (req, res, next) => { 450 | await next(); // next() is awaited, so errors are caught properly 451 | }) 452 | .use((req, res, next) => { 453 | return next(); // this works as well since we forward the rejected promise 454 | }) 455 | .use(async () => { 456 | throw new Error("💥"); 457 | // return new Promise.reject("💥"); 458 | }); 459 | ``` 460 | 461 | Another issue is that the handler would resolve before all the code in each layer runs. 462 | 463 | ```javascript 464 | const handler = router 465 | .use(async (req, res, next) => { 466 | next(); // this is not returned or await 467 | }) 468 | .get(async () => { 469 | // simulate a long task 470 | await new Promise((resolve) => setTimeout(resolve, 1000)); 471 | res.send("ok"); 472 | console.log("request is completed"); 473 | }) 474 | .handler(); 475 | 476 | await handler(req, res); 477 | console.log("finally"); // this will run before the get layer gets to finish 478 | 479 | // This will result in: 480 | // 1) "finally" 481 | // 2) "request is completed" 482 | ``` 483 | 484 | 2. **DO NOT** reuse the same instance of `router` like the below pattern: 485 | 486 | ```javascript 487 | // api-libs/base.js 488 | export default createRouter().use(a).use(b); 489 | 490 | // api/foo.js 491 | import router from "api-libs/base"; 492 | export default router.get(x).handler(); 493 | 494 | // api/bar.js 495 | import router from "api-libs/base"; 496 | export default router.get(y).handler(); 497 | ``` 498 | 499 | This is because, in each API Route, the same router instance is mutated, leading to undefined behaviors. 500 | If you want to achieve something like that, you can use `router.clone` to return different instances with the same routes populated. 501 | 502 | ```javascript 503 | // api-libs/base.js 504 | export default createRouter().use(a).use(b); 505 | 506 | // api/foo.js 507 | import router from "api-libs/base"; 508 | export default router.clone().get(x).handler(); 509 | 510 | // api/bar.js 511 | import router from "api-libs/base"; 512 | export default router.clone().get(y).handler(); 513 | ``` 514 | 515 | 3. **DO NOT** use response function like `res.(s)end` or `res.redirect` inside `getServerSideProps`. 516 | 517 | ```javascript 518 | // page/index.js 519 | const handler = createRouter() 520 | .use((req, res) => { 521 | // BAD: res.redirect is not a function (not defined in `getServerSideProps`) 522 | // See https://github.com/hoangvvo/next-connect/issues/194#issuecomment-1172961741 for a solution 523 | res.redirect("foo"); 524 | }) 525 | .use((req, res) => { 526 | // BAD: `getServerSideProps` gives undefined behavior if we try to send a response 527 | res.end("bar"); 528 | }); 529 | 530 | export async function getServerSideProps({ req, res }) { 531 | await router.run(req, res); 532 | return { 533 | props: {}, 534 | }; 535 | } 536 | ``` 537 | 538 | 3. **DO NOT** use `handler()` directly in `getServerSideProps`. 539 | 540 | ```javascript 541 | // page/index.js 542 | const router = createRouter().use(foo).use(bar); 543 | const handler = router.handler(); 544 | 545 | export async function getServerSideProps({ req, res }) { 546 | await handler(req, res); // BAD: You should call router.run(req, res); 547 | return { 548 | props: {}, 549 | }; 550 | } 551 | ``` 552 | 553 | ## Contributing 554 | 555 | Please see my [contributing.md](CONTRIBUTING.md). 556 | 557 | ## License 558 | 559 | [MIT](LICENSE) 560 | -------------------------------------------------------------------------------- /bench/README.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | A simple benchmark against some popular router using [wrk](https://github.com/wg/wrk) 4 | 5 | ``` 6 | wrk -t12 -c400 -d30s http://localhost:3000/user/123 7 | ``` 8 | 9 | > Remember, this benchmark is for reference only and by no means says that one is better than the others. The slowest part of the application is still the application code itself, not the library. 10 | 11 | ## How-to 12 | 13 | Set permissions: 14 | 15 | ```bash 16 | chmod u+x ./run.sh 17 | chmod u+x ./runall.sh 18 | ``` 19 | 20 | Run individual benchmark 21 | 22 | ```bash 23 | ./run 24 | # ./run next-connect 25 | ``` 26 | 27 | Run all benchmarks 28 | 29 | ```bash 30 | ./runall 31 | ``` 32 | 33 | ## Result 34 | 35 | ``` 36 | Machine: Linux 5.17.0-051700-generic x86_64 | 12 vCPUs | 16GB 37 | Node: v18.0.0 38 | 39 | express 40 | Running 30s test @ http://localhost:3000/user/123 41 | 12 threads and 400 connections 42 | Thread Stats Avg Stdev Max +/- Stdev 43 | Latency 27.66ms 3.30ms 88.73ms 90.64% 44 | Req/Sec 1.20k 137.40 2.61k 82.69% 45 | 430220 requests in 30.09s, 63.18MB read 46 | Requests/sec: 14296.68 47 | Transfer/sec: 2.10MB 48 | 49 | http 50 | Running 30s test @ http://localhost:3000/user/123 51 | 12 threads and 400 connections 52 | Thread Stats Avg Stdev Max +/- Stdev 53 | Latency 7.02ms 1.65ms 79.49ms 99.08% 54 | Req/Sec 4.76k 413.57 5.64k 80.44% 55 | 1704802 requests in 30.03s, 212.98MB read 56 | Requests/sec: 56765.13 57 | Transfer/sec: 7.09MB 58 | 59 | next-connect 60 | Running 30s test @ http://localhost:3000/user/123 61 | 12 threads and 400 connections 62 | Thread Stats Avg Stdev Max +/- Stdev 63 | Latency 8.98ms 2.67ms 110.80ms 98.92% 64 | Req/Sec 3.73k 370.41 6.44k 84.61% 65 | 1338233 requests in 30.05s, 167.19MB read 66 | Requests/sec: 44531.36 67 | Transfer/sec: 5.56MB 68 | ``` 69 | -------------------------------------------------------------------------------- /bench/express.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | function one(req, res, next) { 4 | req.one = true; 5 | next(); 6 | } 7 | 8 | function two(req, res, next) { 9 | req.two = true; 10 | next(); 11 | } 12 | 13 | express() 14 | .use(one, two) 15 | .get("/", (req, res) => res.send("Hello")) 16 | .get("/user/:id", (req, res) => { 17 | res.end(`User: ${req.params.id}`); 18 | }) 19 | .listen(3000); 20 | -------------------------------------------------------------------------------- /bench/http.js: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | 3 | createServer((req, res) => { 4 | req.one = true; 5 | req.two = true; 6 | if (req.url === "/") return res.end("Hello"); 7 | else if (req.url.startsWith("/user")) { 8 | return res.end(`User: 123`); 9 | } 10 | res.statusCode = 404; 11 | res.end("not found"); 12 | }).listen(3000); 13 | -------------------------------------------------------------------------------- /bench/next-connect.js: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | import { createRouter } from "next-connect"; 3 | 4 | function one(req, res, next) { 5 | req.one = true; 6 | next(); 7 | } 8 | 9 | function two(req, res, next) { 10 | req.two = true; 11 | next(); 12 | } 13 | 14 | createServer( 15 | createRouter() 16 | .use(one, two) 17 | .get("/", (req, res) => res.end("Hello")) 18 | .get("/user/:id", (req, res) => { 19 | res.end(`User: ${req.params.id}`); 20 | }) 21 | .handler() 22 | ).listen(3000); 23 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "name": "bench", 4 | "version": "1.0.0", 5 | "description": "", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "private": true, 13 | "dependencies": { 14 | "express": "^4.18.1", 15 | "fastify": "^4.2.0", 16 | "next-connect": "file:.." 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /bench/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | server="$1" 4 | url="$2" 5 | 6 | NODE_ENV=production node $server & 7 | pid=$! 8 | 9 | printf "$server\n" 10 | 11 | sleep 2 12 | 13 | wrk -t12 -c400 -d30s http://localhost:3000/user/123 14 | 15 | printf "\n" 16 | 17 | kill $pid -------------------------------------------------------------------------------- /bench/runall.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | printf "Machine: $(uname -s -r -m) | $(node -r os -p "\`\${os.cpus().length} vCPUs | \${Math.ceil(os.totalmem() / (Math.pow(1024, 3)))}GB\`")\n" 3 | printf "Node: $(node -v)\n\n" 4 | for path in *.js 5 | do 6 | file=${path##*/} 7 | base=${file%.*} 8 | ./run.sh $base 9 | done -------------------------------------------------------------------------------- /examples/nextjs-13/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nextjs-13/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /examples/nextjs-13/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /examples/nextjs-13/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | [http://localhost:3000/api/hello](http://localhost:3000/api/hello) is an endpoint that uses [Route Handlers](https://beta.nextjs.org/docs/routing/route-handlers). This endpoint can be edited in `app/api/hello/route.ts`. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /examples/nextjs-13/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /examples/nextjs-13/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-13", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@types/node": "18.16.3", 13 | "@types/react": "18.2.4", 14 | "@types/react-dom": "18.2.3", 15 | "eslint": "8.39.0", 16 | "eslint-config-next": "13.3.4", 17 | "next": "13.3.4", 18 | "next-connect": "^1.0.0-next.3", 19 | "react": "18.2.0", 20 | "react-dom": "18.2.0", 21 | "typescript": "5.0.4" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs-13/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs-13/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/api/users/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { getUsers } from "@/utils/api"; 2 | import { logRequest } from "@/utils/middleware"; 3 | import { createEdgeRouter } from "next-connect"; 4 | import type { NextRequest } from "next/server"; 5 | import { NextResponse } from "next/server"; 6 | 7 | interface RequestContext { 8 | params: { 9 | id: string; 10 | }; 11 | } 12 | 13 | const router = createEdgeRouter(); 14 | 15 | router.use(logRequest); 16 | 17 | router.get((req, { params: { id } }) => { 18 | const users = getUsers(req); 19 | const user = users.find((user) => user.id === id); 20 | if (!user) { 21 | return NextResponse.json({ error: "User not found" }, { status: 404 }); 22 | } 23 | return NextResponse.json({ user }); 24 | }); 25 | 26 | export async function GET(request: NextRequest, ctx: RequestContext) { 27 | return router.run(request, ctx); 28 | } 29 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/api/users/route.ts: -------------------------------------------------------------------------------- 1 | import { getUsers, randomId, saveUsers } from "@/utils/api"; 2 | import type { User } from "@/utils/common"; 3 | import { validateUser } from "@/utils/common"; 4 | import { logRequest } from "@/utils/middleware"; 5 | import { createEdgeRouter } from "next-connect"; 6 | import type { NextRequest } from "next/server"; 7 | import { NextResponse } from "next/server"; 8 | 9 | const router = createEdgeRouter(); 10 | 11 | router.use(logRequest); 12 | 13 | router.get((req) => { 14 | const users = getUsers(req); 15 | return NextResponse.json({ users }); 16 | }); 17 | 18 | router.post(async (req) => { 19 | const users = getUsers(req); 20 | const body = await req.json(); 21 | const newUser = { 22 | id: randomId(), 23 | ...body, 24 | } as User; 25 | validateUser(newUser); 26 | users.push(newUser); 27 | const res = NextResponse.json({ 28 | message: "User has been created", 29 | }); 30 | saveUsers(res, users); 31 | return res; 32 | }); 33 | 34 | export async function GET(request: NextRequest, ctx: { params?: unknown }) { 35 | return router.run(request, ctx); 36 | } 37 | 38 | export async function POST(request: NextRequest, ctx: { params?: unknown }) { 39 | return router.run(request, ctx); 40 | } 41 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangvvo/next-connect/a56a9f9fee131ad1b2012ec00b2132fe486138d0/examples/nextjs-13/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", 5 | "Roboto Mono", "Oxygen Mono", "Ubuntu Monospace", "Source Code Pro", 6 | "Fira Mono", "Droid Sans Mono", "Courier New", monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import "./globals.css"; 3 | 4 | const inter = Inter({ subsets: ["latin"] }); 5 | 6 | export const metadata = { 7 | title: "Create Next App", 8 | description: "Generated by create next app", 9 | }; 10 | 11 | export default function RootLayout({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) { 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(4, minmax(25%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 30ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ""; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo { 108 | position: relative; 109 | } 110 | /* Enable hover only on non-touch devices */ 111 | @media (hover: hover) and (pointer: fine) { 112 | .card:hover { 113 | background: rgba(var(--card-rgb), 0.1); 114 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 115 | } 116 | 117 | .card:hover span { 118 | transform: translateX(4px); 119 | } 120 | } 121 | 122 | @media (prefers-reduced-motion) { 123 | .card:hover span { 124 | transform: none; 125 | } 126 | } 127 | 128 | /* Mobile */ 129 | @media (max-width: 700px) { 130 | .content { 131 | padding: 4rem; 132 | } 133 | 134 | .grid { 135 | grid-template-columns: 1fr; 136 | margin-bottom: 120px; 137 | max-width: 320px; 138 | text-align: center; 139 | } 140 | 141 | .card { 142 | padding: 1rem 2.5rem; 143 | } 144 | 145 | .card h2 { 146 | margin-bottom: 0.5rem; 147 | } 148 | 149 | .center { 150 | padding: 8rem 0 6rem; 151 | } 152 | 153 | .center::before { 154 | transform: none; 155 | height: 300px; 156 | } 157 | 158 | .description { 159 | font-size: 0.8rem; 160 | } 161 | 162 | .description a { 163 | padding: 1rem; 164 | } 165 | 166 | .description p, 167 | .description div { 168 | display: flex; 169 | justify-content: center; 170 | position: fixed; 171 | width: 100%; 172 | } 173 | 174 | .description p { 175 | align-items: center; 176 | inset: 0 0 auto; 177 | padding: 2rem 1rem 1.4rem; 178 | border-radius: 0; 179 | border: none; 180 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 181 | background: linear-gradient( 182 | to bottom, 183 | rgba(var(--background-start-rgb), 1), 184 | rgba(var(--callout-rgb), 0.5) 185 | ); 186 | background-clip: padding-box; 187 | backdrop-filter: blur(24px); 188 | } 189 | 190 | .description div { 191 | align-items: flex-end; 192 | pointer-events: none; 193 | inset: auto 0 0; 194 | padding: 2rem; 195 | height: 200px; 196 | background: linear-gradient( 197 | to bottom, 198 | transparent 0%, 199 | rgb(var(--background-end-rgb)) 40% 200 | ); 201 | z-index: 1; 202 | } 203 | } 204 | 205 | /* Tablet and Smaller Desktop */ 206 | @media (min-width: 701px) and (max-width: 1120px) { 207 | .grid { 208 | grid-template-columns: repeat(2, 50%); 209 | } 210 | } 211 | 212 | @media (prefers-color-scheme: dark) { 213 | .vercelLogo { 214 | filter: invert(1); 215 | } 216 | 217 | .logo { 218 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 219 | } 220 | } 221 | 222 | @keyframes rotate { 223 | from { 224 | transform: rotate(360deg); 225 | } 226 | to { 227 | transform: rotate(0deg); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import styles from "./page.module.css"; 2 | 3 | export default function Home() { 4 | return ( 5 |
6 |

7 | 8 | App Router 9 | {" "} 10 | example 11 |

12 |

13 | Open your devtool (F12) and try the following snippets. 14 |

15 |

16 | POST /api/users 17 | - Create a user 18 |

19 |
20 |
{`await fetch("/api/users", {
21 |   method: "POST",
22 |   headers: { "content-type": "application/json" },
23 |   body: JSON.stringify({ name: "Jane Doe", age: 18 }),
24 | }).then((res) => res.json());
25 | `}
26 |
27 |

28 | GET /api/users 29 | - Get all users 30 |

31 |
32 |
{`await fetch("/api/users").then((res) => res.json());
33 | `}
34 |
35 |

36 | GET /api/users/:id 37 | - Get a single user 38 |

39 |
40 |
41 |           {`await fetch("/api/users/`}
42 |           some-id
43 |           {`").then(res => res.json());
44 | `}
45 |         
46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest, NextResponse } from "next/server"; 2 | import type { User } from "./common"; 3 | import { COOKIE_NAME } from "./common"; 4 | 5 | export const randomId = () => crypto.randomUUID(); 6 | 7 | export const getUsers = (req: NextRequest): User[] => { 8 | // we store all data in cookies for demo purposes 9 | const cookie = req.cookies.get(COOKIE_NAME); 10 | if (cookie) { 11 | return JSON.parse(cookie.value); 12 | } 13 | return []; 14 | }; 15 | 16 | export const saveUsers = (res: NextResponse, users: User[]) => { 17 | res.cookies.set(COOKIE_NAME, JSON.stringify(users), { 18 | path: "/", 19 | }); 20 | return res; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export const COOKIE_NAME = "nc_users"; 2 | export const VIEW_COOKIE_NAME = "view_count"; 3 | export interface User { 4 | id: string; 5 | name: string; 6 | age: number; 7 | } 8 | 9 | export const validateUser = (user: User) => { 10 | user.name = user.name.trim(); 11 | if (!user.name) throw new Error("Name cannot be empty"); 12 | if (user.age < 8) { 13 | throw new Error("Aren't You a Little Young to be a Web Developer?"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /examples/nextjs-13/src/utils/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from "next/server.js"; 2 | 3 | export const logRequest = ( 4 | req: NextRequest, 5 | params: unknown, 6 | next: () => void 7 | ) => { 8 | console.log(`${req.method} ${req.url}`); 9 | return next(); 10 | }; 11 | -------------------------------------------------------------------------------- /examples/nextjs-13/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /examples/nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | package-lock.json 38 | yarn.lock -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /examples/nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "cookie": "^0.5.0", 13 | "next": "13.3.4", 14 | "next-connect": "1.0.0-next.2", 15 | "react": "18.2.0", 16 | "react-dom": "18.2.0" 17 | }, 18 | "devDependencies": { 19 | "@types/cookie": "^0.5.1", 20 | "@types/node": "18.16.3", 21 | "@types/react": "18.2.4", 22 | "@types/react-dom": "18.2.3", 23 | "eslint": "8.39.0", 24 | "eslint-config-next": "13.3.4", 25 | "typescript": "5.0.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoangvvo/next-connect/a56a9f9fee131ad1b2012ec00b2132fe486138d0/examples/nextjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /examples/nextjs/src/middleware.ts: -------------------------------------------------------------------------------- 1 | // middleware.ts 2 | import { createEdgeRouter } from "next-connect"; 3 | import type { NextFetchEvent, NextRequest } from "next/server"; 4 | import { NextResponse } from "next/server"; 5 | 6 | const router = createEdgeRouter(); 7 | 8 | router.use(async (request, event, next) => { 9 | console.log(`${request.method} ${request.url}`); 10 | return next(); 11 | }); 12 | 13 | router.all(() => { 14 | // default if none of the above matches 15 | return NextResponse.next(); 16 | }); 17 | 18 | export function middleware(request: NextRequest, event: NextFetchEvent) { 19 | return router.run(request, event); 20 | } 21 | 22 | export const config = { 23 | matcher: [ 24 | /* 25 | * Match all request paths except for the ones starting with: 26 | * - api (API routes) 27 | * - _next/static (static files) 28 | * - _next/image (image optimization files) 29 | * - favicon.ico (favicon file) 30 | */ 31 | "/((?!api|_next/static|_next/image|favicon.ico).*)", 32 | ], 33 | }; 34 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import "../styles/globals.css"; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/api-routes.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import styles from "../styles/styles.module.css"; 4 | 5 | const ApiRoutesPage: NextPage = () => { 6 | return ( 7 |
8 | 9 | API Routes example 10 | 11 | 12 | 13 |
14 |

15 | 16 | API Routes 17 | {" "} 18 | example 19 |

20 |

21 | Open your devtool (F12) and try the following snippets. 22 |

23 |

24 | POST /api/users 25 | - Create a user 26 |

27 |
28 |
{`await fetch("/api/users", {
29 |   method: "POST",
30 |   headers: { "content-type": "application/json" },
31 |   body: JSON.stringify({ name: "Jane Doe", age: 18 }),
32 | }).then((res) => res.json());
33 | `}
34 |
35 |

36 | GET /api/users 37 | - Get all users 38 |

39 |
40 |
{`await fetch("/api/users").then((res) => res.json());
41 | `}
42 |
43 |

44 | GET /api/users/:id 45 | - Get a single user 46 |

47 |
48 |
49 |             {`await fetch("/api/users/`}
50 |             some-id
51 |             {`").then(res => res.json());
52 | `}
53 |           
54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export default ApiRoutesPage; 61 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/api/edge-users/[id].ts: -------------------------------------------------------------------------------- 1 | import { createEdgeRouter } from "next-connect"; 2 | import type { NextFetchEvent, NextRequest } from "next/server"; 3 | import { NextResponse } from "next/server"; 4 | import { getUsers } from "../../../utils/edge-api"; 5 | 6 | export const config = { 7 | runtime: "edge", 8 | }; 9 | 10 | const router = createEdgeRouter(); 11 | 12 | router.get((req) => { 13 | const id = req.nextUrl.searchParams.get("id"); 14 | const users = getUsers(req); 15 | const user = users.find((user) => user.id === id); 16 | if (!user) { 17 | return NextResponse.json({ error: "User not found" }, { status: 404 }); 18 | } 19 | return NextResponse.json({ user }); 20 | }); 21 | 22 | // this will run if none of the above matches 23 | export default router.handler({ 24 | onError(err) { 25 | return new NextResponse(JSON.stringify({ error: (err as Error).message }), { 26 | status: 500, 27 | }); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/api/edge-users/index.ts: -------------------------------------------------------------------------------- 1 | import { createEdgeRouter } from "next-connect"; 2 | import type { NextFetchEvent, NextRequest } from "next/server"; 3 | import { NextResponse } from "next/server"; 4 | import type { User } from "../../../utils/common"; 5 | import { validateUser } from "../../../utils/common"; 6 | import { getUsers, randomId, saveUsers } from "../../../utils/edge-api"; 7 | 8 | export const config = { 9 | runtime: "edge", 10 | }; 11 | 12 | const router = createEdgeRouter(); 13 | 14 | router.get((req) => { 15 | const users = getUsers(req); 16 | return NextResponse.json({ users }); 17 | }); 18 | 19 | router.post(async (req) => { 20 | const users = getUsers(req); 21 | const body = await req.json(); 22 | const newUser = { 23 | id: randomId(), 24 | ...body, 25 | } as User; 26 | validateUser(newUser); 27 | users.push(newUser); 28 | const res = NextResponse.json({ 29 | message: "User has been created", 30 | }); 31 | saveUsers(res, users); 32 | return res; 33 | }); 34 | 35 | // this will run if none of the above matches 36 | router.all(() => { 37 | return NextResponse.json({ error: "Method not allowed" }, { status: 405 }); 38 | }); 39 | 40 | export default router.handler({ 41 | onError(err) { 42 | return new NextResponse(JSON.stringify({ error: (err as Error).message }), { 43 | status: 500, 44 | }); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/api/users/[id].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { createRouter } from "next-connect"; 3 | import { getUsers } from "../../../utils/api"; 4 | 5 | const router = createRouter(); 6 | 7 | router.get((req, res) => { 8 | const users = getUsers(req); 9 | const user = users.find((user) => user.id === req.query.id); 10 | if (!user) { 11 | res.status(404).json({ error: "User not found" }); 12 | return; 13 | } 14 | res.json({ 15 | user, 16 | }); 17 | }); 18 | 19 | // this will run if none of the above matches 20 | router.all((req, res) => { 21 | res.status(405).json({ 22 | error: "Method not allowed", 23 | }); 24 | }); 25 | 26 | export default router.handler({ 27 | onError(err, req, res) { 28 | res.status(400).json({ 29 | error: (err as Error).message, 30 | }); 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/api/users/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { createRouter } from "next-connect"; 3 | import { getUsers, randomId, saveUsers } from "../../../utils/api"; 4 | import type { User } from "../../../utils/common"; 5 | import { validateUser } from "../../../utils/common"; 6 | 7 | const router = createRouter(); 8 | 9 | router.get((req, res) => { 10 | const users = getUsers(req); 11 | res.json({ 12 | users, 13 | }); 14 | }); 15 | 16 | router.post((req, res) => { 17 | const users = getUsers(req); 18 | const newUser = { 19 | id: randomId(), 20 | ...req.body, 21 | } as User; 22 | validateUser(newUser); 23 | users.push(newUser); 24 | saveUsers(res, users); 25 | res.json({ 26 | message: "User has been created", 27 | }); 28 | }); 29 | 30 | // this will run if none of the above matches 31 | router.all((req, res) => { 32 | res.status(405).json({ 33 | error: "Method not allowed", 34 | }); 35 | }); 36 | 37 | export default router.handler({ 38 | onError(err, req, res) { 39 | res.status(500).json({ 40 | error: (err as Error).message, 41 | }); 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/edge-api-routes.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import styles from "../styles/styles.module.css"; 4 | 5 | const EdgeApiRoutesPage: NextPage = () => { 6 | return ( 7 |
8 | 9 | Edge API Routes example 10 | 11 | 12 | 13 |
14 |

15 | 16 | Edge API Routes 17 | {" "} 18 | example 19 |

20 |

21 | Open your devtool (F12) and try the following snippets. 22 |

23 |

24 | POST /api/edge-users 25 | - Create a user 26 |

27 |
28 |
{`await fetch("/api/edge-users", {
29 |   method: "POST",
30 |   headers: { "content-type": "application/json" },
31 |   body: JSON.stringify({ name: "Jane Doe", age: 18 }),
32 | }).then((res) => res.json());
33 | `}
34 |
35 |

36 | GET /api/edge-users 37 | - Get all users 38 |

39 |
40 |
{`await fetch("/api/edge-users").then((res) => res.json());
41 | `}
42 |
43 |

44 | GET /api/edge-users/:id 45 | - Get a single user 46 |

47 |
48 |
49 |             {`await fetch("/api/edge-users/`}
50 |             some-id
51 |             {`").then(res => res.json());
52 | `}
53 |           
54 |
55 |
56 |
57 | ); 58 | }; 59 | 60 | export default EdgeApiRoutesPage; 61 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/gssp-users/[id].tsx: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import type { 3 | GetServerSideProps, 4 | GetServerSidePropsResult, 5 | NextPage, 6 | } from "next"; 7 | import { createRouter } from "next-connect"; 8 | import Head from "next/head"; 9 | import styles from "../../styles/styles.module.css"; 10 | import { getUsers } from "../../utils/api"; 11 | import type { User } from "../../utils/common"; 12 | 13 | interface PageProps { 14 | user: User; 15 | } 16 | 17 | const UserPage: NextPage = ({ user }) => { 18 | return ( 19 |
20 | 21 | getServerSideProps Example 22 | 23 | 24 | 25 |
26 |

27 | 28 | getServerSideProps 29 | {" "} 30 | Example 31 |

32 |

User Data

33 |
34 |

35 | Name: {user.name} 36 |

37 |

38 | Age: {user.age} 39 |

40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default UserPage; 47 | 48 | const gsspRouter = createRouter< 49 | IncomingMessage & { 50 | body?: Record; 51 | params?: Record; 52 | }, 53 | ServerResponse 54 | >().get((req): GetServerSidePropsResult => { 55 | const users = getUsers(req); 56 | const user = users.find((user) => user.id === req.params?.id); 57 | if (!user) { 58 | return { 59 | notFound: true, 60 | }; 61 | } 62 | return { 63 | props: { 64 | user, 65 | }, 66 | }; 67 | }); 68 | 69 | export const getServerSideProps: GetServerSideProps = async ({ 70 | req, 71 | res, 72 | params, 73 | }) => { 74 | // @ts-ignore: attach params to req.params 75 | req.params = params; 76 | return gsspRouter.run(req, res) as Promise< 77 | GetServerSidePropsResult 78 | >; 79 | }; 80 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/gssp-users/index.tsx: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import type { 3 | GetServerSideProps, 4 | GetServerSidePropsResult, 5 | NextPage, 6 | } from "next"; 7 | import { createRouter } from "next-connect"; 8 | import ErrorPage from "next/error"; 9 | import Head from "next/head"; 10 | import Link from "next/link"; 11 | import styles from "../../styles/styles.module.css"; 12 | import { getUsers, randomId, saveUsers } from "../../utils/api"; 13 | import type { User } from "../../utils/common"; 14 | import { validateUser } from "../../utils/common"; 15 | 16 | interface PageProps { 17 | users?: User[]; 18 | error?: string; 19 | } 20 | 21 | const UsersPage: NextPage = ({ users, error }) => { 22 | if (error) return ; 23 | return ( 24 |
25 | 26 | getServerSideProps Example 27 | 28 | 29 | 30 | 31 |
32 |

33 | 34 | getServerSideProps 35 | {" "} 36 | Example 37 |

38 |

39 | Server-rendered app using{" "} 40 | 44 | getServerSideProps 45 | {" "} 46 | built with next-connect. 47 |
48 | This page works just fine{" "} 49 | 55 | without JavaScript 56 | 57 | ! 58 |

59 |
60 |

All users

61 |
62 | {users?.length ? ( 63 | users.map((user) => ( 64 | 69 |

{user.name}

70 |

{user.age}

71 | 72 | )) 73 | ) : ( 74 |

No users

75 | )} 76 |
77 |
78 |
79 |
80 |

Create users

81 | 84 | 85 | 88 | 98 | 101 |
102 |
103 |
104 |
105 | ); 106 | }; 107 | 108 | export default UsersPage; 109 | 110 | const gsspRouter = createRouter< 111 | IncomingMessage & { body?: Record }, 112 | ServerResponse 113 | >() 114 | .get((req): GetServerSidePropsResult => { 115 | const users = getUsers(req); 116 | return { props: { users } }; 117 | }) 118 | .post( 119 | async (req, res, next) => { 120 | // a middleware to parse application/x-www-form-urlencoded 121 | req.body = await new Promise((resolve, reject) => { 122 | let body = ""; 123 | req.on("error", reject); 124 | req.on("data", (chunk) => (body += chunk)); 125 | req.on("end", () => { 126 | const searchParams = new URLSearchParams(body); 127 | const result: Record = {}; 128 | for (const [key, value] of searchParams) { 129 | result[key] = value; 130 | } 131 | resolve(result); 132 | }); 133 | }); 134 | return next(); 135 | }, 136 | (req, res): GetServerSidePropsResult => { 137 | const users = getUsers(req); 138 | // parse number 139 | const newUser = { 140 | id: randomId(), 141 | ...req.body, 142 | age: Number(req.body?.age), 143 | } as User; 144 | validateUser(newUser); 145 | users.push(newUser); 146 | saveUsers(res, users); 147 | return { 148 | redirect: { 149 | destination: "/gssp-users", 150 | // https://stackoverflow.com/questions/37337412/should-i-use-a-301-302-or-303-redirect-after-form-submission 151 | statusCode: 303, 152 | }, 153 | }; 154 | } 155 | ) 156 | .all(() => { 157 | // this will be called if method is not GET or POST 158 | return { 159 | notFound: true, 160 | props: {}, 161 | }; 162 | }); 163 | 164 | export const getServerSideProps: GetServerSideProps = async ({ 165 | req, 166 | res, 167 | }) => { 168 | try { 169 | // need await so that error can be caught below 170 | return (await gsspRouter.run( 171 | req, 172 | res 173 | )) as GetServerSidePropsResult; 174 | } catch (e) { 175 | return { 176 | props: { 177 | error: (e as Error).message, 178 | }, 179 | }; 180 | } 181 | }; 182 | -------------------------------------------------------------------------------- /examples/nextjs/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from "next"; 2 | import Head from "next/head"; 3 | import Link from "next/link"; 4 | import styles from "../styles/styles.module.css"; 5 | 6 | const Home: NextPage = () => { 7 | return ( 8 |
9 | 10 | Next.js + next-connect 11 | 12 | 13 | 14 | 15 |
16 |

17 | 18 | Next.js + next-connect 19 | 20 |

21 |

22 | Get started by running{" "} 23 | npm i next-connect 24 |

25 |
26 | 27 |

GetServerSideProps

28 |

Use next-connect in getServerSideProps

29 | 30 | 31 | 32 |

API Routes

33 |

Use next-connect in API Routes

34 | 35 | 36 | 37 |

Edge API Routes

38 |

Use next-connect in Edge API Routes

39 | 40 |
41 |
42 |
43 | ); 44 | }; 45 | 46 | export default Home; 47 | -------------------------------------------------------------------------------- /examples/nextjs/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | code { 19 | padding: 4px; 20 | } 21 | -------------------------------------------------------------------------------- /examples/nextjs/src/styles/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | display: flex; 3 | justify-content: center; 4 | align-items: center; 5 | min-height: 100vh; 6 | padding: 0 0.5rem; 7 | flex-direction: column; 8 | } 9 | 10 | .main { 11 | padding: 5rem 0; 12 | flex: 1 1; 13 | flex-direction: column; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | width: 100%; 18 | } 19 | 20 | .footer { 21 | display: flex; 22 | flex: 1; 23 | padding: 2rem 0; 24 | border-top: 1px solid #eaeaea; 25 | justify-content: center; 26 | align-items: center; 27 | } 28 | 29 | .footer a { 30 | display: flex; 31 | justify-content: center; 32 | align-items: center; 33 | flex-grow: 1; 34 | } 35 | 36 | .title a { 37 | color: #0070f3; 38 | text-decoration: none; 39 | } 40 | 41 | .title a:hover, 42 | .title a:focus, 43 | .title a:active { 44 | text-decoration: underline; 45 | } 46 | 47 | .title { 48 | margin: 0; 49 | line-height: 1.15; 50 | font-size: 4rem; 51 | margin-bottom: 1rem; 52 | } 53 | 54 | .title, 55 | .description { 56 | text-align: center; 57 | } 58 | 59 | .description { 60 | margin: 1rem 0; 61 | line-height: 1.5; 62 | font-size: 1.5rem; 63 | } 64 | 65 | .code { 66 | color: rgb(212, 0, 255); 67 | font-size: 1.1rem; 68 | font-family: monospace; 69 | } 70 | 71 | .snippet { 72 | border-radius: 8px; 73 | background: #1d1f21; 74 | color: #f8f8f2; 75 | padding: 2rem 1.75rem; 76 | font-size: 0.9rem; 77 | font-family: monospace; 78 | display: block; 79 | max-width: 100%; 80 | overflow-y: scroll; 81 | } 82 | 83 | .grid { 84 | display: flex; 85 | align-items: center; 86 | justify-content: center; 87 | flex-wrap: wrap; 88 | max-width: 800px; 89 | } 90 | 91 | .card { 92 | margin: 1rem; 93 | padding: 1.5rem; 94 | text-align: left; 95 | color: inherit; 96 | text-decoration: none; 97 | border: 1px solid #eaeaea; 98 | border-radius: 10px; 99 | transition: color 0.15s ease, border-color 0.15s ease; 100 | max-width: 300px; 101 | } 102 | 103 | .card:hover, 104 | .card:focus, 105 | .card:active { 106 | color: #0070f3; 107 | border-color: #0070f3; 108 | } 109 | 110 | .card h2 { 111 | margin: 0 0 1rem 0; 112 | font-size: 1.5rem; 113 | } 114 | 115 | .card p { 116 | margin: 0; 117 | font-size: 1.25rem; 118 | line-height: 1.5; 119 | } 120 | 121 | .logo { 122 | height: 1em; 123 | margin-left: 0.5rem; 124 | } 125 | 126 | @media (max-width: 600px) { 127 | .grid { 128 | width: 100%; 129 | flex-direction: column; 130 | } 131 | } 132 | 133 | .form { 134 | display: flex; 135 | flex-direction: column; 136 | align-items: center; 137 | } 138 | 139 | .label { 140 | display: block; 141 | color: #444444; 142 | margin-bottom: 0.5rem; 143 | } 144 | 145 | .input { 146 | margin-bottom: 1rem; 147 | padding: 0.5rem 1rem; 148 | } 149 | 150 | .button { 151 | padding: 0.5rem 1rem; 152 | } 153 | -------------------------------------------------------------------------------- /examples/nextjs/src/utils/api.ts: -------------------------------------------------------------------------------- 1 | import cookie from "cookie"; 2 | import crypto from "crypto"; 3 | import type { IncomingMessage, ServerResponse } from "http"; 4 | import type { User } from "./common"; 5 | import { COOKIE_NAME } from "./common"; 6 | 7 | export const randomId = () => crypto.randomUUID(); 8 | 9 | export const getUsers = (req: IncomingMessage): User[] => { 10 | // we store all data in cookies for demo purposes 11 | const cookies = cookie.parse(req.headers.cookie || ""); 12 | if (cookies[COOKIE_NAME]) { 13 | return JSON.parse(cookies[COOKIE_NAME]); 14 | } 15 | return []; 16 | }; 17 | 18 | export const saveUsers = (res: ServerResponse, users: User[]) => { 19 | const setCookie = cookie.serialize(COOKIE_NAME, JSON.stringify(users), { 20 | path: "/", 21 | }); 22 | res.setHeader("Set-Cookie", setCookie); 23 | }; 24 | -------------------------------------------------------------------------------- /examples/nextjs/src/utils/common.ts: -------------------------------------------------------------------------------- 1 | export const COOKIE_NAME = "nc_users"; 2 | export const VIEW_COOKIE_NAME = "view_count"; 3 | export interface User { 4 | id: string; 5 | name: string; 6 | age: number; 7 | } 8 | 9 | export const validateUser = (user: User) => { 10 | user.name = user.name.trim(); 11 | if (!user.name) throw new Error("Name cannot be empty"); 12 | if (user.age < 8) { 13 | throw new Error("Aren't You a Little Young to be a Web Developer?"); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /examples/nextjs/src/utils/edge-api.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest, NextResponse } from "next/server"; 2 | import type { User } from "./common"; 3 | import { COOKIE_NAME } from "./common"; 4 | 5 | export const randomId = () => crypto.randomUUID(); 6 | 7 | export const getUsers = (req: NextRequest): User[] => { 8 | // we store all data in cookies for demo purposes 9 | const cookie = req.cookies.get(COOKIE_NAME); 10 | if (cookie) { 11 | return JSON.parse(cookie.value); 12 | } 13 | return []; 14 | }; 15 | 16 | export const saveUsers = (res: NextResponse, users: User[]) => { 17 | res.cookies.set(COOKIE_NAME, JSON.stringify(users), { 18 | path: "/", 19 | }); 20 | return res; 21 | }; 22 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-connect", 3 | "version": "1.0.0", 4 | "description": "The method routing and middleware layer for Next.js (and many others)", 5 | "keywords": [ 6 | "javascript", 7 | "nextjs", 8 | "middleware", 9 | "router", 10 | "connect" 11 | ], 12 | "type": "module", 13 | "files": [ 14 | "dist" 15 | ], 16 | "exports": { 17 | "import": { 18 | "types": "./dist/types/index.d.ts", 19 | "default": "./dist/esm/index.js" 20 | }, 21 | "require": { 22 | "types": "./dist/types/index.d.ts", 23 | "default": "./dist/commonjs/index.cjs" 24 | } 25 | }, 26 | "main": "./dist/commonjs/index.cjs", 27 | "module": "./dist/esm/index.js", 28 | "types": "./dist/types/index.d.ts", 29 | "sideEffects": false, 30 | "scripts": { 31 | "build": "tscd --entry index.js", 32 | "test": "c8 tap", 33 | "prepublishOnly": "npm run clean && npm run test && npm run build", 34 | "coverage": "c8 report --reporter=lcov", 35 | "lint": "eslint ./src --ext json,ts --ignore-path .gitignore", 36 | "format": "prettier . -w --ignore-path ./.gitignore", 37 | "clean": "rm -rf ./dist" 38 | }, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/hoangvvo/next-connect.git" 42 | }, 43 | "author": "Hoang Vo (https://www.hoangvvo.com)", 44 | "bugs": { 45 | "url": "https://github.com/hoangvvo/next-connect/issues" 46 | }, 47 | "homepage": "https://github.com/hoangvvo/next-connect#readme", 48 | "license": "MIT", 49 | "devDependencies": { 50 | "@types/node": "^18.16.3", 51 | "@types/tap": "^15.0.8", 52 | "@typescript-eslint/eslint-plugin": "^5.59.2", 53 | "@typescript-eslint/parser": "^5.59.2", 54 | "c8": "^7.13.0", 55 | "eslint": "^8.39.0", 56 | "eslint-config-prettier": "^8.8.0", 57 | "eslint-plugin-prettier": "^4.2.1", 58 | "prettier": "^2.8.8", 59 | "tap": "^16.3.4", 60 | "tinyspy": "^2.1.0", 61 | "ts-node": "^10.9.1", 62 | "tscd": "^0.0.5", 63 | "typescript": "^5.0.4" 64 | }, 65 | "tap": { 66 | "node-arg": [ 67 | "--loader", 68 | "ts-node/esm" 69 | ], 70 | "coverage": false 71 | }, 72 | "dependencies": { 73 | "@tsconfig/node16": "^1.0.3", 74 | "regexparam": "^2.0.1" 75 | }, 76 | "engines": { 77 | "node": ">=16" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/edge.ts: -------------------------------------------------------------------------------- 1 | import { Router } from "./router.js"; 2 | import type { 3 | FindResult, 4 | HandlerOptions, 5 | HttpMethod, 6 | Nextable, 7 | RouteMatch, 8 | RouteShortcutMethod, 9 | ValueOrPromise, 10 | } from "./types.js"; 11 | 12 | export type RequestHandler = ( 13 | req: Req, 14 | ctx: Ctx 15 | ) => ValueOrPromise; 16 | 17 | export class EdgeRouter { 18 | private router = new Router>(); 19 | 20 | private add( 21 | method: HttpMethod | "", 22 | route: RouteMatch | Nextable>, 23 | ...fns: Nextable>[] 24 | ) { 25 | this.router.add(method, route, ...fns); 26 | return this; 27 | } 28 | 29 | public all: RouteShortcutMethod> = 30 | this.add.bind(this, ""); 31 | public get: RouteShortcutMethod> = 32 | this.add.bind(this, "GET"); 33 | public head: RouteShortcutMethod> = 34 | this.add.bind(this, "HEAD"); 35 | public post: RouteShortcutMethod> = 36 | this.add.bind(this, "POST"); 37 | public put: RouteShortcutMethod> = 38 | this.add.bind(this, "PUT"); 39 | public patch: RouteShortcutMethod> = 40 | this.add.bind(this, "PATCH"); 41 | public delete: RouteShortcutMethod> = 42 | this.add.bind(this, "DELETE"); 43 | 44 | public use( 45 | base: 46 | | RouteMatch 47 | | Nextable> 48 | | EdgeRouter, 49 | ...fns: (Nextable> | EdgeRouter)[] 50 | ) { 51 | if (typeof base === "function" || base instanceof EdgeRouter) { 52 | fns.unshift(base); 53 | base = "/"; 54 | } 55 | this.router.use( 56 | base, 57 | ...fns.map((fn) => (fn instanceof EdgeRouter ? fn.router : fn)) 58 | ); 59 | return this; 60 | } 61 | 62 | private prepareRequest( 63 | req: Req & { params?: Record }, 64 | ctx: Ctx, 65 | findResult: FindResult> 66 | ) { 67 | req.params = { 68 | ...findResult.params, 69 | ...req.params, // original params will take precedence 70 | }; 71 | } 72 | 73 | public clone() { 74 | const r = new EdgeRouter(); 75 | r.router = this.router.clone(); 76 | return r; 77 | } 78 | 79 | async run(req: Req, ctx: Ctx) { 80 | const result = this.router.find(req.method as HttpMethod, getPathname(req)); 81 | if (!result.fns.length) return; 82 | this.prepareRequest(req, ctx, result); 83 | return Router.exec(result.fns, req, ctx); 84 | } 85 | 86 | handler(options: HandlerOptions> = {}) { 87 | const onNoMatch = options.onNoMatch || onnomatch; 88 | const onError = options.onError || onerror; 89 | return async (req: Req, ctx: Ctx): Promise => { 90 | const result = this.router.find( 91 | req.method as HttpMethod, 92 | getPathname(req) 93 | ); 94 | this.prepareRequest(req, ctx, result); 95 | try { 96 | if (result.fns.length === 0 || result.middleOnly) { 97 | return await onNoMatch(req, ctx); 98 | } else { 99 | return await Router.exec(result.fns, req, ctx); 100 | } 101 | } catch (err) { 102 | return onError(err, req, ctx); 103 | } 104 | }; 105 | } 106 | } 107 | 108 | function onnomatch(req: Request) { 109 | return new Response( 110 | req.method !== "HEAD" ? `Route ${req.method} ${req.url} not found` : null, 111 | { status: 404 } 112 | ); 113 | } 114 | function onerror(err: unknown) { 115 | console.error(err); 116 | return new Response("Internal Server Error", { status: 500 }); 117 | } 118 | 119 | export function getPathname(req: Request & { nextUrl?: URL }) { 120 | return (req.nextUrl || new URL(req.url)).pathname; 121 | } 122 | 123 | export function createEdgeRouter() { 124 | return new EdgeRouter(); 125 | } 126 | -------------------------------------------------------------------------------- /src/express.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import type { RequestHandler } from "./node.js"; 3 | import type { Nextable } from "./types.js"; 4 | 5 | type NextFunction = (err?: any) => void; 6 | 7 | type ExpressRequestHandler = ( 8 | req: Req, 9 | res: Res, 10 | next: NextFunction 11 | ) => void; 12 | 13 | export function expressWrapper< 14 | Req extends IncomingMessage, 15 | Res extends ServerResponse 16 | >(fn: ExpressRequestHandler): Nextable> { 17 | return (req, res, next) => { 18 | return new Promise((resolve, reject) => { 19 | fn(req, res, (err) => (err ? reject(err) : resolve())); 20 | }).then(next); 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { createEdgeRouter } from "./edge.js"; 2 | export { expressWrapper } from "./express.js"; 3 | export { createRouter } from "./node.js"; 4 | export type { HandlerOptions, NextHandler } from "./types.js"; 5 | -------------------------------------------------------------------------------- /src/node.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import { Router } from "./router.js"; 3 | import type { 4 | FindResult, 5 | HandlerOptions, 6 | HttpMethod, 7 | Nextable, 8 | RouteMatch, 9 | RouteShortcutMethod, 10 | ValueOrPromise, 11 | } from "./types.js"; 12 | 13 | export type RequestHandler< 14 | Req extends IncomingMessage, 15 | Res extends ServerResponse 16 | > = (req: Req, res: Res) => ValueOrPromise; 17 | 18 | export class NodeRouter< 19 | Req extends IncomingMessage = IncomingMessage, 20 | Res extends ServerResponse = ServerResponse 21 | > { 22 | private router = new Router>(); 23 | 24 | private add( 25 | method: HttpMethod | "", 26 | route: RouteMatch | Nextable>, 27 | ...fns: Nextable>[] 28 | ) { 29 | this.router.add(method, route, ...fns); 30 | return this; 31 | } 32 | 33 | public all: RouteShortcutMethod> = 34 | this.add.bind(this, ""); 35 | public get: RouteShortcutMethod> = 36 | this.add.bind(this, "GET"); 37 | public head: RouteShortcutMethod> = 38 | this.add.bind(this, "HEAD"); 39 | public post: RouteShortcutMethod> = 40 | this.add.bind(this, "POST"); 41 | public put: RouteShortcutMethod> = 42 | this.add.bind(this, "PUT"); 43 | public patch: RouteShortcutMethod> = 44 | this.add.bind(this, "PATCH"); 45 | public delete: RouteShortcutMethod> = 46 | this.add.bind(this, "DELETE"); 47 | 48 | public use( 49 | base: 50 | | RouteMatch 51 | | Nextable> 52 | | NodeRouter, 53 | ...fns: (Nextable> | NodeRouter)[] 54 | ) { 55 | if (typeof base === "function" || base instanceof NodeRouter) { 56 | fns.unshift(base); 57 | base = "/"; 58 | } 59 | this.router.use( 60 | base, 61 | ...fns.map((fn) => (fn instanceof NodeRouter ? fn.router : fn)) 62 | ); 63 | return this; 64 | } 65 | 66 | private prepareRequest( 67 | req: Req & { params?: Record }, 68 | res: Res, 69 | findResult: FindResult> 70 | ) { 71 | req.params = { 72 | ...findResult.params, 73 | ...req.params, // original params will take precedence 74 | }; 75 | } 76 | 77 | public clone() { 78 | const r = new NodeRouter(); 79 | r.router = this.router.clone(); 80 | return r; 81 | } 82 | 83 | async run(req: Req, res: Res) { 84 | const result = this.router.find( 85 | req.method as HttpMethod, 86 | getPathname(req.url as string) 87 | ); 88 | if (!result.fns.length) return; 89 | this.prepareRequest(req, res, result); 90 | return Router.exec(result.fns, req, res); 91 | } 92 | 93 | handler(options: HandlerOptions> = {}) { 94 | const onNoMatch = options.onNoMatch || onnomatch; 95 | const onError = options.onError || onerror; 96 | return async (req: Req, res: Res) => { 97 | const result = this.router.find( 98 | req.method as HttpMethod, 99 | getPathname(req.url as string) 100 | ); 101 | this.prepareRequest(req, res, result); 102 | try { 103 | if (result.fns.length === 0 || result.middleOnly) { 104 | await onNoMatch(req, res); 105 | } else { 106 | await Router.exec(result.fns, req, res); 107 | } 108 | } catch (err) { 109 | await onError(err, req, res); 110 | } 111 | }; 112 | } 113 | } 114 | 115 | function onnomatch(req: IncomingMessage, res: ServerResponse) { 116 | res.statusCode = 404; 117 | res.end( 118 | req.method !== "HEAD" 119 | ? `Route ${req.method} ${req.url} not found` 120 | : undefined 121 | ); 122 | } 123 | 124 | function onerror(err: unknown, req: IncomingMessage, res: ServerResponse) { 125 | res.statusCode = 500; 126 | console.error(err); 127 | res.end("Internal Server Error"); 128 | } 129 | 130 | export function getPathname(url: string) { 131 | const queryIdx = url.indexOf("?"); 132 | return queryIdx !== -1 ? url.substring(0, queryIdx) : url; 133 | } 134 | 135 | export function createRouter< 136 | Req extends IncomingMessage, 137 | Res extends ServerResponse 138 | >() { 139 | return new NodeRouter(); 140 | } 141 | -------------------------------------------------------------------------------- /src/regexparam.d.ts: -------------------------------------------------------------------------------- 1 | declare module "regexparam" { 2 | export function parse( 3 | route: string | RegExp, 4 | loose?: boolean 5 | ): { 6 | keys: string[] | false; 7 | pattern: RegExp; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Agnostic router class 3 | * Adapted from lukeed/trouter library: 4 | * https://github.com/lukeed/trouter/blob/master/index.mjs 5 | */ 6 | import { parse } from "regexparam"; 7 | import type { 8 | FindResult, 9 | FunctionLike, 10 | HttpMethod, 11 | Nextable, 12 | RouteMatch, 13 | } from "./types.js"; 14 | 15 | export type Route = { 16 | method: HttpMethod | ""; 17 | fns: (H | Router)[]; 18 | isMiddle: boolean; 19 | } & ( 20 | | { 21 | keys: string[] | false; 22 | pattern: RegExp; 23 | } 24 | | { matchAll: true } 25 | ); 26 | 27 | export class Router { 28 | constructor( 29 | public base: string = "/", 30 | public routes: Route>[] = [] 31 | ) {} 32 | public add( 33 | method: HttpMethod | "", 34 | route: RouteMatch | Nextable, 35 | ...fns: Nextable[] 36 | ): this { 37 | if (typeof route === "function") { 38 | fns.unshift(route); 39 | route = ""; 40 | } 41 | if (route === "") 42 | this.routes.push({ matchAll: true, method, fns, isMiddle: false }); 43 | else { 44 | const { keys, pattern } = parse(route); 45 | this.routes.push({ keys, pattern, method, fns, isMiddle: false }); 46 | } 47 | return this; 48 | } 49 | 50 | public use( 51 | base: RouteMatch | Nextable | Router, 52 | ...fns: (Nextable | Router)[] 53 | ) { 54 | if (typeof base === "function" || base instanceof Router) { 55 | fns.unshift(base); 56 | base = "/"; 57 | } 58 | // mount subrouter 59 | fns = fns.map((fn) => { 60 | if (fn instanceof Router) { 61 | if (typeof base === "string") return fn.clone(base); 62 | throw new Error("Mounting a router to RegExp base is not supported"); 63 | } 64 | return fn; 65 | }); 66 | const { keys, pattern } = parse(base, true); 67 | this.routes.push({ keys, pattern, method: "", fns, isMiddle: true }); 68 | return this; 69 | } 70 | 71 | public clone(base?: string) { 72 | return new Router(base, Array.from(this.routes)); 73 | } 74 | 75 | static async exec( 76 | fns: Nextable[], 77 | ...args: Parameters 78 | ): Promise { 79 | let i = 0; 80 | const next = () => fns[++i](...args, next); 81 | return fns[i](...args, next); 82 | } 83 | 84 | find(method: HttpMethod, pathname: string): FindResult { 85 | let middleOnly = true; 86 | const fns: Nextable[] = []; 87 | const params: Record = {}; 88 | const isHead = method === "HEAD"; 89 | for (const route of this.routes) { 90 | if ( 91 | route.method !== method && 92 | // matches any method 93 | route.method !== "" && 94 | // The HEAD method requests that the target resource transfer a representation of its state, as for a GET request... 95 | !(isHead && route.method === "GET") 96 | ) { 97 | continue; 98 | } 99 | let matched = false; 100 | if ("matchAll" in route) { 101 | matched = true; 102 | } else { 103 | if (route.keys === false) { 104 | // routes.key is RegExp: https://github.com/lukeed/regexparam/blob/master/src/index.js#L2 105 | const matches = route.pattern.exec(pathname); 106 | if (matches === null) continue; 107 | if (matches.groups !== void 0) 108 | for (const k in matches.groups) params[k] = matches.groups[k]; 109 | matched = true; 110 | } else if (route.keys.length > 0) { 111 | const matches = route.pattern.exec(pathname); 112 | if (matches === null) continue; 113 | for (let j = 0; j < route.keys.length; ) 114 | params[route.keys[j]] = matches[++j]; 115 | matched = true; 116 | } else if (route.pattern.test(pathname)) { 117 | matched = true; 118 | } // else not a match 119 | } 120 | if (matched) { 121 | fns.push( 122 | ...route.fns 123 | .map((fn) => { 124 | if (fn instanceof Router) { 125 | const base = fn.base as string; 126 | let stripPathname = pathname.substring(base.length); 127 | // fix stripped pathname, not sure why this happens 128 | if (stripPathname[0] != "/") 129 | stripPathname = `/${stripPathname}`; 130 | const result = fn.find(method, stripPathname); 131 | if (!result.middleOnly) middleOnly = false; 132 | // merge params 133 | Object.assign(params, result.params); 134 | return result.fns; 135 | } 136 | return fn; 137 | }) 138 | .flat() 139 | ); 140 | if (!route.isMiddle) middleOnly = false; 141 | } 142 | } 143 | return { fns, params, middleOnly }; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; 2 | 3 | export type FunctionLike = (...args: any[]) => unknown; 4 | 5 | export type RouteMatch = string | RegExp; 6 | 7 | export type NextHandler = () => ValueOrPromise; 8 | 9 | export type Nextable = ( 10 | ...args: [...Parameters, NextHandler] 11 | ) => ValueOrPromise; 12 | 13 | export type FindResult = { 14 | fns: Nextable[]; 15 | params: Record; 16 | middleOnly: boolean; 17 | }; 18 | 19 | export interface HandlerOptions { 20 | onNoMatch?: Handler; 21 | onError?: (err: unknown, ...args: Parameters) => ReturnType; 22 | } 23 | 24 | export type ValueOrPromise = T | Promise; 25 | 26 | export type RouteShortcutMethod = ( 27 | route: RouteMatch | Nextable, 28 | ...fns: Nextable[] 29 | ) => This; 30 | -------------------------------------------------------------------------------- /test/edge.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "tap"; 2 | import { spyOn } from "tinyspy"; 3 | import { createEdgeRouter, EdgeRouter, getPathname } from "../src/edge.js"; 4 | import { Router } from "../src/router.js"; 5 | 6 | type AnyHandler = (...args: any[]) => any; 7 | 8 | const noop: AnyHandler = async () => { 9 | /** noop */ 10 | }; 11 | 12 | const METHODS = ["GET", "HEAD", "PATCH", "DELETE", "POST", "PUT"]; 13 | 14 | test("internals", (t) => { 15 | const ctx = new EdgeRouter(); 16 | t.ok(ctx instanceof EdgeRouter, "creates new `EdgeRouter` instance"); 17 | // @ts-expect-error: internal 18 | t.ok(ctx.router instanceof Router, "~> has a `Router` instance"); 19 | 20 | t.type(ctx.all, "function", "~> has `all` method"); 21 | METHODS.forEach((str) => { 22 | t.type(ctx[str.toLowerCase()], "function", `~> has \`${str}\` method`); 23 | }); 24 | t.end(); 25 | }); 26 | 27 | test("createEdgeRouter() returns an instance", async (t) => { 28 | t.ok(createEdgeRouter() instanceof EdgeRouter); 29 | }); 30 | 31 | test("add()", async (t) => { 32 | const ctx = new EdgeRouter(); 33 | // @ts-expect-error: private property 34 | const routerAddStub = spyOn(ctx.router, "add"); 35 | // @ts-expect-error: private property 36 | const returned = ctx.add("GET", "/", noop); 37 | t.same(routerAddStub.calls, [["GET", "/", noop]], "call router.add()"); 38 | t.equal(returned, ctx, "returned itself"); 39 | }); 40 | 41 | test("use()", async (t) => { 42 | t.test("it defaults to / if base is not provided", async (t) => { 43 | const ctx = new EdgeRouter(); 44 | 45 | // @ts-expect-error: private field 46 | const useSpy = spyOn(ctx.router, "use"); 47 | 48 | ctx.use(noop); 49 | 50 | t.same(useSpy.calls, [["/", noop]]); 51 | }); 52 | 53 | t.test("it call this.router.use() with fn", async (t) => { 54 | const ctx = new EdgeRouter(); 55 | 56 | // @ts-expect-error: private field 57 | const useSpy = spyOn(ctx.router, "use"); 58 | 59 | ctx.use("/test", noop, noop); 60 | 61 | t.same(useSpy.calls, [["/test", noop, noop]]); 62 | }); 63 | 64 | t.test("it call this.router.use() with fn.router", async (t) => { 65 | const ctx = new EdgeRouter(); 66 | const ctx2 = new EdgeRouter(); 67 | 68 | // @ts-expect-error: private field 69 | const useSpy = spyOn(ctx.router, "use"); 70 | 71 | ctx.use("/test", ctx2, ctx2); 72 | 73 | // @ts-expect-error: private field 74 | t.same(useSpy.calls, [["/test", ctx2.router, ctx2.router]]); 75 | }); 76 | }); 77 | 78 | test("clone()", (t) => { 79 | const ctx = new EdgeRouter(); 80 | // @ts-expect-error: private property 81 | ctx.router.routes = [noop, noop] as any[]; 82 | t.ok(ctx.clone() instanceof EdgeRouter, "is a NodeRouter instance"); 83 | t.not(ctx, ctx.clone(), "not the same identity"); 84 | // @ts-expect-error: private property 85 | t.not(ctx.router, ctx.clone().router, "not the same router identity"); 86 | t.not( 87 | // @ts-expect-error: private property 88 | ctx.router.routes, 89 | // @ts-expect-error: private property 90 | ctx.clone().router.routes, 91 | "routes are deep cloned (identity)" 92 | ); 93 | t.same( 94 | // @ts-expect-error: private property 95 | ctx.router.routes, 96 | // @ts-expect-error: private property 97 | ctx.clone().router.routes, 98 | "routes are deep cloned" 99 | ); 100 | t.end(); 101 | }); 102 | 103 | test("run() - runs req and evt through fns and return last value", async (t) => { 104 | t.plan(7); 105 | const ctx = createEdgeRouter(); 106 | const req = { url: "http://localhost/foo/bar", method: "POST" } as Request; 107 | const evt = {}; 108 | const badFn = () => t.fail("test error"); 109 | ctx.use("/", (reqq, evtt, next) => { 110 | t.equal(reqq, req, "passes along req"); 111 | t.equal(evtt, evt, "passes along evt"); 112 | return next(); 113 | }); 114 | ctx.use("/not/match", badFn); 115 | ctx.get("/", badFn); 116 | ctx.get("/foo/bar", badFn); 117 | ctx.post("/foo/bar", async (reqq, evtt, next) => { 118 | t.equal(reqq, req, "passes along req"); 119 | t.equal(evtt, evt, "passes along evt"); 120 | return next(); 121 | }); 122 | ctx.use("/foo", (reqq, evtt) => { 123 | t.equal(reqq, req, "passes along req"); 124 | t.equal(evtt, evt, "passes along evt"); 125 | return "ok"; 126 | }); 127 | t.equal(await ctx.run(req, evt), "ok"); 128 | }); 129 | 130 | test("run() - propagates error", async (t) => { 131 | const req = { url: "http://localhost/", method: "GET" } as Request; 132 | const evt = {}; 133 | const err = new Error("💥"); 134 | await t.rejects( 135 | () => 136 | createEdgeRouter() 137 | .use((_, __, next) => { 138 | next(); 139 | }) 140 | .use(() => { 141 | throw err; 142 | }) 143 | .run(req, evt), 144 | err 145 | ); 146 | 147 | await t.rejects( 148 | () => 149 | createEdgeRouter() 150 | .use((_, __, next) => { 151 | return next(); 152 | }) 153 | .use(async () => { 154 | throw err; 155 | }) 156 | .run(req, evt), 157 | err 158 | ); 159 | 160 | await t.rejects( 161 | () => 162 | createEdgeRouter() 163 | .use((_, __, next) => { 164 | return next(); 165 | }) 166 | .use(async (_, __, next) => { 167 | await next(); 168 | }) 169 | .use(() => Promise.reject(err)) 170 | .run(req, evt), 171 | err 172 | ); 173 | }); 174 | 175 | test("run() - returns if no fns", async (t) => { 176 | const req = { url: "http://localhost/foo/bar", method: "GET" } as Request; 177 | const evt = {}; 178 | const ctx = createEdgeRouter(); 179 | const badFn = () => t.fail("test error"); 180 | ctx.get("/foo", badFn); 181 | ctx.post("/foo/bar", badFn); 182 | ctx.use("/bar", badFn); 183 | return t 184 | .resolves(() => ctx.run(req, evt)) 185 | .then((val) => t.equal(val, undefined)); 186 | }); 187 | 188 | test("handler() - basic", async (t) => { 189 | t.type(createEdgeRouter().handler(), "function", "returns a function"); 190 | }); 191 | 192 | test("handler() - handles incoming and returns value (sync)", async (t) => { 193 | t.plan(4); 194 | const response = new Response(""); 195 | let i = 0; 196 | const req = { method: "GET", url: "http://localhost/" } as Request; 197 | const badFn = () => t.fail("test error"); 198 | const res = await createEdgeRouter() 199 | .use((req, evt, next) => { 200 | t.equal(++i, 1); 201 | return next(); 202 | }) 203 | .use((req, evt, next) => { 204 | t.equal(++i, 2); 205 | return next(); 206 | }) 207 | .post(badFn) 208 | .get("/not/match", badFn) 209 | .get(() => { 210 | t.equal(++i, 3); 211 | return response; 212 | }) 213 | .handler()(req, {}); 214 | t.equal(res, response, "resolve with response (sync)"); 215 | }); 216 | 217 | test("handler() - handles incoming and returns value (async)", async (t) => { 218 | t.plan(4); 219 | const response = new Response(""); 220 | let i = 0; 221 | const req = { method: "GET", url: "http://localhost/" } as Request; 222 | const badFn = () => t.fail("test error"); 223 | const res = await createEdgeRouter() 224 | .use(async (req, evt, next) => { 225 | t.equal(++i, 1); 226 | const val = await next(); 227 | return val; 228 | }) 229 | .use((req, evt, next) => { 230 | t.equal(++i, 2); 231 | return next(); 232 | }) 233 | .post(badFn) 234 | .get("/not/match", badFn) 235 | .get(async () => { 236 | t.equal(++i, 3); 237 | return response; 238 | }) 239 | .handler()(req, {}); 240 | t.equal(res, response, "resolve with response (async)"); 241 | }); 242 | 243 | test("handler() - calls onError if error thrown (sync)", async (t) => { 244 | t.plan(3 * 3); 245 | const error = new Error("💥"); 246 | const consoleSpy = spyOn(globalThis.console, "error", () => undefined); 247 | 248 | const badFn = () => t.fail("test error"); 249 | const baseFn = (req: Request, evt: unknown, next: any) => { 250 | return next(); 251 | }; 252 | 253 | let idx = 0; 254 | const testResponse = async (response: Response) => { 255 | t.equal(response.status, 500, "set 500 status code"); 256 | t.equal(await response.text(), "Internal Server Error"); 257 | t.same(consoleSpy.calls[idx], [error], `called console.error ${idx}`); 258 | idx += 1; 259 | }; 260 | 261 | const req = { method: "GET", url: "http://localhost/" } as Request; 262 | await createEdgeRouter() 263 | .use(baseFn) 264 | .use(() => { 265 | throw error; 266 | }) 267 | .get(badFn) 268 | .handler()(req, {}) 269 | .then(testResponse); 270 | await createEdgeRouter() 271 | .use(baseFn) 272 | .use((req, evt, next) => { 273 | next(); 274 | }) 275 | .get(() => { 276 | throw error; 277 | }) 278 | .handler()(req, {}) 279 | .then(testResponse); 280 | 281 | await createEdgeRouter() 282 | .use(baseFn) 283 | .get(() => { 284 | // non error throw 285 | throw ""; 286 | }) 287 | .handler()(req, {}) 288 | .then(async (res: Response) => { 289 | t.equal(res.status, 500); 290 | t.equal(await res.text(), "Internal Server Error"); 291 | t.same(consoleSpy.calls[idx], [""], `called console.error with ""`); 292 | }); 293 | 294 | consoleSpy.restore(); 295 | }); 296 | 297 | test("handler() - calls onError if error thrown (async)", async (t) => { 298 | t.plan(2 * 3); 299 | const error = new Error("💥"); 300 | const consoleSpy = spyOn(globalThis.console, "error", () => undefined); 301 | 302 | const badFn = () => t.fail("test error"); 303 | 304 | let idx = 0; 305 | const testResponse = async (response: Response) => { 306 | t.equal(response.status, 500, "set 500 status code"); 307 | t.equal(await response.text(), "Internal Server Error"); 308 | t.same(consoleSpy.calls[idx], [error], `called console.error ${idx}`); 309 | idx += 1; 310 | }; 311 | 312 | const req = { method: "GET", url: "http://localhost/" } as Request; 313 | 314 | const baseFn = (req: Request, evt: unknown, next: any) => { 315 | return next(); 316 | }; 317 | await createEdgeRouter() 318 | .use(baseFn) 319 | .use(async () => { 320 | return Promise.reject(error); 321 | }) 322 | .get(badFn) 323 | .handler()(req, {}) 324 | .then(testResponse); 325 | await createEdgeRouter() 326 | .use(baseFn) 327 | .get(() => { 328 | throw error; 329 | }) 330 | .handler()(req, {}) 331 | .then(testResponse); 332 | 333 | consoleSpy.restore(); 334 | }); 335 | 336 | test("handler() - calls custom onError", async (t) => { 337 | t.plan(1); 338 | await createEdgeRouter() 339 | .get(() => { 340 | throw new Error("💥"); 341 | }) 342 | .handler({ 343 | onError(err) { 344 | t.equal((err as Error).message, "💥"); 345 | }, 346 | })({ method: "GET", url: "http://localhost/" } as Request, {}); 347 | }); 348 | 349 | test("handler() - calls onNoMatch if no fns matched", async (t) => { 350 | t.plan(2); 351 | const req = { url: "http://localhost/foo/bar", method: "GET" } as Request; 352 | const res: Response = await createEdgeRouter() 353 | .get("/foo") 354 | .post("/foo/bar") 355 | .handler()(req, {}); 356 | t.equal(res.status, 404); 357 | t.equal(await res.text(), "Route GET http://localhost/foo/bar not found"); 358 | }); 359 | 360 | test("handler() - calls onNoMatch if only middle fns found", async (t) => { 361 | t.plan(2); 362 | const badFn = () => t.fail("test error"); 363 | const req = { url: "http://localhost/foo/bar", method: "GET" } as Request; 364 | const res: Response = await createEdgeRouter() 365 | .use("", badFn) 366 | .use("/foo", badFn) 367 | .handler()(req, {}); 368 | t.equal(res.status, 404); 369 | t.equal(await res.text(), "Route GET http://localhost/foo/bar not found"); 370 | }); 371 | 372 | test("handler() - calls onNoMatch if no fns matched (HEAD)", async (t) => { 373 | t.plan(2); 374 | const req = { url: "http://localhost/foo/bar", method: "HEAD" } as Request; 375 | const res: Response = await createEdgeRouter() 376 | .get("/foo") 377 | .post("/foo/bar") 378 | .handler()(req, {}); 379 | t.equal(res.status, 404); 380 | t.equal(await res.text(), ""); 381 | }); 382 | 383 | test("handler() - calls custom onNoMatch if not found", async (t) => { 384 | t.plan(1); 385 | await createEdgeRouter().handler({ 386 | onNoMatch() { 387 | t.pass("onNoMatch called"); 388 | }, 389 | })({ url: "http://localhost/foo/bar", method: "GET" } as Request, {}); 390 | }); 391 | 392 | test("handler() - calls onError if custom onNoMatch throws", async (t) => { 393 | t.plan(2); 394 | await createEdgeRouter().handler({ 395 | onNoMatch() { 396 | t.pass("onNoMatch called"); 397 | throw new Error("💥"); 398 | }, 399 | onError(err) { 400 | t.equal((err as Error).message, "💥"); 401 | }, 402 | })({ url: "http://localhost/foo/bar", method: "GET" } as Request, {}); 403 | }); 404 | 405 | test("prepareRequest() - attach params", async (t) => { 406 | const req = {} as Request & { params?: Record }; 407 | 408 | const ctx2 = createEdgeRouter().get("/hello/:name"); 409 | // @ts-expect-error: internal 410 | ctx2.prepareRequest(req, {}, ctx2.router.find("GET", "/hello/world")); 411 | t.same(req.params, { name: "world" }, "params are attached"); 412 | 413 | const reqWithParams = { 414 | params: { age: "20" }, 415 | }; 416 | // @ts-expect-error: internal 417 | ctx2.prepareRequest( 418 | reqWithParams as unknown as Request, 419 | {}, 420 | // @ts-expect-error: internal 421 | ctx2.router.find("GET", "/hello/world") 422 | ); 423 | t.same( 424 | reqWithParams.params, 425 | { name: "world", age: "20" }, 426 | "params are merged" 427 | ); 428 | 429 | const reqWithParams2 = { 430 | params: { name: "sunshine" }, 431 | }; 432 | // @ts-expect-error: internal 433 | ctx2.prepareRequest( 434 | reqWithParams2 as unknown as Request, 435 | {}, 436 | // @ts-expect-error: internal 437 | ctx2.router.find("GET", "/hello/world") 438 | ); 439 | t.same( 440 | reqWithParams2.params, 441 | { name: "sunshine" }, 442 | "params are merged (existing takes precedence)" 443 | ); 444 | }); 445 | 446 | test("getPathname() - returns pathname correctly", async (t) => { 447 | t.equal( 448 | getPathname({ url: "http://google.com/foo/bar" } as Request), 449 | "/foo/bar" 450 | ); 451 | t.equal( 452 | getPathname({ url: "http://google.com/foo/bar?q=quz" } as Request), 453 | "/foo/bar" 454 | ); 455 | t.equal( 456 | getPathname({ 457 | url: "http://google.com/do/not/use/me", 458 | nextUrl: new URL("http://google.com/foo/bar?q=quz"), 459 | } as unknown as Request), 460 | "/foo/bar", 461 | "get pathname using req.nextUrl" 462 | ); 463 | }); 464 | -------------------------------------------------------------------------------- /test/express.test.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import { test } from "tap"; 3 | import { expressWrapper } from "../src/express.js"; 4 | import { NodeRouter } from "../src/node.js"; 5 | 6 | test("expressWrapper", async (t) => { 7 | const req = { url: "/" } as IncomingMessage; 8 | const res = {} as ServerResponse; 9 | 10 | t.test("basic", async (t) => { 11 | t.plan(3); 12 | const ctx = new NodeRouter(); 13 | const midd = (reqq, ress, next) => { 14 | t.same(reqq, req, "called with req"); 15 | t.same(ress, res, "called with res"); 16 | next(); 17 | }; 18 | ctx.use(expressWrapper(midd)).use(() => "ok"); 19 | t.same(await ctx.run(req, res), "ok", "returned the last value"); 20 | }); 21 | 22 | t.test("next()", async (t) => { 23 | t.plan(2); 24 | const ctx = new NodeRouter(); 25 | const midd = (reqq, ress, next) => { 26 | next(); 27 | }; 28 | ctx.use(expressWrapper(midd)).use(async () => "ok"); 29 | t.same(await ctx.run(req, res), "ok", "returned the last value"); 30 | 31 | const ctx2 = new NodeRouter(); 32 | const err = new Error("💥"); 33 | ctx2.use(expressWrapper(midd)).use(async () => { 34 | throw err; 35 | }); 36 | t.rejects(() => ctx2.run(req, res), err, "throws async error"); 37 | }); 38 | 39 | t.test("next(err)", async (t) => { 40 | const err = new Error("💥"); 41 | const ctx = new NodeRouter(); 42 | const midd = (reqq, ress, next) => { 43 | next(err); 44 | }; 45 | ctx.use(expressWrapper(midd)).use(async () => "ok"); 46 | t.rejects( 47 | () => ctx.run(req, res), 48 | err, 49 | "throws error called with next(err)" 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "tap"; 2 | import { createRouter } from "../src/index.js"; 3 | 4 | test("imports", async (t) => { 5 | t.ok(createRouter); 6 | }); 7 | -------------------------------------------------------------------------------- /test/node.test.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse } from "http"; 2 | import { test } from "tap"; 3 | import { spyOn } from "tinyspy"; 4 | import { createRouter, getPathname, NodeRouter } from "../src/node.js"; 5 | import { Router } from "../src/router.js"; 6 | 7 | type AnyHandler = (...args: any[]) => any; 8 | 9 | const noop: AnyHandler = async () => { 10 | /** noop */ 11 | }; 12 | 13 | const METHODS = ["GET", "HEAD", "PATCH", "DELETE", "POST", "PUT"]; 14 | 15 | test("internals", (t) => { 16 | const ctx = new NodeRouter(); 17 | t.ok(ctx instanceof NodeRouter, "creates new `NodeRouter` instance"); 18 | // @ts-expect-error: internal 19 | t.ok(ctx.router instanceof Router, "~> has a `Router` instance"); 20 | 21 | t.type(ctx.all, "function", "~> has `all` method"); 22 | METHODS.forEach((str) => { 23 | t.type(ctx[str.toLowerCase()], "function", `~> has \`${str}\` method`); 24 | }); 25 | t.end(); 26 | }); 27 | 28 | test("createRouter() returns an instance", async (t) => { 29 | t.ok(createRouter() instanceof NodeRouter); 30 | }); 31 | 32 | test("add()", async (t) => { 33 | const ctx = new NodeRouter(); 34 | // @ts-expect-error: private property 35 | const routerAddStub = spyOn(ctx.router, "add"); 36 | // @ts-expect-error: private property 37 | const returned = ctx.add("GET", "/", noop); 38 | t.same(routerAddStub.calls, [["GET", "/", noop]], "call router.add()"); 39 | t.equal(returned, ctx, "returned itself"); 40 | }); 41 | 42 | test("use()", async (t) => { 43 | t.test("it defaults to / if base is not provided", async (t) => { 44 | const ctx = new NodeRouter(); 45 | 46 | // @ts-expect-error: private field 47 | const useSpy = spyOn(ctx.router, "use"); 48 | 49 | ctx.use(noop); 50 | 51 | t.same(useSpy.calls, [["/", noop]]); 52 | }); 53 | 54 | t.test("it call this.router.use() with fn", async (t) => { 55 | const ctx = new NodeRouter(); 56 | 57 | // @ts-expect-error: private field 58 | const useSpy = spyOn(ctx.router, "use"); 59 | 60 | ctx.use("/test", noop, noop); 61 | 62 | t.same(useSpy.calls, [["/test", noop, noop]]); 63 | }); 64 | 65 | t.test("it call this.router.use() with fn.router", async (t) => { 66 | const ctx = new NodeRouter(); 67 | const ctx2 = new NodeRouter(); 68 | 69 | // @ts-expect-error: private field 70 | const useSpy = spyOn(ctx.router, "use"); 71 | 72 | ctx.use("/test", ctx2, ctx2); 73 | 74 | // @ts-expect-error: private field 75 | t.same(useSpy.calls, [["/test", ctx2.router, ctx2.router]]); 76 | }); 77 | }); 78 | 79 | test("clone()", (t) => { 80 | const ctx = new NodeRouter(); 81 | // @ts-expect-error: private property 82 | ctx.router.routes = [noop, noop] as any[]; 83 | t.ok(ctx.clone() instanceof NodeRouter, "is a NodeRouter instance"); 84 | t.not(ctx, ctx.clone(), "not the same identity"); 85 | // @ts-expect-error: private property 86 | t.not(ctx.router, ctx.clone().router, "not the same router identity"); 87 | t.not( 88 | // @ts-expect-error: private property 89 | ctx.router.routes, 90 | // @ts-expect-error: private property 91 | ctx.clone().router.routes, 92 | "routes are deep cloned (identity)" 93 | ); 94 | t.same( 95 | // @ts-expect-error: private property 96 | ctx.router.routes, 97 | // @ts-expect-error: private property 98 | ctx.clone().router.routes, 99 | "routes are deep cloned" 100 | ); 101 | t.end(); 102 | }); 103 | 104 | test("run() - runs req and res through fns and return last value", async (t) => { 105 | t.plan(7); 106 | const ctx = createRouter(); 107 | const req = { url: "/foo/bar", method: "POST" } as IncomingMessage; 108 | const res = {} as ServerResponse; 109 | const badFn = () => t.fail("test error"); 110 | ctx.use("/", (reqq, ress, next) => { 111 | t.equal(reqq, req, "passes along req"); 112 | t.equal(ress, res, "passes along req"); 113 | return next(); 114 | }); 115 | ctx.use("/not/match", badFn); 116 | ctx.get("/", badFn); 117 | ctx.get("/foo/bar", badFn); 118 | ctx.post("/foo/bar", async (reqq, ress, next) => { 119 | t.equal(reqq, req, "passes along req"); 120 | t.equal(ress, res, "passes along req"); 121 | return next(); 122 | }); 123 | ctx.use("/foo", (reqq, ress) => { 124 | t.equal(reqq, req, "passes along req"); 125 | t.equal(ress, res, "passes along req"); 126 | return "ok"; 127 | }); 128 | t.equal(await ctx.run(req, res), "ok"); 129 | }); 130 | 131 | test("run() - propagates error", async (t) => { 132 | const req = { url: "/", method: "GET" } as IncomingMessage; 133 | const res = {} as ServerResponse; 134 | const err = new Error("💥"); 135 | await t.rejects( 136 | () => 137 | createRouter() 138 | .use((_, __, next) => { 139 | next(); 140 | }) 141 | .use(() => { 142 | throw err; 143 | }) 144 | .run(req, res), 145 | err 146 | ); 147 | 148 | await t.rejects( 149 | () => 150 | createRouter() 151 | .use((_, __, next) => { 152 | return next(); 153 | }) 154 | .use(async () => { 155 | throw err; 156 | }) 157 | .run(req, res), 158 | err 159 | ); 160 | 161 | await t.rejects( 162 | () => 163 | createRouter() 164 | .use((_, __, next) => { 165 | return next(); 166 | }) 167 | .use(async (_, __, next) => { 168 | await next(); 169 | }) 170 | .use(() => Promise.reject(err)) 171 | .run(req, res), 172 | err 173 | ); 174 | }); 175 | 176 | test("run() - returns if no fns", async (t) => { 177 | const req = { url: "/foo/bar", method: "GET" } as IncomingMessage; 178 | const res = {} as ServerResponse; 179 | const ctx = createRouter(); 180 | const badFn = () => t.fail("test error"); 181 | ctx.get("/foo", badFn); 182 | ctx.post("/foo/bar", badFn); 183 | ctx.use("/bar", badFn); 184 | return t 185 | .resolves(() => ctx.run(req, res)) 186 | .then((val) => t.equal(val, undefined)); 187 | }); 188 | 189 | test("handler() - basic", async (t) => { 190 | t.type(createRouter().handler(), "function", "returns a function"); 191 | }); 192 | 193 | test("handler() - handles incoming (sync)", async (t) => { 194 | t.plan(3); 195 | let i = 0; 196 | const req = { method: "GET", url: "/" } as IncomingMessage; 197 | const res = {} as ServerResponse; 198 | const badFn = () => t.fail("test error"); 199 | await createRouter() 200 | .use((req, res, next) => { 201 | t.equal(++i, 1); 202 | next(); 203 | }) 204 | .use((req, res, next) => { 205 | t.equal(++i, 2); 206 | next(); 207 | }) 208 | .post(badFn) 209 | .get("/not/match", badFn) 210 | .get(() => { 211 | t.equal(++i, 3); 212 | }) 213 | .handler()(req, res); 214 | }); 215 | 216 | test("handler() - handles incoming (async)", async (t) => { 217 | t.plan(3); 218 | let i = 0; 219 | const req = { method: "GET", url: "/" } as IncomingMessage; 220 | const res = {} as ServerResponse; 221 | const badFn = () => t.fail("test error"); 222 | await createRouter() 223 | .use(async (req, res, next) => { 224 | t.equal(++i, 1); 225 | await next(); 226 | }) 227 | .use((req, res, next) => { 228 | t.equal(++i, 2); 229 | return next(); 230 | }) 231 | .post(badFn) 232 | .get("/not/match", badFn) 233 | .get(async () => { 234 | t.equal(++i, 3); 235 | }) 236 | .handler()(req, res); 237 | }); 238 | 239 | test("handler() - calls onError if error thrown (sync)", async (t) => { 240 | t.plan(3 * 3); 241 | const error = new Error("💥"); 242 | const consoleSpy = spyOn(globalThis.console, "error", () => undefined); 243 | 244 | const badFn = () => t.fail("test error"); 245 | const baseFn = (req: IncomingMessage, res: ServerResponse, next: any) => { 246 | res.statusCode = 200; 247 | return next(); 248 | }; 249 | 250 | let idx = 0; 251 | 252 | const req = { method: "GET", url: "/" } as IncomingMessage; 253 | const res = { 254 | end(chunk) { 255 | t.equal(this.statusCode, 500, "set 500 status code"); 256 | t.equal(chunk, "Internal Server Error"); 257 | t.same(consoleSpy.calls[idx], [error], `called console.error ${idx}`); 258 | idx += 1; 259 | }, 260 | } as ServerResponse; 261 | await createRouter() 262 | .use(baseFn) 263 | .use(() => { 264 | throw error; 265 | }) 266 | .get(badFn) 267 | .handler()(req, res); 268 | await createRouter() 269 | .use(baseFn) 270 | .use((req, res, next) => { 271 | next(); 272 | }) 273 | .get(() => { 274 | throw error; 275 | }) 276 | .handler()(req, res); 277 | 278 | const res2 = { 279 | end(chunk) { 280 | t.equal(res.statusCode, 500); 281 | t.equal(chunk, "Internal Server Error"); 282 | t.same(consoleSpy.calls[idx], [""], `called console.error with ""`); 283 | }, 284 | } as ServerResponse; 285 | await createRouter() 286 | .use(baseFn) 287 | .get(() => { 288 | // non error throw 289 | throw ""; 290 | }) 291 | .handler()(req, res2); 292 | 293 | consoleSpy.restore(); 294 | }); 295 | 296 | test("handler() - calls onError if error thrown (async)", async (t) => { 297 | t.plan(2 * 3); 298 | const error = new Error("💥"); 299 | const consoleSpy = spyOn(globalThis.console, "error", () => undefined); 300 | 301 | const badFn = () => t.fail("test error"); 302 | 303 | const req = { method: "GET", url: "/" } as IncomingMessage; 304 | let idx = 0; 305 | const res = { 306 | end(chunk) { 307 | t.equal(this.statusCode, 500); 308 | t.equal(chunk, "Internal Server Error"); 309 | t.same(consoleSpy.calls[idx], [error], `called console.error ${idx}`); 310 | idx += 1; 311 | }, 312 | } as ServerResponse; 313 | const baseFn = async ( 314 | req: IncomingMessage, 315 | res: ServerResponse, 316 | next: any 317 | ) => { 318 | res.statusCode = 200; 319 | return next(); 320 | }; 321 | await createRouter() 322 | .use(baseFn) 323 | .use(async () => { 324 | return Promise.reject(error); 325 | }) 326 | .get(badFn) 327 | .handler()(req, res); 328 | await createRouter() 329 | .use(baseFn) 330 | .get(() => { 331 | throw error; 332 | }) 333 | .handler()(req, res); 334 | 335 | consoleSpy.restore(); 336 | }); 337 | 338 | test("handler() - calls custom onError", async (t) => { 339 | t.plan(1); 340 | await createRouter() 341 | .get(() => { 342 | throw new Error("💥"); 343 | }) 344 | .handler({ 345 | onError(err) { 346 | t.equal((err as Error).message, "💥"); 347 | }, 348 | })({ method: "GET", url: "/" } as IncomingMessage, {} as ServerResponse); 349 | }); 350 | 351 | test("handler() - calls onNoMatch if no fns matched", async (t) => { 352 | t.plan(2); 353 | const req = { url: "/foo/bar", method: "GET" } as IncomingMessage; 354 | const res = { 355 | end(chunk) { 356 | t.equal(this.statusCode, 404); 357 | t.equal(chunk, "Route GET /foo/bar not found"); 358 | }, 359 | } as ServerResponse; 360 | await createRouter().get("/foo").post("/foo/bar").handler()(req, res); 361 | }); 362 | 363 | test("handler() - calls onNoMatch if only middle fns found", async (t) => { 364 | t.plan(2); 365 | const badFn = () => t.fail("test error"); 366 | const req = { url: "/foo/bar", method: "GET" } as IncomingMessage; 367 | const res = { 368 | end(chunk) { 369 | t.equal(this.statusCode, 404); 370 | t.equal(chunk, "Route GET /foo/bar not found"); 371 | }, 372 | } as ServerResponse; 373 | await createRouter().use("", badFn).use("/foo", badFn).handler()(req, res); 374 | }); 375 | 376 | test("handler() - calls onNoMatch if no fns matched (HEAD)", async (t) => { 377 | t.plan(2); 378 | const req = { url: "/foo/bar", method: "HEAD" } as IncomingMessage; 379 | const res = { 380 | end(chunk) { 381 | t.equal(this.statusCode, 404); 382 | t.equal(chunk, undefined); 383 | }, 384 | } as ServerResponse; 385 | await createRouter().get("/foo").post("/foo/bar").handler()(req, res); 386 | }); 387 | 388 | test("handler() - calls custom onNoMatch if not found", async (t) => { 389 | t.plan(1); 390 | await createRouter().handler({ 391 | onNoMatch() { 392 | t.pass("onNoMatch called"); 393 | }, 394 | })( 395 | { url: "/foo/bar", method: "GET" } as IncomingMessage, 396 | {} as ServerResponse 397 | ); 398 | }); 399 | 400 | test("handler() - calls onError if custom onNoMatch throws", async (t) => { 401 | t.plan(2); 402 | await createRouter().handler({ 403 | onNoMatch() { 404 | t.pass("onNoMatch called"); 405 | throw new Error("💥"); 406 | }, 407 | onError(err) { 408 | t.equal((err as Error).message, "💥"); 409 | }, 410 | })( 411 | { url: "/foo/bar", method: "GET" } as IncomingMessage, 412 | {} as ServerResponse 413 | ); 414 | }); 415 | 416 | test("prepareRequest() - attach params", async (t) => { 417 | const req = {} as IncomingMessage; 418 | 419 | const ctx2 = createRouter().get("/hello/:name"); 420 | // @ts-expect-error: internal 421 | ctx2.prepareRequest( 422 | req, 423 | {} as ServerResponse, 424 | // @ts-expect-error: internal 425 | ctx2.router.find("GET", "/hello/world") 426 | ); 427 | // @ts-expect-error: extra prop 428 | t.same(req.params, { name: "world" }, "params are attached"); 429 | 430 | const reqWithParams = { 431 | params: { age: "20" }, 432 | }; 433 | // @ts-expect-error: internal 434 | ctx2.prepareRequest( 435 | reqWithParams as unknown as IncomingMessage, 436 | {} as ServerResponse, 437 | // @ts-expect-error: internal 438 | ctx2.router.find("GET", "/hello/world") 439 | ); 440 | t.same( 441 | reqWithParams.params, 442 | { name: "world", age: "20" }, 443 | "params are merged" 444 | ); 445 | 446 | const reqWithParams2 = { 447 | params: { name: "sunshine" }, 448 | }; 449 | // @ts-expect-error: internal 450 | ctx2.prepareRequest( 451 | reqWithParams2 as unknown as IncomingMessage, 452 | {} as ServerResponse, 453 | // @ts-expect-error: internal 454 | ctx2.router.find("GET", "/hello/world") 455 | ); 456 | t.same( 457 | reqWithParams2.params, 458 | { name: "sunshine" }, 459 | "params are merged (existing takes precedence)" 460 | ); 461 | }); 462 | 463 | test("getPathname() - returns pathname correctly", async (t) => { 464 | t.equal(getPathname("/foo/bar"), "/foo/bar"); 465 | t.equal(getPathname("/foo/bar?q=quz"), "/foo/bar"); 466 | }); 467 | -------------------------------------------------------------------------------- /test/router.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Adapted from lukeed/trouter library: 3 | * https://github.com/lukeed/trouter/blob/master/test/index.js 4 | */ 5 | import { test } from "tap"; 6 | import type { Route } from "../src/router.js"; 7 | import { Router } from "../src/router.js"; 8 | import type { HttpMethod, Nextable } from "../src/types.js"; 9 | 10 | type AnyHandler = (...args: any[]) => any; 11 | 12 | const noop: AnyHandler = async () => { 13 | /** noop */ 14 | }; 15 | 16 | const testRoute = ( 17 | t: Tap.Test, 18 | rr: Route, 19 | { route, ...match }: Partial & { route: string }> 20 | ) => { 21 | // @ts-expect-error: pattern does not always exist 22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 23 | const { pattern, ...r } = rr; 24 | t.same(r, match, `~> has same route`); 25 | if (route) { 26 | const testCtx = new Router(); 27 | testCtx.routes = [rr]; 28 | t.ok( 29 | testCtx.find(match.method as HttpMethod, route).fns.length > 0, 30 | "~~> pattern satisfies route" 31 | ); 32 | } 33 | }; 34 | 35 | test("internals", (t) => { 36 | const ctx = new Router(); 37 | t.ok(ctx instanceof Router, "creates new `Router` instance"); 38 | t.ok(Array.isArray(ctx.routes), "~> has `routes` key (Array)"); 39 | t.type(ctx.add, "function", "~> has `add` method"); 40 | t.type(ctx.find, "function", "~> has `find` method"); 41 | 42 | t.end(); 43 | }); 44 | 45 | test("add()", (t) => { 46 | const ctx = new Router(); 47 | 48 | const out = ctx.add("GET", "/foo/:hello", noop); 49 | t.same(out, ctx, "returns the Router instance (chainable)"); 50 | 51 | t.equal(ctx.routes.length, 1, 'added "GET /foo/:hello" route successfully'); 52 | 53 | testRoute(t, ctx.routes[0], { 54 | fns: [noop], 55 | method: "GET", 56 | isMiddle: false, 57 | keys: ["hello"], 58 | route: "/foo/bar", 59 | }); 60 | 61 | ctx.add("POST", "bar", noop); 62 | t.equal( 63 | ctx.routes.length, 64 | 2, 65 | 'added "POST /bar" route successfully (via alias)' 66 | ); 67 | 68 | testRoute(t, ctx.routes[1], { 69 | fns: [noop], 70 | keys: [], 71 | method: "POST", 72 | isMiddle: false, 73 | route: "/bar", 74 | }); 75 | 76 | ctx.add("PUT", /^[/]foo[/](?\w+)[/]?$/, noop); 77 | t.same( 78 | ctx.routes.length, 79 | 3, 80 | 'added "PUT /^[/]foo[/](?\\w+)[/]?$/" route successfully' 81 | ); 82 | 83 | testRoute(t, ctx.routes[2], { 84 | fns: [noop], 85 | keys: false, 86 | method: "PUT", 87 | isMiddle: false, 88 | }); 89 | 90 | t.end(); 91 | }); 92 | 93 | test("add() - multiple", (t) => { 94 | const ctx = new Router(); 95 | 96 | ctx.add("PATCH", "/foo/:hello", noop, noop); 97 | t.same(ctx.routes.length, 1, 'added "SEARCH /foo/:hello" route successfully'); 98 | 99 | testRoute(t, ctx.routes[0], { 100 | fns: [noop, noop], 101 | keys: ["hello"], 102 | method: "PATCH", 103 | route: "/foo/howdy", 104 | isMiddle: false, 105 | }); 106 | 107 | ctx.add("PUT", "/bar", noop, noop, noop); 108 | t.same( 109 | ctx.routes.length, 110 | 2, 111 | 'added "PUT /bar" route successfully (via alias)' 112 | ); 113 | 114 | testRoute(t, ctx.routes[1], { 115 | fns: [noop, noop, noop], 116 | keys: [], 117 | method: "PUT", 118 | route: "/bar", 119 | isMiddle: false, 120 | }); 121 | 122 | t.end(); 123 | }); 124 | 125 | test("use()", (t) => { 126 | const ctx = new Router(); 127 | 128 | const out = ctx.use("/foo/:hello", noop); 129 | t.same(out, ctx, "returns the Router instance (chainable)"); 130 | 131 | t.same(ctx.routes.length, 1, 'added "ANY /foo/:hello" route successfully'); 132 | 133 | testRoute(t, ctx.routes[0], { 134 | method: "", 135 | keys: ["hello"], 136 | route: "/foo/bar", 137 | fns: [noop], 138 | isMiddle: true, 139 | }); 140 | 141 | ctx.use("/", noop, noop, noop); 142 | t.same(ctx.routes.length, 2, 'added "ANY /" routes successfully'); 143 | 144 | testRoute(t, ctx.routes[1], { 145 | keys: [], 146 | method: "", 147 | route: "/", 148 | fns: [noop, noop, noop], 149 | isMiddle: true, 150 | }); 151 | 152 | ctx.use("/foo/:world?", noop, noop, noop, noop); 153 | t.same(ctx.routes.length, 3, 'added "ANY /foo/:world?" routes successfully'); 154 | 155 | testRoute(t, ctx.routes[2], { 156 | keys: ["world"], 157 | method: "", 158 | route: "/foo/hello", 159 | fns: [noop, noop, noop, noop], 160 | isMiddle: true, 161 | }); 162 | 163 | t.end(); 164 | }); 165 | 166 | test("all()", (t) => { 167 | const fn: AnyHandler = (req: any) => req.chain++; 168 | const ctx = new Router().add("", "/greet/:name", fn); 169 | t.same(ctx.routes.length, 1, 'added "ALL /greet/:name" route'); 170 | 171 | testRoute(t, ctx.routes[0], { 172 | method: "", // ~> "ALL" 173 | keys: ["name"], 174 | route: "/greet/you", 175 | fns: [fn], 176 | isMiddle: false, 177 | }); 178 | 179 | const foo = ctx.find("HEAD", "/greet/Bob") as any; 180 | t.same(foo.params.name, "Bob", '~> "params.name" is expected'); 181 | t.same(foo.fns.length, 1, '~~> "handlers" has 1 item'); 182 | 183 | foo.chain = 0; 184 | foo.fns.forEach((fn) => fn(foo)); 185 | t.same(foo.chain, 1, "~~> handler executed successfully"); 186 | 187 | const bar = ctx.find("GET", "/greet/Judy") as any; 188 | t.same(bar.params.name, "Judy", '~> "params.name" is expected'); 189 | t.same(bar.fns.length, 1, '~~> "handlers" has 1 item'); 190 | 191 | bar.chain = 0; 192 | bar.fns.forEach((fn) => fn(bar)); 193 | t.same(bar.chain, 1, "~~> handler executed successfully"); 194 | 195 | const fn2: AnyHandler = (req: any) => { 196 | t.same(req.chain++, 1, "~> ran new HEAD after ALL handler"); 197 | t.same(req.params.name, "Rick", '~~> still see "params.name" value'); 198 | t.same(req.params.person, "Rick", '~~> receives "params.person" value'); 199 | }; 200 | ctx.add("HEAD", "/greet/:person", fn2); 201 | 202 | t.same(ctx.routes.length, 2, 'added "HEAD /greet/:name" route'); 203 | 204 | testRoute(t, ctx.routes[1], { 205 | method: "HEAD", // ~> "ALL" 206 | keys: ["person"], 207 | route: "/greet/you", 208 | fns: [fn2], 209 | isMiddle: false, 210 | }); 211 | 212 | const baz = ctx.find("HEAD", "/greet/Rick") as any; 213 | t.same(baz.params.name, "Rick", '~> "params.name" is expected'); 214 | t.same(baz.fns.length, 2, '~~> "handlers" has 2 items'); 215 | 216 | baz.chain = 0; 217 | baz.fns.forEach((fn) => fn(baz)); 218 | t.same(baz.chain, 2, "~~> handlers executed successfully"); 219 | 220 | const bat = ctx.find("POST", "/greet/Morty") as any; 221 | t.same(bat.params.name, "Morty", '~> "params.name" is expected'); 222 | t.same(bat.fns.length, 1, '~~> "handlers" has 1 item'); 223 | 224 | bat.chain = 0; 225 | bat.fns.forEach((fn) => fn(bat)); 226 | t.same(bat.chain, 1, "~~> handler executed successfully"); 227 | 228 | t.end(); 229 | }); 230 | 231 | test("find()", (t) => { 232 | t.plan(9); 233 | 234 | const ctx = new Router(); 235 | 236 | ctx.add( 237 | "GET", 238 | "/foo/:title", 239 | ((req) => { 240 | t.same(req.chain++, 1, '~> 1st "GET /foo/:title" ran first'); 241 | t.same(req.params.title, "bar", '~> "params.title" is expected'); 242 | }) as AnyHandler, 243 | ((req) => { 244 | t.same(req.chain++, 2, '~> 2nd "GET /foo/:title" ran second'); 245 | }) as AnyHandler 246 | ); 247 | 248 | const out = ctx.find("GET", "/foo/bar") as any; 249 | 250 | t.type(out, "object", "returns an object"); 251 | t.type(out.params, "object", '~> has "params" key (object)'); 252 | t.same(out.params.title, "bar", '~~> "params.title" value is correct'); 253 | 254 | t.ok(Array.isArray(out.fns), `~> has "handlers" key (array)`); 255 | t.same(out.fns.length, 2, "~~> saved both handlers"); 256 | 257 | out.chain = 1; 258 | out.fns.forEach((fn) => fn(out)); 259 | t.same(out.chain, 3, "~> executes the handler group sequentially"); 260 | }); 261 | 262 | test("find() - no match", (t) => { 263 | const ctx = new Router(); 264 | const out = ctx.find("DELETE", "/nothing"); 265 | 266 | t.type(out, "object", "returns an object"); 267 | t.same(Object.keys(out.params).length, 0, '~> "params" is empty'); 268 | t.same(out.fns.length, 0, '~> "handlers" is empty'); 269 | t.end(); 270 | }); 271 | 272 | test("find() - multiple", (t) => { 273 | t.plan(18); 274 | 275 | const ctx = new Router() 276 | .use("/foo", ((req) => { 277 | t.pass('~> ran use("/foo")" route'); // x2 278 | isRoot || t.same(req.params.title, "bar", '~~> saw "param.title" value'); 279 | t.same(req.chain++, 0, "~~> ran 1st"); 280 | }) as AnyHandler) 281 | .add("GET", "/foo", ((req) => { 282 | t.pass('~> ran "GET /foo" route'); 283 | t.same(req.chain++, 1, "~~> ran 2nd"); 284 | }) as AnyHandler) 285 | .add("GET", "/foo/:title?", ((req) => { 286 | t.pass('~> ran "GET /foo/:title?" route'); // x2 287 | isRoot || t.same(req.params.title, "bar", '~~> saw "params.title" value'); 288 | isRoot 289 | ? t.same(req.chain++, 2, "~~> ran 3rd") 290 | : t.same(req.chain++, 1, "~~> ran 2nd"); 291 | }) as AnyHandler) 292 | .add("GET", "/foo/*", ((req) => { 293 | t.pass('~> ran "GET /foo/*" route'); 294 | t.same(req.params.wild, "bar", '~~> saw "params.wild" value'); 295 | t.same(req.params.title, "bar", '~~> saw "params.title" value'); 296 | t.same(req.chain++, 2, "~~> ran 3rd"); 297 | }) as AnyHandler); 298 | 299 | let isRoot = true; 300 | const foo = ctx.find("GET", "/foo") as any; 301 | t.same(foo.fns.length, 3, "found 3 handlers"); 302 | 303 | foo.chain = 0; 304 | foo.fns.forEach((fn) => fn(foo)); 305 | 306 | isRoot = false; 307 | const bar = ctx.find("GET", "/foo/bar") as any; 308 | t.same(bar.fns.length, 3, "found 3 handlers"); 309 | 310 | bar.chain = 0; 311 | bar.fns.forEach((fn) => fn(bar)); 312 | }); 313 | 314 | test("find() - HEAD", (t) => { 315 | t.plan(5); 316 | const ctx = new Router() 317 | .add("", "/foo", ((req) => { 318 | t.same(req.chain++, 0, '~> found "ALL /foo" route'); 319 | }) as AnyHandler) 320 | .add("HEAD", "/foo", ((req) => { 321 | t.same(req.chain++, 1, '~> found "HEAD /foo" route'); 322 | }) as AnyHandler) 323 | .add("GET", "/foo", ((req) => { 324 | t.same(req.chain++, 2, '~> also found "GET /foo" route'); 325 | }) as AnyHandler) 326 | .add("GET", "/", () => { 327 | t.pass("should not run"); 328 | }); 329 | 330 | const out = ctx.find("HEAD", "/foo") as any; 331 | t.same(out.fns.length, 3, "found 3 handlers"); 332 | 333 | out.chain = 0; 334 | out.fns.forEach((fn) => fn(out)); 335 | t.same(out.chain, 3, "ran handlers sequentially"); 336 | }); 337 | 338 | test("find() - order", (t) => { 339 | t.plan(5); 340 | const ctx = new Router() 341 | .add("", "/foo", ((req) => { 342 | t.same(req.chain++, 0, '~> ran "ALL /foo" 1st'); 343 | }) as AnyHandler) 344 | .add("GET", "/foo", ((req) => { 345 | t.same(req.chain++, 1, '~> ran "GET /foo" 2nd'); 346 | }) as AnyHandler) 347 | .add("HEAD", "/foo", ((req) => { 348 | t.same(req.chain++, 2, '~> ran "HEAD /foo" 3rd'); 349 | }) as AnyHandler) 350 | .add("GET", "/", (() => { 351 | t.pass("should not run"); 352 | }) as AnyHandler); 353 | 354 | const out = ctx.find("HEAD", "/foo") as any; 355 | t.same(out.fns.length, 3, "found 3 handlers"); 356 | 357 | out.chain = 0; 358 | out.fns.forEach((fn) => fn(out)); 359 | t.same(out.chain, 3, "ran handlers sequentially"); 360 | }); 361 | 362 | test("find() w/ all()", (t) => { 363 | const noop = () => { 364 | /** noop */ 365 | }; 366 | const find = (x, y) => x.find("GET", y); 367 | 368 | const ctx1 = new Router().add("", "api", noop); 369 | const ctx2 = new Router().add("", "api/:version", noop); 370 | const ctx3 = new Router().add("", "api/:version?", noop); 371 | const ctx4 = new Router().add("", "movies/:title.mp4", noop); 372 | 373 | t.same(find(ctx1, "/api").fns.length, 1, "~> exact match"); 374 | t.same( 375 | find(ctx1, "/api/foo").fns.length, 376 | 0, 377 | '~> does not match "/api/foo" - too long' 378 | ); 379 | 380 | t.same(find(ctx2, "/api").fns.length, 0, '~> does not match "/api" only'); 381 | 382 | const foo1 = find(ctx2, "/api/v1"); 383 | t.same(foo1.fns.length, 1, '~> does match "/api/v1" directly'); 384 | t.same(foo1.params.version, "v1", '~> parses the "version" correctly'); 385 | 386 | const foo2 = find(ctx2, "/api/v1/users"); 387 | t.same(foo2.fns.length, 0, '~> does not match "/api/v1/users" - too long'); 388 | t.same( 389 | foo2.params.version, 390 | undefined, 391 | '~> cannot parse the "version" parameter (not a match)' 392 | ); 393 | 394 | t.same( 395 | find(ctx3, "/api").fns.length, 396 | 1, 397 | '~> does match "/api" because optional' 398 | ); 399 | 400 | const bar1 = find(ctx3, "/api/v1"); 401 | t.same(bar1.fns.length, 1, '~> does match "/api/v1" directly'); 402 | t.same(bar1.params.version, "v1", '~> parses the "version" correctly'); 403 | 404 | const bar2 = find(ctx3, "/api/v1/users"); 405 | t.same(bar2.fns.length, 0, '~> does match "/api/v1/users" - too long'); 406 | t.same( 407 | bar2.params.version, 408 | undefined, 409 | '~> cannot parse the "version" parameter (not a match)' 410 | ); 411 | 412 | t.same( 413 | find(ctx4, "/movies").fns.length, 414 | 0, 415 | '~> does not match "/movies" directly' 416 | ); 417 | t.same( 418 | find(ctx4, "/movies/narnia").fns.length, 419 | 0, 420 | '~> does not match "/movies/narnia" directly' 421 | ); 422 | 423 | const baz1 = find(ctx4, "/movies/narnia.mp4"); 424 | t.same(baz1.fns.length, 1, '~> does match "/movies/narnia.mp4" directly'); 425 | t.same(baz1.params.title, "narnia", '~> parses the "title" correctly'); 426 | 427 | const baz2 = find(ctx4, "/movies/narnia.mp4/cast"); 428 | t.same( 429 | baz2.fns.length, 430 | 0, 431 | '~> does match "/movies/narnia.mp4/cast" - too long' 432 | ); 433 | t.same( 434 | baz2.params.title, 435 | undefined, 436 | '~> cannot parse the "title" parameter (not a match)' 437 | ); 438 | 439 | t.end(); 440 | }); 441 | 442 | test("find() w/ use()", (t) => { 443 | const noop = () => { 444 | /** noop */ 445 | }; 446 | const find = (x, y) => x.find("GET", y); 447 | 448 | const ctx1 = new Router().use("api", noop); 449 | const ctx2 = new Router().use("api/:version", noop); 450 | const ctx3 = new Router().use("api/:version?", noop); 451 | const ctx4 = new Router().use("movies/:title.mp4", noop); 452 | 453 | t.same(find(ctx1, "/api").fns.length, 1, "~> exact match"); 454 | t.same(find(ctx1, "/api/foo").fns.length, 1, "~> loose match"); 455 | 456 | t.same(find(ctx2, "/api").fns.length, 0, '~> does not match "/api" only'); 457 | 458 | const foo1 = find(ctx2, "/api/v1"); 459 | t.same(foo1.fns.length, 1, '~> does match "/api/v1" directly'); 460 | t.same(foo1.params.version, "v1", '~> parses the "version" correctly'); 461 | 462 | const foo2 = find(ctx2, "/api/v1/users"); 463 | t.same(foo2.fns.length, 1, '~> does match "/api/v1/users" loosely'); 464 | t.same(foo2.params.version, "v1", '~> parses the "version" correctly'); 465 | 466 | t.same( 467 | find(ctx3, "/api").fns.length, 468 | 1, 469 | '~> does match "/api" because optional' 470 | ); 471 | 472 | const bar1 = find(ctx3, "/api/v1"); 473 | t.same(bar1.fns.length, 1, '~> does match "/api/v1" directly'); 474 | t.same(bar1.params.version, "v1", '~> parses the "version" correctly'); 475 | 476 | const bar2 = find(ctx3, "/api/v1/users"); 477 | t.same(bar2.fns.length, 1, '~> does match "/api/v1/users" loosely'); 478 | t.same(bar2.params.version, "v1", '~> parses the "version" correctly'); 479 | 480 | t.same( 481 | find(ctx4, "/movies").fns.length, 482 | 0, 483 | '~> does not match "/movies" directly' 484 | ); 485 | t.same( 486 | find(ctx4, "/movies/narnia").fns.length, 487 | 0, 488 | '~> does not match "/movies/narnia" directly' 489 | ); 490 | 491 | const baz1 = find(ctx4, "/movies/narnia.mp4"); 492 | t.same(baz1.fns.length, 1, '~> does match "/movies/narnia.mp4" directly'); 493 | t.same(baz1.params.title, "narnia", '~> parses the "title" correctly'); 494 | 495 | const baz2 = find(ctx4, "/movies/narnia.mp4/cast"); 496 | t.same(baz2.fns.length, 1, '~> does match "/movies/narnia.mp4/cast" loosely'); 497 | t.same(baz2.params.title, "narnia", '~> parses the "title" correctly'); 498 | 499 | t.end(); 500 | }); 501 | 502 | test("find() - regex w/ named groups", (t) => { 503 | t.plan(9); 504 | const ctx = new Router(); 505 | 506 | ctx.add( 507 | "GET", 508 | /^[/]foo[/](?\w+)[/]?$/, 509 | ((req) => { 510 | t.same( 511 | req.chain++, 512 | 1, 513 | '~> 1st "GET /^[/]foo[/](?<title>\\w+)[/]?$/" ran first' 514 | ); 515 | t.same(req.params.title, "bar", '~> "params.title" is expected'); 516 | }) as AnyHandler, 517 | ((req) => { 518 | t.same( 519 | req.chain++, 520 | 2, 521 | '~> 2nd "GET /^[/]foo[/](?<title>\\w+)[/]?$/" ran second' 522 | ); 523 | }) as AnyHandler 524 | ); 525 | 526 | const out = ctx.find("GET", "/foo/bar") as any; 527 | 528 | t.type(out, "object", "returns an object"); 529 | t.type(out.params, "object", '~> has "params" key (object)'); 530 | t.same(out.params.title, "bar", '~~> "params.title" value is correct'); 531 | 532 | t.ok(Array.isArray(out.fns), `~> has "handlers" key (array)`); 533 | t.same(out.fns.length, 2, "~~> saved both handlers"); 534 | 535 | out.chain = 1; 536 | out.fns.forEach((fn) => fn(out)); 537 | t.same(out.chain, 3, "~> executes the handler group sequentially"); 538 | }); 539 | 540 | test("find() - multiple regex w/ named groups", (t) => { 541 | t.plan(18); 542 | 543 | const ctx = new Router<AnyHandler>() 544 | .use("/foo", ((req) => { 545 | t.pass('~> ran use("/foo")" route'); // x2 546 | isRoot || t.same(req.params.title, "bar", '~~> saw "params.title" value'); 547 | t.same(req.chain++, 0, "~~> ran 1st"); 548 | }) as AnyHandler) 549 | .add("GET", "/foo", ((req) => { 550 | t.pass('~> ran "GET /foo" route'); 551 | t.same(req.chain++, 1, "~~> ran 2nd"); 552 | }) as AnyHandler) 553 | .add("GET", /^[/]foo(?:[/](?<title>\w+))?[/]?$/, ((req) => { 554 | t.pass('~> ran "GET /^[/]foo[/](?<title>\\w+)?[/]?$/" route'); // x2 555 | isRoot || t.same(req.params.title, "bar", '~~> saw "params.title" value'); 556 | isRoot 557 | ? t.same(req.chain++, 2, "~~> ran 3rd") 558 | : t.same(req.chain++, 1, "~~> ran 2nd"); 559 | }) as AnyHandler) 560 | .add("GET", /^[/]foo[/](?<wild>.*)$/, ((req) => { 561 | t.pass('~> ran "GET /^[/]foo[/](?<wild>.*)$/" route'); 562 | t.same(req.params.wild, "bar", '~~> saw "params.wild" value'); 563 | t.same(req.params.title, "bar", '~~> saw "params.title" value'); 564 | t.same(req.chain++, 2, "~~> ran 3rd"); 565 | }) as AnyHandler); 566 | 567 | let isRoot = true; 568 | const foo = ctx.find("GET", "/foo") as any; 569 | t.same(foo.fns.length, 3, "found 3 handlers"); 570 | 571 | foo.chain = 0; 572 | foo.fns.forEach((fn) => fn(foo)); 573 | 574 | isRoot = false; 575 | const bar = ctx.find("GET", "/foo/bar") as any; 576 | t.same(bar.fns.length, 3, "found 3 handlers"); 577 | 578 | bar.chain = 0; 579 | bar.fns.forEach((fn) => fn(bar)); 580 | }); 581 | 582 | /** 583 | * Additional handling tailored to next-connect 584 | */ 585 | 586 | test("constructor() with base", (t) => { 587 | t.equal(new Router().base, "/", "assign base to / by default"); 588 | t.equal(new Router("/foo").base, "/foo", "assign base to provided value"); 589 | t.end(); 590 | }); 591 | 592 | test("constructor() with routes", (t) => { 593 | t.same(new Router().routes, [], "assign to empty route array by default"); 594 | const routes = []; 595 | t.equal( 596 | new Router(undefined, routes).routes, 597 | routes, 598 | "assign routes if provided" 599 | ); 600 | t.end(); 601 | }); 602 | 603 | test("clone()", (t) => { 604 | const ctx = new Router(); 605 | ctx.routes = [noop, noop] as any[]; 606 | t.not(ctx, ctx.clone(), "not the same identity"); 607 | t.ok(ctx.clone() instanceof Router, "is a Router instance"); 608 | t.equal(ctx.clone("/foo").base, "/foo", "cloned with custom base"); 609 | 610 | const ctxRoutes = new Router("", [noop as any]); 611 | t.not( 612 | ctxRoutes.clone().routes, 613 | ctxRoutes.routes, 614 | "routes are deep cloned (identity)" 615 | ); 616 | t.same(ctxRoutes.clone().routes, ctxRoutes.routes, "routes are deep cloned"); 617 | 618 | t.end(); 619 | }); 620 | 621 | test("use() - default to / with no base", (t) => { 622 | t.plan(2); 623 | const ctx = new Router(); 624 | const fn = () => undefined; 625 | ctx.use(fn); 626 | testRoute(t, ctx.routes[0], { 627 | keys: [], 628 | fns: [fn], 629 | isMiddle: true, 630 | method: "", 631 | route: "/some/wacky/route", 632 | }); 633 | }); 634 | 635 | test("use() - mount router", (t) => { 636 | const subCtx = new Router(); 637 | 638 | testRoute(t, new Router().use("/foo", subCtx, noop).routes[0], { 639 | keys: [], 640 | fns: [subCtx.clone("/foo"), noop], 641 | isMiddle: true, 642 | method: "", 643 | }); 644 | 645 | testRoute(t, new Router().use("/", subCtx, noop).routes[0], { 646 | keys: [], 647 | fns: [subCtx, noop], 648 | isMiddle: true, 649 | method: "", 650 | }); 651 | 652 | // nested mount 653 | const subCtx2 = new Router().use("/bar", subCtx); 654 | testRoute(t, new Router().use("/foo", subCtx2, noop).routes[0], { 655 | keys: [], 656 | fns: [subCtx2.clone("/foo"), noop], 657 | isMiddle: true, 658 | method: "", 659 | }); 660 | testRoute(t, subCtx2.routes[0], { 661 | keys: [], 662 | fns: [subCtx.clone("/bar")], 663 | isMiddle: true, 664 | method: "", 665 | }); 666 | 667 | // unsupported 668 | t.throws( 669 | () => new Router().use(new RegExp("/not/supported"), subCtx), 670 | new Error("Mounting a router to RegExp base is not supported"), 671 | "throws unsupported message" 672 | ); 673 | 674 | t.end(); 675 | }); 676 | 677 | test("find() - w/ router with correct match", async (t) => { 678 | const noop1 = async () => undefined; 679 | const noop2 = async () => undefined; 680 | const noop3 = async () => undefined; 681 | const noop4 = async () => undefined; 682 | 683 | const ctx = new Router<AnyHandler>() 684 | .add("GET", noop) 685 | .use( 686 | "/foo", 687 | new Router<AnyHandler>() 688 | .use("/", noop1) 689 | .use("/bar", noop2, noop2) 690 | .use("/quz", noop3), 691 | noop4 692 | ); 693 | t.same( 694 | ctx.find("GET", "/foo"), 695 | { 696 | middleOnly: false, 697 | params: {}, 698 | fns: [noop, noop1, noop4], 699 | }, 700 | "matches exact base" 701 | ); 702 | 703 | t.same( 704 | ctx.find("GET", "/quz"), 705 | { 706 | middleOnly: false, 707 | params: {}, 708 | fns: [noop], 709 | }, 710 | "does not matches different base" 711 | ); 712 | 713 | t.same( 714 | ctx.find("GET", "/foobar"), 715 | { 716 | middleOnly: false, 717 | params: {}, 718 | fns: [noop], 719 | }, 720 | "does not matches different base (just-in-case case)" 721 | ); 722 | 723 | t.same( 724 | ctx.find("GET", "/foo/bar"), 725 | { 726 | middleOnly: false, 727 | params: {}, 728 | fns: [noop, noop1, noop2, noop2, noop4], 729 | }, 730 | "matches sub routes 1" 731 | ); 732 | 733 | t.same( 734 | ctx.find("GET", "/foo/quz"), 735 | { 736 | middleOnly: false, 737 | params: {}, 738 | fns: [noop, noop1, noop3, noop4], 739 | }, 740 | "matches sub routes 2" 741 | ); 742 | 743 | // with params 744 | t.same( 745 | new Router() 746 | .use("/:id", new Router().use("/bar", noop1), noop2) 747 | .find("GET", "/foo/bar"), 748 | { 749 | middleOnly: true, 750 | params: { 751 | id: "foo", 752 | }, 753 | fns: [noop1, noop2], 754 | }, 755 | "with params" 756 | ); 757 | 758 | t.same( 759 | new Router() 760 | .use("/:id", new Router().use("/:subId", noop1), noop2) 761 | .find("GET", "/foo/bar"), 762 | { 763 | middleOnly: true, 764 | params: { 765 | id: "foo", 766 | subId: "bar", 767 | }, 768 | fns: [noop1, noop2], 769 | }, 770 | "with params on both outer and sub" 771 | ); 772 | 773 | t.same( 774 | new Router().use(noop).use(new Router().add("GET", noop1)).find("GET", "/"), 775 | { 776 | middleOnly: false, 777 | params: {}, 778 | fns: [noop, noop1], 779 | }, 780 | "set root middleOnly to false if sub = false" 781 | ); 782 | }); 783 | 784 | test("find() - w/ router nested multiple level", (t) => { 785 | const noop1 = async () => undefined; 786 | const noop2 = async () => undefined; 787 | const noop3 = async () => undefined; 788 | const noop4 = async () => undefined; 789 | const noop5 = async () => undefined; 790 | 791 | const ctx4 = new Router<AnyHandler>().use(noop5); 792 | const ctx3 = new Router<AnyHandler>().use(noop4).use("/:id", noop3); 793 | const ctx2 = new Router<AnyHandler>().use("/quz", noop2, ctx3).use(ctx4); 794 | const ctx = new Router<AnyHandler>().use("/foo", noop, ctx2, noop1); 795 | 796 | t.same(ctx.find("GET", "/foo"), { 797 | middleOnly: true, 798 | params: {}, 799 | fns: [noop, noop5, noop1], 800 | }); 801 | 802 | t.same(ctx.find("GET", "/foo/quz"), { 803 | middleOnly: true, 804 | params: {}, 805 | fns: [noop, noop2, noop4, noop5, noop1], 806 | }); 807 | 808 | t.same(ctx.find("GET", "/foo/quz/bar"), { 809 | middleOnly: true, 810 | params: { 811 | id: "bar", 812 | }, 813 | fns: [noop, noop2, noop4, noop3, noop5, noop1], 814 | }); 815 | 816 | t.end(); 817 | }); 818 | 819 | test("add() - matches all if no route", (t) => { 820 | t.plan(4); 821 | const ctx = new Router(); 822 | const fn = () => undefined; 823 | ctx.add("GET", fn); 824 | testRoute(t, ctx.routes[0], { 825 | route: "/some/wacky/route", 826 | fns: [fn], 827 | matchAll: true, 828 | isMiddle: false, 829 | method: "GET", 830 | }); 831 | 832 | const ctx2 = new Router(); 833 | ctx2.add("POST", "", fn); 834 | testRoute(t, ctx2.routes[0], { 835 | route: "/some/wacky/route", 836 | fns: [fn], 837 | matchAll: true, 838 | isMiddle: false, 839 | method: "POST", 840 | }); 841 | }); 842 | 843 | test("exec() - execute handlers sequentially", async (t) => { 844 | t.plan(10); 845 | const rreq = {}; 846 | const rres = {}; 847 | let idx = 0; 848 | const fns: Nextable< 849 | (arg0: Record<string, unknown>, arg1: Record<string, unknown>) => void 850 | >[] = [ 851 | async (req, res, next) => { 852 | t.equal(idx++, 0, "correct execution order"); 853 | t.equal(req, rreq, "~~> passes all args"); 854 | t.equal(res, rres, "~~> passes all args"); 855 | t.type(next, "function", "~~> receives next function"); 856 | const val = await next(); 857 | t.equal(val, "bar", "~~> resolves the next handler"); 858 | t.equal(idx++, 4, "correct execution order"); 859 | return "final"; 860 | }, 861 | async (_req, _res, next) => { 862 | t.equal(idx++, 1, "correct execution order"); 863 | await next(); 864 | t.equal(idx++, 3, "correct execution order"); 865 | return "bar"; 866 | }, 867 | async () => { 868 | t.equal(idx++, 2, "correct execution order"); 869 | return "foo"; 870 | }, 871 | async () => { 872 | t.fail("don't call me"); 873 | }, 874 | ]; 875 | t.equal( 876 | await Router.exec(fns, rreq, rres), 877 | "final", 878 | "~~> returns the final value" 879 | ); 880 | }); 881 | 882 | test("find() - returns middleOnly", async (t) => { 883 | const ctx = new Router(); 884 | const fn = () => undefined; 885 | ctx.add("", "/this/will/not/match", fn); 886 | ctx.add("POST", "/bar", fn); 887 | ctx.use("/", fn); 888 | ctx.use("/foo", fn); 889 | 890 | await t.test("= true if only middles found", async (t) => { 891 | t.equal(ctx.find("GET", "/bar").middleOnly, true); 892 | }); 893 | 894 | await t.test("= false if at least one non-middle found", async (t) => { 895 | t.equal(ctx.find("POST", "/bar").middleOnly, false); 896 | }); 897 | }); 898 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node14/tsconfig.json", 3 | "compilerOptions": { 4 | "isolatedModules": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["ES2020", "DOM"], 8 | "outDir": "dist", 9 | "declaration": false 10 | }, 11 | "include": ["./src/**/*"], 12 | "ts-node": { 13 | "transpileOnly": true, 14 | "esm": true, 15 | "files": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------