├── .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 |
--------------------------------------------------------------------------------