├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── index.js ├── package.json ├── pathToRegExp.js └── test ├── example.js ├── next.js └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.swp 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v2.0.0 Null Scepter 2 | 3 | - Return `null` when a route isn't matched, rather than `undefined` 4 | 5 | # v1.1.0 Wide Wild West 6 | 7 | - Fixed an issue where wildcards couldn't be used in named parameters, e.g `:id(\\d*)` 8 | 9 | # v1.0.0 IPO 10 | 11 | - Initial Public Release 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2015 Nicolas Bevacqua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ruta3 2 | 3 | > Route matcher devised for shared rendering JavaScript applications 4 | 5 | # Install 6 | 7 | ```shell 8 | npm install --save ruta3 9 | ``` 10 | 11 | # Sample Usage 12 | 13 | Get a router instance 14 | 15 | ```js 16 | var ruta3 = require('ruta3'); 17 | var router = ruta3(); 18 | ``` 19 | 20 | Add some routes 21 | 22 | ```js 23 | router.addRoute('/articles', getArticles); 24 | router.addRoute('/articles/:slug', getArticleBySlug); 25 | router.addRoute('/articles/search/*', searchForArticles); 26 | ``` 27 | 28 | Find a match 29 | 30 | ```js 31 | router.match('/articles'); 32 | ``` 33 | 34 | You'll get `null` back if no route matches the provided URL. Otherwise, the route match will provide all the useful information you need inside an object. 35 | 36 | Key | Description 37 | ------------------|--------------------------------------------------------------------------------------- 38 | `action` | The action passed to `addRoute` as a second argument. Using a function is recommended 39 | `next` | Fall through to the next route, or `null` if no other routes match 40 | `route` | The route passed to `addRoute` as the first argument 41 | `params` | An object containing the values for named parameters in the route 42 | `splats` | An object filled with the values for wildcard parameters 43 | 44 | # License 45 | 46 | MIT 47 | 48 | _(originally derived from [routes][1], **which is no longer maintained**)_ 49 | 50 | [1]: https://github.com/aaronblohowiak/routes.js 51 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var pathToRegExp = require('./pathToRegExp'); 4 | 5 | function match (routes, uri, startAt) { 6 | var captures; 7 | var i = startAt || 0; 8 | 9 | for (var len = routes.length; i < len; ++i) { 10 | var route = routes[i]; 11 | var re = route.re; 12 | var keys = route.keys; 13 | var splats = []; 14 | var params = {}; 15 | 16 | if (captures = uri.match(re)) { 17 | for (var j = 1, len = captures.length; j < len; ++j) { 18 | var value = typeof captures[j] === 'string' ? decodeURIComponent(captures[j]) : captures[j]; 19 | var key = keys[j - 1]; 20 | if (key) { 21 | params[key] = value; 22 | } else { 23 | splats.push(value); 24 | } 25 | } 26 | 27 | return { 28 | params: params, 29 | splats: splats, 30 | route: route.src, 31 | next: i + 1, 32 | index: route.index 33 | }; 34 | } 35 | } 36 | 37 | return null; 38 | } 39 | 40 | function routeInfo (path, index) { 41 | var src; 42 | var re; 43 | var keys = []; 44 | 45 | if (path instanceof RegExp) { 46 | re = path; 47 | src = path.toString(); 48 | } else { 49 | re = pathToRegExp(path, keys); 50 | src = path; 51 | } 52 | 53 | return { 54 | re: re, 55 | src: path.toString(), 56 | keys: keys, 57 | index: index 58 | }; 59 | } 60 | 61 | function Router () { 62 | if (!(this instanceof Router)) { 63 | return new Router(); 64 | } 65 | 66 | this.routes = []; 67 | this.routeMap = []; 68 | } 69 | 70 | Router.prototype.addRoute = function (path, action) { 71 | if (!path) { 72 | throw new Error(' route requires a path'); 73 | } 74 | if (!action) { 75 | throw new Error(' route ' + path.toString() + ' requires an action'); 76 | } 77 | 78 | var route = routeInfo(path, this.routeMap.length); 79 | route.action = action; 80 | this.routes.push(route); 81 | this.routeMap.push([path, action]); 82 | } 83 | 84 | Router.prototype.match = function (uri, startAt) { 85 | var route = match(this.routes, uri, startAt); 86 | if (route) { 87 | route.action = this.routeMap[route.index][1]; 88 | route.next = this.match.bind(this, uri, route.next); 89 | } 90 | return route; 91 | } 92 | 93 | module.exports = Router; 94 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ruta3", 3 | "description": "Route matcher devised for shared rendering JavaScript applications", 4 | "version": "2.0.1", 5 | "homepage": "https://github.com/bevacqua/ruta3", 6 | "repository": "https://github.com/bevacqua/ruta3.git", 7 | "author": "Nicolas Bevacqua (http://github.com/bevacqua)", 8 | "scripts": { 9 | "test": "node test/test && node test/next" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pathToRegExp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function pathToRegExp (path, keys) { 4 | path = path 5 | .concat('/?') 6 | .replace(/\/\(/g, '(?:/') 7 | .replace(/(\/)?(\.)?:(\w+)(?:(\(.*?\)))?(\?)?|\*/g, tweak) 8 | .replace(/([\/.])/g, '\\$1') 9 | .replace(/\*/g, '(.*)'); 10 | 11 | return new RegExp('^' + path + '$', 'i'); 12 | 13 | function tweak (match, slash, format, key, capture, optional) { 14 | if (match === '*') { 15 | keys.push(void 0); 16 | return match; 17 | } 18 | 19 | keys.push(key); 20 | 21 | slash = slash || ''; 22 | 23 | return '' 24 | + (optional ? '' : slash) 25 | + '(?:' 26 | + (optional ? slash : '') 27 | + (format || '') 28 | + (capture ? capture.replace(/\*/g, '{0,}').replace(/\./g, '[\\s\\S]') : '([^/]+?)') 29 | + ')' 30 | + (optional || ''); 31 | } 32 | } 33 | 34 | module.exports = pathToRegExp; 35 | -------------------------------------------------------------------------------- /test/example.js: -------------------------------------------------------------------------------- 1 | var ruta3 = require('../index'); 2 | var router = ruta3(); 3 | 4 | function fn1 () {} 5 | function fn2 () {} 6 | 7 | router.addRoute('/', fn1); 8 | router.addRoute('/', fn2); 9 | 10 | console.log(router.match('/').next()); 11 | -------------------------------------------------------------------------------- /test/next.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert') 2 | var http = require('http') 3 | var url = require('url') 4 | var Routes = require('../index') 5 | var router = new Routes() 6 | 7 | router.addRoute('/*?', staticFiles) 8 | router.addRoute('/admin/*?', auth) 9 | router.addRoute('/admin/users', adminUsers) 10 | router.addRoute('/*', notFound) 11 | 12 | var server = http.createServer(function (req, res) { 13 | var path = url.parse(req.url).pathname 14 | var match = router.match(path) 15 | 16 | function wrapNext(next) { 17 | return function() { 18 | var match = next() 19 | match.action(req, res, wrapNext(match.next)) 20 | } 21 | } 22 | 23 | match.action(req, res, wrapNext(match.next)) 24 | 25 | }).listen(1337, runTests) 26 | 27 | // serve the file or pass it on 28 | function staticFiles(req, res, next) { 29 | var qs = url.parse(req.url, true).query 30 | if (qs.img) { 31 | res.statusCode = 304 32 | res.end() 33 | } 34 | else { 35 | res.setHeader('x-static', 'next') 36 | next() 37 | } 38 | } 39 | 40 | // authenticate the user and pass them on 41 | // or 403 them 42 | function auth(req, res, next) { 43 | var qs = url.parse(req.url, true).query 44 | if (qs.user) { 45 | res.setHeader('x-auth', 'next') 46 | return next() 47 | } 48 | res.statusCode = 403 49 | res.end() 50 | } 51 | 52 | // render the admin.users page 53 | function adminUsers(req, res, next) { 54 | res.statusCode = 200 55 | res.end() 56 | } 57 | 58 | function notFound(req, res, next) { 59 | res.statusCode = 404 60 | return res.end() 61 | } 62 | 63 | 64 | function httpError(err) { 65 | console.error('An error occurred:', err) 66 | tryClose() 67 | } 68 | 69 | function runTests() { 70 | 71 | http.get('http://localhost:1337/?img=1', function(res) { 72 | console.log('Match /*? and return 304') 73 | assert.equal(res.statusCode, 304) 74 | assert.notEqual(res.headers['x-static'], 'next') 75 | tryClose() 76 | }).on('error', httpError) 77 | 78 | http.get('http://localhost:1337/admin/users', function(res) { 79 | console.log('Match /admin/* and return 403') 80 | assert.equal(res.statusCode, 403) 81 | assert.equal(res.headers['x-static'], 'next') 82 | tryClose() 83 | }).on('error', httpError) 84 | 85 | http.get('http://localhost:1337/admin/users?user=1', function(res) { 86 | console.log('Match /admin/users and return 200') 87 | assert.equal(res.statusCode, 200) 88 | assert.equal(res.headers['x-static'], 'next') 89 | assert.equal(res.headers['x-auth'], 'next') 90 | tryClose() 91 | }).on('error', httpError) 92 | 93 | http.get('http://localhost:1337/something-else', function(res) { 94 | console.log('Match /*? but not an image and return 404') 95 | assert.equal(res.statusCode, 404) 96 | assert.equal(res.headers['x-static'], 'next') 97 | tryClose() 98 | }).on('error', httpError) 99 | 100 | } 101 | 102 | var waitingFor = 4 103 | function tryClose() { 104 | waitingFor-- 105 | if (!waitingFor) { 106 | process.exit() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require('assert'), 2 | Router = require('../index'), 3 | router = Router(); 4 | 5 | (function(){ 6 | //avoid typing assert.blah all over 7 | for(k in assert){ 8 | this[k] = assert[k]; 9 | } 10 | })(); 11 | 12 | equal(1, 1); 13 | 14 | var noop = function(){}; 15 | 16 | var cases = [ 17 | { 18 | path: '/lang', 19 | testMatch: { 20 | '/lang' :{ 21 | action: noop, 22 | params: {}, 23 | splats: [] 24 | }, 25 | '/lang/' :{ 26 | action: noop, 27 | params: {}, 28 | splats: [] 29 | } 30 | } 31 | }, 32 | { 33 | path: '/lang/:lang([a-z]{2})', 34 | testMatch :{ 35 | '/lang/de':{ 36 | action: noop, 37 | params: { 38 | 'lang':'de' 39 | }, 40 | splats:[] 41 | } 42 | }, 43 | testNoMatch: ['/lang/who', '/lang/toolong', '/lang/1'] 44 | }, 45 | { 46 | path: '/normal/:id', 47 | testMatch: { 48 | '/normal/1':{ 49 | action: noop, 50 | params: { 51 | id: '1' 52 | }, 53 | splats: [] 54 | } 55 | }, 56 | testNoMatch: ['/normal/1/updates'] 57 | }, 58 | { 59 | path: '/optional/:id?', 60 | testMatch: { 61 | '/optional/1':{ 62 | action: noop, 63 | params: { 64 | id: '1' 65 | }, 66 | splats: [] 67 | }, 68 | '/optional/':{ 69 | action: noop, 70 | params: { 71 | id: undefined 72 | }, 73 | splats: [] 74 | } 75 | }, 76 | testNoMatch: ['/optional/1/blah'] 77 | }, 78 | { 79 | path: '/empty/*', 80 | testMatch: { 81 | '/empty/':{ 82 | action: noop, 83 | params: { }, 84 | splats:[''], 85 | } 86 | }, 87 | testNomatch: [ '/empty' ] 88 | }, 89 | { 90 | path: '/whatever/*.*', 91 | testMatch: { 92 | '/whatever/1/2/3.js':{ 93 | action: noop, 94 | params: { }, 95 | splats:['1/2/3', 'js'], 96 | } 97 | }, 98 | testNomatch: [ '/whatever/' ] 99 | }, 100 | { 101 | path: '/files/*.*', 102 | testMatch: { 103 | '/files/hi.json':{ 104 | action: noop, 105 | params: {}, 106 | splats: ['hi', 'json'] 107 | }, 108 | '/files/blah/blah.js':{ 109 | action: noop, 110 | params: {}, 111 | splats: ['blah/blah', 'js'] 112 | } 113 | }, 114 | testNoMatch: ['/files/', '/files/blah'] 115 | }, 116 | { 117 | path: '/transitive/:kind/:id/:method?.:format?', 118 | testMatch: { 119 | '/transitive/users/ekjnekjnfkej': { 120 | action: noop, 121 | params: { 122 | 'kind':'users', 123 | 'id':'ekjnekjnfkej', 124 | 'method': undefined, 125 | 'format': undefined }, 126 | splats:[], 127 | }, 128 | '/transitive/users/ekjnekjnfkej/update': { 129 | action: noop, 130 | params: { 131 | 'kind':'users', 132 | 'id':'ekjnekjnfkej', 133 | 'method': 'update', 134 | 'format': undefined }, 135 | splats:[], 136 | }, 137 | '/transitive/users/ekjnekjnfkej/update.json': { 138 | action: noop, 139 | params: { 140 | 'kind':'users', 141 | 'id':'ekjnekjnfkej', 142 | 'method': 'update', 143 | 'format': 'json' }, 144 | splats:[], 145 | } 146 | }, 147 | testNoMatch: ['/transitive/kind/', '/transitive/'] 148 | }, 149 | { 150 | path: /^\/(\d{2,3}-\d{2,3}-\d{4})\.(\w*)$/, 151 | testMatch :{ 152 | '/123-22-1234.json':{ 153 | action: noop, 154 | params: {}, 155 | splats:['123-22-1234', 'json'] 156 | } 157 | }, 158 | testNoMatch: ['/123-1-1234.png', '/123-22-1234', '/123.png'] 159 | }, 160 | { 161 | path: '/cat/*', 162 | testMatch: { 163 | '/cat/%' :{ 164 | action: noop, 165 | params: {}, 166 | splats: ['%'] 167 | } 168 | } 169 | }, 170 | { 171 | path: '*://*example.com/:foo/*/:bar', 172 | testMatch: { 173 | 'http://www.example.com/the/best/test' :{ 174 | action: noop, 175 | params: { 176 | 'foo':'the', 177 | 'bar':'test' 178 | }, 179 | splats: ['http','www.','best'] 180 | } 181 | } 182 | }, 183 | { 184 | path: '*://*example.com/:foo/*/:bar', 185 | testMatch: { 186 | 'http://example.com/the/best/test' :{ 187 | action: noop, 188 | params: { 189 | 'foo':'the', 190 | 'bar':'test' 191 | }, 192 | splats: ['http','','best'] 193 | } 194 | } 195 | }, 196 | { 197 | path: "/:id([1-9]\\d*)d", 198 | testMatch: { 199 | "/1d": { 200 | action: noop, 201 | params: { 202 | id: "1" 203 | }, 204 | splats: [] 205 | }, 206 | "/123d": { 207 | action: noop, 208 | params: { 209 | id: "123" 210 | }, 211 | splats: [] 212 | } 213 | }, 214 | testNoMatch: ["/0d", "/0123d", "/d1d", "/123asd"] 215 | }, 216 | { 217 | path: "/a:test(a.*z)z", 218 | testMatch: { 219 | "/aabcdzz": { 220 | action: noop, 221 | params: { 222 | test: "abcdz" 223 | }, 224 | splats: [] 225 | } 226 | }, 227 | testNoMatch: ["/abcdz", "/aaaz", "/azzz", "/az"] 228 | } 229 | ]; 230 | 231 | //load routes 232 | for(caseIdx in cases){ 233 | test = cases[caseIdx]; 234 | router.addRoute(test.path, noop); 235 | } 236 | 237 | var assertCount = 0; 238 | 239 | //run tests 240 | for(caseIdx in cases){ 241 | test = cases[caseIdx]; 242 | for(path in test.testMatch){ 243 | match = router.match(path); 244 | fixture = test.testMatch[path]; 245 | 246 | //save typing in fixtures 247 | fixture.route = test.path.toString(); // match gets string, so ensure same type 248 | if (match) { 249 | delete match.next; // next shouldn't be compared 250 | delete match.index; 251 | } 252 | deepEqual(match, fixture); 253 | assertCount++; 254 | } 255 | 256 | for(noMatchIdx in test.testNoMatch){ 257 | match = router.match(test.testNoMatch[noMatchIdx]); 258 | strictEqual(match, undefined); 259 | assertCount++; 260 | } 261 | } 262 | 263 | //test exceptions 264 | assert.throws( 265 | function() { 266 | router.addRoute(); 267 | } 268 | , /route requires a path/ 269 | , 'expected "route requires a path" error' 270 | ); 271 | 272 | assertCount++; 273 | 274 | assert.throws( 275 | function() { 276 | router.addRoute('/'); 277 | } 278 | , /route \/ requires an action/ 279 | , 'expected "route requires an action" error' 280 | ); 281 | 282 | assertCount++; 283 | 284 | // test next 285 | router.addRoute('/*?', noop); 286 | router.addRoute('/next/x', noop); 287 | var match = router.match('/next/x'); 288 | equal(typeof match.next, 'function') 289 | strictEqual(match.route, '/*?'); 290 | assertCount++; 291 | var next = match.next(); 292 | strictEqual(next.route, '/next/x'); 293 | assertCount++; 294 | 295 | console.log(assertCount.toString()+ ' assertions made succesfully'); 296 | --------------------------------------------------------------------------------