├── .gitignore ├── package.json ├── README.markdown ├── index.js └── test └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "beeline", 3 | "version": "0.2.4", 4 | "description": "A laughably simplistic router for node.js", 5 | "keywords": [ 6 | "url", 7 | "dispatch", 8 | "router", 9 | "request handler", 10 | "middleware" 11 | ], 12 | "maintainers": [ 13 | { 14 | "name": "Xavi", 15 | "email": "xavi.rmz@gmail.com", 16 | "web": "http://xavi.co" 17 | } 18 | ], 19 | "main": "./index.js", 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/xavi-/beeline.git" 23 | }, 24 | "engines": { 25 | "node": ">= 0.3.1" 26 | }, 27 | "dependencies": { 28 | "lru-cache": "~2.3.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Beeline 2 | 3 | A laughably simplistic router for node.js 4 | 5 | Currently works with node.js v0.3.1 and above 6 | 7 | ## Goals 8 | * Simple 9 | * Unobtrusive 10 | * Fairly Foolproof 11 | * Easy to debug 12 | * Fast 13 | 14 | ## Examples 15 | 16 | ```javascript 17 | var bee = require("beeline"); 18 | var router = bee.route({ // Create a new router 19 | "/cheggit": function(req, res) { 20 | // Called when req.url === "/cheggit" or req.url === "/cheggit?woo=poo" 21 | }, 22 | "/names/`last-name`/`first-name`": function(req, res, tokens, values) { 23 | // Called when req.url contains three parts, the first of is "name". 24 | // The parameter tokens is an object that maps token names to values. 25 | // For example if req.url === "/names/smith/will" 26 | // then tokens === { "first-name": "will", "last-name": "smith" } 27 | // and values === [ "will", "smith" ] 28 | // also req.params === tokens 29 | }, 30 | "/static/`path...`": function(req, res, tokens, values) { 31 | // Called when req.url starts with "/static/" 32 | // The parameter tokens is an object that maps token name to a value 33 | // The parameter values is a list of 34 | // For example if req.url === "/static/pictures/actors/smith/will.jpg" 35 | // then tokens === { "path": "pictures/actors/smith/will.jpg" } 36 | // and values === [ "pictures/actors/smith/will.jpg" ] 37 | // also req.params === tokens 38 | }, 39 | "/`user`/static/`path...`": function(req, res, tokens, values) { 40 | // Called when req.url contains at least three parts, the second of which 41 | // is "static". 42 | // The parameter tokens is an object that maps token names and value 43 | // For example if req.url === "/da-oozer/static/pictures/venkman.jpg" 44 | // then tokens === { "user": "da-oozer", "path": "pictures/venkman.jpg" } 45 | // and values === [ "da-oozer", "pictures/venkman.jpg" ] 46 | // also req.params === tokens 47 | }, 48 | "/blogs/`user-id: [a-z]{2}-\\d{5}`/`post-id: \\d+`": function( 49 | req, res, tokens, values 50 | ) { 51 | // Called when req.url starts with "/blogs/" and when the second and third 52 | // parts match /[a-z]{2}-\d{5}/ and /\d+/ respectiviely. 53 | // The parameter tokens is an object that maps token names and value 54 | // For example if req.url === "/blog/ab-12345/1783" 55 | // then tokens === { "user-id": "ab-12345", "post-id": "1783" } 56 | // and values === [ "ab-12345", "1783" ] 57 | // also req.params === tokens 58 | }, 59 | "r`^/actors/([\\w]+)/([\\w]+)$`": function(req, res, matches) { 60 | // Called when req.url matches this regex: "^/actors/([\\w]+)/([\\w]+)$" 61 | // An array of captured groups is passed as the third parameter 62 | // For example if req.url === "/actors/smith/will" 63 | // then matches === [ "smith", "will" ] 64 | }, 65 | "`404`": function(req, res) { 66 | // Called when no other route rule are matched 67 | // 68 | // This handler can later be called explicitly with router.missing 69 | }, 70 | "`500`": function(req, res, err) { 71 | // Called when an exception is thrown by another router function 72 | // The error that caused the exception is passed as the third parameter 73 | // This _not_ guaranteed to catch all exceptions 74 | // 75 | // This handler can later be called explicitly with router.error 76 | } 77 | }); 78 | 79 | router.add({ // Use `.add` to append new rules to a router 80 | "/ /home r`^/index(.php|.html|.xhtml)?$`": function(req, res) { 81 | // Called when req.url === "/" or req.url === "/home" 82 | // or req.url matches this regex: ^/index(.php|.html|.xhtml)?$ 83 | // (i.e. req.url === "/index.php" or req.url === "/index.html") 84 | // Note that any number of rules can be combined using a space. 85 | // All rules will call the same request handler when matched. 86 | }, 87 | "/my-method": { // Method (aka verb) specific dispatch. Note case matters. 88 | "GET": function(req, res) { 89 | // Called when req.url === "/my-method" and req.method === "GET" 90 | }, 91 | "POST PUT": function(req, res) { 92 | // Called when req.url === "/my-method" and 93 | // req.method === "POST" or req.method === "PUT" 94 | // Methods can be combined with a space like URL rules. 95 | }, 96 | "any": function(req, res) { 97 | // Called when req.url === "/my-method" and req.method is not 98 | // "GET" or "POST" 99 | } 100 | }, 101 | "`405`": function(req, res) { 102 | // Called when when a URL is specified but no corresponding method (aka verb) 103 | // matches. For example, this handler would be executed if the "any" catch 104 | // all wasn't specified in the handler above and req.method === "HEAD" 105 | // 106 | // This handler can later be called explicitly with router.missingVerb 107 | }, 108 | "/explicit-calls": function(req, res) { // If necessary you can reroute requests 109 | if(url.parse(req.url).query["item-name"] === "unknown") { 110 | // Calls the 404 (aka missing) handler: 111 | return router.missing(req, res, this); 112 | // The last parameter is optional. It sets the this pointer in the 113 | // 404 handler. 114 | } 115 | 116 | if(url.parse(req.url).query["item-name"] === "an-error") { 117 | // Calls the 500 (aka error) handler: 118 | return router.error(req, res, err, this); 119 | // The last parameter is optional. It sets the this pointer in the 120 | // 500 handler. 121 | } 122 | 123 | // Do normal request handling 124 | } 125 | }); 126 | 127 | // Starts serve with routes defined above: 128 | require("http").createServer(router).listen(8001); 129 | ``` 130 | 131 | See `test/test.js` for a working example. 132 | 133 | ## The API 134 | 135 | To start, simply store the `beeline` library in a local variable: 136 | ```javascript 137 | var bee = require("beeline"); 138 | ``` 139 | The `beeline` library contains the following three methods: 140 | 141 | - `bee.route(routes)`: Used to create a new router. It returns a function called `rtn_fn` that takes [ServerRequest](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerRequest) and [ServerResponse](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerResponse) objects as parameters. The `routes` parameter is an objects that maps rules to handlers. See examples section for more details. 142 | - `bee.staticFile(path, mimeType[, maxage=31536000])`: This is a utility method that is used to quickly expose static files. It returns a function called `rtn_fn` that takes [ServerRequest](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerRequest) and [ServerResponse](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerResponse) objects as parameters. When `rtn_fn` is called, the file contents located at `path` are served (via the ServerResponse) with the `Content-Type` set to the `mimeType` parameter. If the file at `path` does not exist a `404` is served. The optional `maxage` parameter is used to in the response's `Cache-Control` header. Also note that all `Set-Cookie` headers are removed. Here's an example of how you might use `bee.staticFile`: 143 | 144 | ```javascript 145 | bee.route({ 146 | "/robots.txt": bee.staticFile("./content/robots.txt", "text/plain") 147 | }); 148 | ``` 149 | - `bee.staticDir(path, mimeTypes[, maxage=31536000])`: This is utility method is used to expose directories of files. It returns a function called `rtn_fn` that takes a [ServerRequest](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerRequest) object, a [ServerResponse](http://nodejs.org/docs/v0.6.10/api/http.html#http.ServerResponse) object, an optional third parameter, and an array of strings called `matches` as parameters. Whenever `rtn_fn` is called, the items of `matches` are joined together and then concatenated to `path`. The resulting string is assumed to be a path to a specific file. If this file exists, its contents are served (via the ServerResponse) with the `Content-Type` set to the value that corresponds to the file's extension in the `mimeTypes` object. If the resulting string doesn't point to an existing file or if the file's extension is not found in `mimeTypes`, then a `404` is served. Also, file extensions require a leading period (`.`) and are assumed to be lowercase. The optional `maxage` parameter is used to in the response's `Cache-Control` header. Also note that all `Set-Cookie` headers are removed. Here's an example of how you might use `bee.staticDir`: 150 | 151 | ```javascript 152 | bee.route({ 153 | // /pics/mofo.png serves ./content/pics/mofo.png 154 | // /pics/la-ghetto/oh-gee.gif serves ./content/pics/la-ghetto/oh-gee.gif 155 | // /pics/woo-fee.tiff serves a 404 since there's no corresponding 156 | // mimeType specified. 157 | // This helps prevent accidental exposure. 158 | "r`^/pics/(.*)$`": bee.staticDir( 159 | "./content/pics/", 160 | { 161 | ".gif": "image/gif", ".png": "image/png", 162 | ".jpg": "image/jpeg", ".jpeg": "image/jpeg" 163 | } 164 | ), 165 | // Also works with URLs with tokens 166 | // /static/help/faq.html serves ./static/help/faq.html 167 | // /static/properties.json serves a 404 since there's no corresponding 168 | // mimeType specified. 169 | "/static/`path...`": bee.staticDir( 170 | "./static/", 171 | { 172 | ".txt": "text/plain", ".html": "text/html", 173 | ".css": "text/css", ".xml": "text/xml" 174 | } 175 | ), 176 | // More complicated path constructs also works 177 | // /will-smith/img-library/headshots/sexy42.jpg 178 | // serves ./user-images/will-smith/headshots/sexy42.jpg 179 | "/`user`/img-library/`path...`": bee.staticDir( 180 | "./user-images/", { ".jpg": "image/jpeg", ".jpeg": "image/jpeg" } 181 | ) 182 | }); 183 | ``` 184 | 185 | Beeline is also at least somewhat compatibile with [expressjs](https://github.com/visionmedia/express). Here's an example: 186 | 187 | ```javascript 188 | app.use(beeline.route({ 189 | "/": function(req, res, next) { 190 | fs.readFile("./templates/index.html", function(err, data) { 191 | if(err) { throw err; } 192 | 193 | res.html(data); 194 | }); 195 | }, 196 | "/`user`/static/`path...`": function(req, res, tokens, values, next) { 197 | /* ... code ... */ 198 | } 199 | })); 200 | ``` 201 | 202 | Note the `next` callback is always passed as the last parameter. 203 | 204 | ### Precedence Rules 205 | 206 | In the event that a request matches two rules, the following precedence rules are considered: 207 | 208 | - Fully defined rules take highest precedence. In other words, `"/index"` has a higher precedences then ``"r`^/index$`"`` even though semantically both rules are exactly the same. 209 | - Tokens and RegExp rules have the same precedence 210 | - RegExp rules take higher precedence than `404` 211 | - `404` and `405` have the lowest precedences 212 | - The `500` rules is outside the precedence rules. It can potentially be triggered at any time. 213 | - Amoung request methods, "any" has the lowerest precdence. Also note that the "x-http-method-override" header is respected. 214 | 215 | If the exact same rule is defined twice, then it's unspecified which request handler will be triggered. 216 | 217 | ## Getting Beeline 218 | 219 | The easiest way to get beeline is with [npm](http://npmjs.org/): 220 | 221 | npm install beeline 222 | 223 | Alternatively you can clone this git repository: 224 | 225 | git clone git://github.com/xavi-/beeline.git 226 | 227 | ## Running Unit Tests 228 | 229 | Execute the following commands to run the beeline's unit tests: 230 | 231 | $ cd 232 | $ cd test 233 | $ node test.js 234 | 235 | The last line printed to the console should be, "All done. Everything passed.", if all the tests passed successfully. 236 | 237 | ## Developed by 238 | * Xavi Ramirez 239 | 240 | ## License 241 | This project is released under [The MIT License](http://www.opensource.org/licenses/mit-license.php). 242 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var url = require("url"); 2 | var fs = require("fs"); 3 | var path = require("path"); 4 | var crypto = require("crypto"); 5 | var lru = require("lru-cache"); 6 | 7 | var getBuffer = (function() { 8 | var buffers = lru({ max: 1024 * 500, length: function(n) { return n.length; } }); 9 | 10 | function watchBuffer(filePath) { 11 | if(buffers.has(filePath)) { return; } 12 | 13 | buffers.del(filePath); 14 | fs.watchFile(filePath, function() { buffers.del(filePath); }); 15 | } 16 | 17 | return function getBuffer(filePath, callback) { 18 | if(buffers.has(filePath)) { return callback(null, buffers.get(filePath)); } 19 | 20 | fs.stat(filePath, function(err, stats) { 21 | if(err && err.code === "ENOENT") { 22 | return callback({ "file-not-found": true, path: filePath }, null); 23 | } 24 | 25 | if(err) { return callback(err, null); } 26 | 27 | if(!stats.isFile()) { 28 | return callback({ "not-a-file": true, path: filePath }, null); 29 | } 30 | 31 | fs.readFile(filePath, function(err, data) { 32 | if(err) { return callback(err, null); } 33 | 34 | watchBuffer(filePath); 35 | buffers.set(filePath, { 36 | data: data, 37 | sum: crypto.createHash("sha1").update(data).digest("hex") 38 | }); 39 | callback(null, buffers.get(filePath)); 40 | }); 41 | }); 42 | }; 43 | })(); 44 | 45 | function sendBuffer(req, res, mimeType, maxAge) { 46 | return function(err, buffer) { 47 | if(err) { 48 | if(err["file-not-found"]) { 49 | console.error("Could not find file: " + err.path); 50 | return default404(req, res); 51 | } else if(err["not-a-file"]) { 52 | console.error("Not a file: " + err.path); 53 | return default404(req, res); 54 | } 55 | 56 | throw err; 57 | } 58 | 59 | if(maxAge == null) { maxAge = 365 * 24 * 60 * 60; } 60 | 61 | res.removeHeader("Set-Cookie"); 62 | res.setHeader("Cache-Control", "private, max-age=" + maxAge); 63 | res.setHeader("ETag", buffer.sum); 64 | 65 | if(req.headers["if-none-match"] === buffer.sum) { 66 | res.writeHead(304); 67 | return res.end(); 68 | } else { 69 | res.writeHead( 70 | res.statusCode || 200, 71 | { "Content-Length": buffer.data.length, "Content-Type": mimeType } 72 | ); 73 | return res.end(buffer.data, "binary"); 74 | } 75 | }; 76 | } 77 | 78 | var staticFile = (function() { 79 | function handler(filePath, mimeType, req, res, maxAge) { 80 | getBuffer(filePath, sendBuffer(req, res, mimeType, maxAge)); 81 | } 82 | 83 | return function staticFile(filePath, mime, maxAge) { 84 | return function(req, res) { handler(filePath, mime, req, res, maxAge); }; 85 | }; 86 | })(); 87 | 88 | function staticDir(rootDir, mimeLookup, maxAge) { 89 | for(var key in mimeLookup) { 90 | if(key.charAt(0) !== ".") { 91 | console.warn("Extension found without a leading periond ('.'): '" + key + "'"); 92 | } 93 | } 94 | 95 | return function(req, res, extra, matches) { 96 | matches = matches || extra; 97 | var filePath = path.join.apply(path, [ rootDir ].concat(matches)); 98 | var ext = path.extname(filePath).toLowerCase(); 99 | 100 | if(!(ext in mimeLookup)) { 101 | console.error("Unknown file extension -- file: " + filePath + "; extension: " + ext); 102 | return default404(req, res); 103 | } 104 | 105 | if(path.relative(rootDir, filePath).indexOf("..") !== -1) { 106 | console.error( 107 | "Attempted access to parent directory -- root: " + rootDir + "; subdir: " + filePath 108 | ); 109 | return default404(req, res); 110 | } 111 | 112 | getBuffer(filePath, sendBuffer(req, res, mimeLookup[ext], maxAge)); 113 | }; 114 | } 115 | 116 | function default404(req, res, next) { 117 | if(next) { return next(); } 118 | 119 | var body = "404'd"; 120 | res.writeHead(404, { "Content-Length": body.length, "Content-Type": "text/plain" }); 121 | res.end(body); 122 | 123 | console.log("Someone 404'd: " + req.url); 124 | } 125 | function default405(req, res, next) { 126 | if(next) { return next(); } 127 | 128 | var body = "405'd"; 129 | res.writeHead(405, { "Content-Length": body.length, "Content-Type": "text/plain" }); 130 | res.end(body); 131 | 132 | console.log("Someone 405'd -- url: " + req.url + "; verb: " + req.method); 133 | } 134 | function default500(req, res, err, next) { 135 | if(next) { return next(err); } 136 | 137 | console.error("Error accessing: " + req.method + " " + req.url); 138 | console.error(err.message); 139 | console.error(err.stack); 140 | 141 | var body = [ "500'd" ]; 142 | body.push("An exception was thrown while accessing: " + req.method + " " + req.url); 143 | body.push("Exception: " + err.message); 144 | body = body.join("\n"); 145 | res.writeHead(500, { "Content-Length": body.length, "Content-Type": "text/plain" }); 146 | res.end(body); 147 | } 148 | 149 | function findPattern(patterns, urlPath) { 150 | for(var i = 0, l = patterns.length; i < l; i++) { 151 | if(patterns[i].regex.test(urlPath)) { 152 | return { 153 | handler: patterns[i].handler, 154 | extra: patterns[i].regex.exec(urlPath).slice(1) 155 | }; 156 | } 157 | } 158 | 159 | return null; 160 | } 161 | 162 | function findGeneric(generics, req) { 163 | for(var i = 0, l = generics.length; i < l; i++) { 164 | if(generics[i].test(req)) { return generics[i].handler; } 165 | } 166 | 167 | return null; 168 | } 169 | 170 | var rRegExUrl = /^r`(.*)`$/, rToken = /`(.*?)(?:(?:\:\s*(.*?))|(\.\.\.)?)`/g; 171 | function createTokenHandler(tokens, handler) { 172 | return function(req, res, oriVals, next) { 173 | var extra = Object.create(null); 174 | var newVals = new Array(tokens.length); 175 | for(var i = 0; i < tokens.length; i++) { 176 | var token = tokens[i]; 177 | extra[token.name] = decodeURIComponent(oriVals[token.captureIdx]); 178 | newVals[i] = oriVals[token.captureIdx]; 179 | } 180 | executeHandler(handler, this, req, res, { extra: extra, vals: newVals, next: next }); 181 | }; 182 | } 183 | var rHasFullCapture = /^\((?!\?[:!=]).*\)$/; 184 | function processEmbeddedRegex(regex) { 185 | regex = regex.trim(); 186 | var rTest = new RegExp("|" + regex); 187 | var numCaptures = rTest.exec("").length - 1; 188 | 189 | if(numCaptures <= 0) { return { regex: "(" + regex + ")", numCaptures: 1 }; } 190 | 191 | if(rHasFullCapture.test(regex)) { return { regex: regex, numCaptures: numCaptures }; } 192 | 193 | return { regex: "(" + regex + ")", numCaptures: numCaptures + 1 }; 194 | } 195 | var rHasBackreference = /\\[1-9]/; 196 | function parseToken(rule, handler) { 197 | var tokens = [], captureIdx = 0; 198 | var transform = rule.replace(rToken, function replaceToken(_, name, regex, isExtend) { 199 | if(!regex) { 200 | tokens.push({ name: name, captureIdx: captureIdx }); 201 | captureIdx += 1; 202 | } else { 203 | var info = processEmbeddedRegex(regex); 204 | regex = info.regex; 205 | tokens.push({ name: name, captureIdx: captureIdx }); 206 | captureIdx += info.numCaptures; 207 | if(rHasBackreference.test(regex)) { 208 | console.warn("Backreference are not supported -- url: " + rule); 209 | } 210 | } 211 | return regex || (isExtend ? "(.*?)" : "([^/]*?)"); 212 | }); 213 | var rRule = new RegExp("^" + transform + "$"); 214 | return { regex: rRule, handler: createTokenHandler(tokens, handler) }; 215 | } 216 | 217 | 218 | function executeHandler(handler, thisp, req, res, opts) { 219 | var override = req.headers && req.headers["x-http-method-override"]; 220 | handler = (handler[override] || handler[req.method] || handler.any || handler); 221 | 222 | var extra = opts.extra, vals = opts.vals, next = opts.next; 223 | 224 | if(extra && vals) { req.params = extra; } 225 | 226 | if(next) { 227 | if(extra && vals) { handler.call(thisp, req, res, extra, vals, next); } 228 | else if(extra) { handler.call(thisp, req, res, extra, next); } 229 | else { handler.call(thisp, req, res, next); } 230 | } else { 231 | if(extra && vals) { handler.call(thisp, req, res, extra, vals); } 232 | else if(extra) { handler.call(thisp, req, res, extra); } 233 | else { handler.call(thisp, req, res); } 234 | } 235 | } 236 | function expandVerbs(handler) { // Expands "POST GET": handler to "POST": handler, "GET": handler 237 | if(handler.test) { // For `generic` type handlers 238 | handler.handler = expandVerbs(handler.handler); 239 | return handler; 240 | } 241 | 242 | for(var key in handler) { 243 | key.split(/\s+/).forEach(function(method) { 244 | handler[method] = handler[key]; 245 | }); 246 | } 247 | 248 | return handler; 249 | } 250 | var rWhitespace = /[\x20\t\r\n\f]/; 251 | function splitRules(key) { 252 | var rules = []; 253 | var isQuoted = false, isPrevSpace = false; 254 | 255 | var ruleIdx = 0, curIdx = 0; 256 | while(curIdx < key.length) { 257 | var chr = key.charAt(curIdx); 258 | 259 | if(chr === "`") { isQuoted = !isQuoted; curIdx += 1; continue; } 260 | if(isQuoted) { curIdx += 1; continue; } 261 | if(!rWhitespace.test(chr)) { curIdx += 1; continue; } 262 | 263 | rules.push(key.substring(ruleIdx, curIdx)); 264 | do { // consume whitespace 265 | curIdx += 1; 266 | chr = key.charAt(curIdx); 267 | } while(curIdx < key.length && rWhitespace.test(chr)); 268 | ruleIdx = curIdx; 269 | } 270 | 271 | if(isQuoted) { console.warn("Invalid beeline rule: " + key.substring(ruleIdx)); } 272 | else if(ruleIdx !== curIdx) { rules.push(key.substring(ruleIdx)); } 273 | 274 | return rules; 275 | } 276 | function route(routes) { 277 | var preprocess = [], urls = Object.create(null), patterns = [], generics = []; 278 | var missing = default404, missingVerb = default405, error = default500; 279 | 280 | function handler(req, res, next) { 281 | try { 282 | var urlPath = url.parse(req.url).pathname; 283 | var info = ( 284 | urls[urlPath] || 285 | findPattern(patterns, urlPath) || 286 | findGeneric(generics, req) || 287 | missing 288 | ); 289 | var handler = info.handler || info; 290 | var extra = info.extra; 291 | 292 | preprocess.forEach(function(process) { process(req, res); }); 293 | 294 | executeHandler(handler, this, req, res, { extra: extra, next: next }); 295 | } catch(err) { 296 | error.call(this, req, res, err, next); 297 | } 298 | } 299 | function missingVerbHandler(req, res) { 300 | var next = arguments[arguments.length - 1]; 301 | 302 | if(typeof next !== "function") { missingVerb.call(this, req, res); } 303 | else { missingVerb.call(this, req, res, next); } 304 | } 305 | handler.add = function(routes) { 306 | for(var key in routes) { 307 | var handler = routes[key]; 308 | 309 | if(Object.prototype.toString.call(handler) === "[object Object]") { 310 | handler.any = handler.any || missingVerbHandler; 311 | } 312 | 313 | if(key === "`preprocess`") { 314 | if(!Array.isArray(handler)) { preprocess.push(handler); } 315 | else { Array.prototype.push.apply(preprocess, handler); } 316 | continue; 317 | } 318 | 319 | splitRules(key).forEach(function(rule) { 320 | if(rule.indexOf("`") === -1) { 321 | if(rule in urls) { console.warn("Duplicate beeline rule: " + rule); } 322 | if(rule.charAt(0) !== "/") { 323 | console.warn("Url doesn't have leading slash (/): " + rule); 324 | } 325 | urls[rule] = expandVerbs(handler); 326 | } else if(rule === "`404`" || rule === "`missing`" || rule === "`default`") { 327 | if(missing !== default404) { console.warn("Duplicate beeline rule: " + rule); } 328 | missing = expandVerbs(handler); 329 | } else if( 330 | rule === "`405`" || rule === "`missing-verb`" || rule === "`missingVerb`" 331 | ) { 332 | if(missingVerb !== default405) { 333 | console.warn("Duplicate beeline rule: " + rule); 334 | } 335 | missingVerb = expandVerbs(handler); 336 | } else if(rule === "`500`" || rule === "`error`") { 337 | if(error !== default500) { console.warn("Duplicate beeline rule: " + rule); } 338 | error = expandVerbs(handler); 339 | } else if(rule === "`generics`") { 340 | Array.prototype.push.apply(generics, handler.map(expandVerbs)); 341 | } else if(rRegExUrl.test(rule)) { 342 | var rRule = new RegExp(rRegExUrl.exec(rule)[1]); 343 | var cmpRegEx = function(p) { return p.regex.toString() === rRule.toString(); }; 344 | if(patterns.some(cmpRegEx)) { 345 | console.warn("Duplicate beeline rule: " + rule); 346 | } 347 | patterns.push({ regex: rRule, handler: expandVerbs(handler) }); 348 | } else if(rToken.test(rule)) { 349 | var pattern = parseToken(rule, expandVerbs(handler)); 350 | var cmpPattern = function(p) { 351 | return p.regex.toString() === pattern.regex.toString(); 352 | }; 353 | if(patterns.some(cmpPattern)) { 354 | console.warn("Duplicate beeline rule: " + rule); 355 | } 356 | patterns.push(pattern); 357 | } else { 358 | console.warn("Invalid beeline rule: " + rule); 359 | } 360 | }); 361 | } 362 | }; 363 | handler.missing = function(req, res, thisp) { 364 | missing.call(thisp, req, res); 365 | }; 366 | handler.missingVerb = function(req, res, thisp) { 367 | missingVerb.call(thisp, req, res); 368 | }; 369 | handler.error = function(req, res, err, thisp) { 370 | error.call(thisp, req, res, err); 371 | }; 372 | handler.add(routes); 373 | 374 | return handler; 375 | } 376 | 377 | exports.route = route; 378 | exports.staticFile = staticFile; 379 | exports.staticDir = staticDir; -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var fs = require("fs"); 3 | var crypto = require("crypto"); 4 | var bee = require("../"); 5 | 6 | var tests = { 7 | expected: 74, 8 | executed: 0, 9 | finished: function() { tests.executed++; } 10 | }; 11 | var warnings = {}; 12 | console.warn = function(msg) { warnings[msg] = true; tests.finished(); }; 13 | 14 | var router = bee.route({ 15 | "/test": function(req, res) { assert.equal(req.url, "/test?param=1&woo=2"); tests.finished(); }, 16 | "/throw-error": function(req, res) { throw Error("500 should catch"); }, 17 | "/names/`last-name`/`first-name`": function(req, res, tokens, vals) { 18 | assert.equal(req.url, "/names/smith/will"); 19 | assert.equal(tokens, req.params); 20 | assert.equal(tokens["first-name"], "will"); 21 | assert.equal(tokens["last-name"], "smith"); 22 | assert.equal(vals[0], "smith"); 23 | assert.equal(vals[1], "will"); 24 | tests.finished(); 25 | }, 26 | "/static/`path...`": function(req, res, tokens, vals) { 27 | assert.equal(req.url, "/static/pictures/actors/smith/will.jpg"); 28 | assert.equal(tokens["path"], "pictures/actors/smith/will.jpg"); 29 | assert.equal(vals[0], "pictures/actors/smith/will.jpg"); 30 | tests.finished(); 31 | }, 32 | "/`user`/static/`path...`": function(req, res, tokens, vals) { 33 | assert.equal(req.url, "/da-oozer/static/pictures/venkman.jpg"); 34 | assert.equal(tokens, req.params); 35 | assert.equal(tokens["user"], "da-oozer"); 36 | assert.equal(tokens["path"], "pictures/venkman.jpg"); 37 | assert.equal(vals[0], "da-oozer"); 38 | assert.equal(vals[1], "pictures/venkman.jpg"); 39 | tests.finished(); 40 | }, 41 | "/`user`/profile": function(req, res, tokens, vals) { // Ensure tokens are decoded but not vals 42 | assert.equal(req.url, "/%E2%88%91%C3%A9%C3%B1/profile"); 43 | assert.equal(tokens, req.params); 44 | assert.equal(tokens["user"], "∑éñ"); 45 | assert.equal(vals[0], "%E2%88%91%C3%A9%C3%B1"); 46 | tests.finished(); 47 | }, 48 | "r`^/actors/([\\w]+)/([\\w]+)$`": function(req, res, matches) { 49 | assert.equal(req.url, "/actors/smith/will"); 50 | assert.equal(req.params, undefined); 51 | assert.equal(matches[0], "smith"); 52 | assert.equal(matches[1], "will"); 53 | tests.finished(); 54 | }, 55 | "`generics`": [ { 56 | test: function(req) { return req.triggerGeneric; }, 57 | handler: function(req, res) { 58 | assert.equal(req.params, undefined); 59 | assert.ok(req.triggerGeneric); tests.finished(); 60 | } 61 | } 62 | ], 63 | "`404`": function(req, res) { 64 | assert.equal(req.url, "/url-not-found"); 65 | tests.finished(); 66 | }, 67 | "`500`": function(req, res, err) { 68 | try { assert.equal(req.url, "/throw-error"); } 69 | catch(e) { 70 | console.error(e.stack); 71 | console.error("Caused by:"); 72 | console.error(err.stack); 73 | process.exit(); 74 | } 75 | assert.equal(err.message, "500 should catch"); 76 | tests.finished(); 77 | } 78 | }); 79 | router({ url: "/test?param=1&woo=2" }); 80 | router({ url: "/throw-error" }); 81 | router({ url: "/names/smith/will" }); 82 | router({ url: "/static/pictures/actors/smith/will.jpg" }); 83 | router({ url: "/da-oozer/static/pictures/venkman.jpg" }); 84 | router({ url: "/%E2%88%91%C3%A9%C3%B1/profile" }); 85 | router({ url: "/actors/smith/will" }); 86 | router({ url: "/random", triggerGeneric: true }); 87 | router({ url: "/url-not-found" }); 88 | 89 | router.add({ 90 | "/ /home r`^/index(.php|.html|.xhtml)?$`": function(req, res) { 91 | assert.ok( 92 | req.url === "/" || 93 | req.url === "/index" || 94 | req.url === "/index.php" || 95 | req.url === "/home" 96 | ); 97 | tests.finished(); 98 | } 99 | }); 100 | router({ url: "/" }); 101 | router({ url: "/index" }); 102 | router({ url: "/index.php" }); 103 | router({ url: "/home" }); 104 | 105 | router.add({ 106 | "/method-test/`id`": { 107 | "GET": function(req, res) { assert.equal(req.method, "GET"); tests.finished(); }, 108 | "POST": function(req, res) { assert.equal(req.method, "POST"); tests.finished(); }, 109 | "any": function(req, res) { 110 | assert.ok(req.method !== "GET" || req.method !== "POST"); tests.finished(); 111 | } 112 | }, 113 | "/fake-put": { 114 | "PUT": function(req, res) { 115 | assert.equal(req.method, "GET"); 116 | assert.equal(req.headers["x-http-method-override"], "PUT"); 117 | tests.finished(); 118 | }, 119 | "any": function(req, res) { throw "I shouldn't have been called...."; } 120 | }, 121 | "/`user`/profile/`path...`": { 122 | "POST PUT": function(req, res, tokens, vals) { 123 | assert.ok(req.method === "POST" || req.method === "PUT"); 124 | tests.finished(); 125 | } 126 | }, 127 | "`405`": function(req, res) { 128 | assert.strictEqual(arguments.length, 2); 129 | assert.equal(req.method, "GET"); 130 | assert.equal(req.url, "/dozer/profile/timeline/2010/holloween"); 131 | tests.finished(); 132 | } 133 | }); 134 | router({ url: "/method-test/123", method: "GET" }); 135 | router({ url: "/method-test/123", method: "POST" }); 136 | router({ url: "/method-test/123", method: "HEAD" }); 137 | router({ url: "/fake-put", headers: { "x-http-method-override": "PUT" }, method: "GET" }); 138 | router({ url: "/dozer/profile/timeline/2010/holloween", method: "POST" }); 139 | router({ url: "/dozer/profile/timeline/2010/holloween", method: "PUT" }); 140 | router({ url: "/dozer/profile/timeline/2010/holloween", method: "GET" }); 141 | 142 | // Testing preprocessors 143 | router.add({ 144 | "`preprocess`": function(req, res) { req.foo = "bar"; res.bar = "baz"; }, 145 | "/test-preprocess": function(req, res) { 146 | assert.equal(req.foo, "bar"); 147 | assert.equal(res.bar, "baz"); 148 | tests.finished(); 149 | } 150 | }); 151 | router({ url: "/test-preprocess" }, {}); 152 | 153 | // Testing warning messages 154 | router.add({ 155 | "/home": function() { }, 156 | "no-slash": function() { }, 157 | "r`^/actors/([\\w]+)/([\\w]+)$`": function() { }, 158 | "/`user`/static/`path...`": function() { }, 159 | "`404`": function() { }, 160 | "`405`": function() { }, 161 | "`500`": function() { }, 162 | "`not-a-valid-rule": function() { }, 163 | "/`not`/ok`": function() { }, 164 | "/`not`/`ok-either": function() { }, 165 | "/`backref: f(.)\\1`": function() { } 166 | }); 167 | 168 | assert.ok(warnings["Duplicate beeline rule: /home"]); 169 | assert.ok(warnings["Duplicate beeline rule: r`^/actors/([\\w]+)/([\\w]+)$`"]); 170 | assert.ok(warnings["Duplicate beeline rule: /`user`/static/`path...`"]); 171 | assert.ok(warnings["Duplicate beeline rule: `404`"]); 172 | assert.ok(warnings["Duplicate beeline rule: `405`"]); 173 | assert.ok(warnings["Duplicate beeline rule: `500`"]); 174 | assert.ok(warnings["Invalid beeline rule: `not-a-valid-rule"]); 175 | assert.ok(warnings["Invalid beeline rule: /`not`/ok`"]); 176 | assert.ok(warnings["Invalid beeline rule: /`not`/`ok-either"]); 177 | assert.ok(warnings["Backreference are not supported -- url: /`backref: f(.)\\1`"]); 178 | assert.ok(warnings["Url doesn't have leading slash (/): no-slash"]); 179 | 180 | // Testing explicit 404, 405, and error calls 181 | var router2 = bee.route({ 182 | "`404`": function(req, res) { 183 | assert.equal(req.url, "/explicit-404"); 184 | assert.equal(res.isCorrectRequest, 1); 185 | assert.equal(this.extra, 1); 186 | tests.finished(); 187 | }, 188 | "`405`": function(req, res) { 189 | assert.equal(req.url, "/explicit-405"); 190 | assert.equal(res.isCorrectRequest, 2); 191 | assert.equal(this.extra, 2); 192 | tests.finished(); 193 | }, 194 | "`500`": function(req, res, err) { 195 | assert.equal(req.url, "/explicit-500"); 196 | assert.equal(res.isCorrectRequest, 3); 197 | assert.ok(err.isError); 198 | assert.equal(this.extra, 3); 199 | tests.finished(); 200 | } 201 | }); 202 | router2.missing({ url: "/explicit-404" }, { isCorrectRequest: 1 }, { extra: 1 }); 203 | router2.missingVerb({ url: "/explicit-405" }, { isCorrectRequest: 2 }, { extra: 2 }); 204 | router2.error({ url: "/explicit-500" }, { isCorrectRequest: 3 }, { isError: true }, { extra: 3 }); 205 | 206 | // Testing default 404, 405, and error handlers 207 | var route3 = bee.route(); 208 | route3.missing({ request: true }, { 209 | writeHead: function(status, headers) { 210 | assert.equal(status, 404); 211 | this.contentLength = headers["Content-Length"]; 212 | tests.finished(); 213 | }, 214 | end: function(body) { 215 | assert.equal(this.contentLength, body.length); 216 | tests.finished(); 217 | } 218 | }); 219 | route3.missingVerb({ request: true }, { 220 | writeHead: function(status, headers) { 221 | assert.equal(status, 405); 222 | this.contentLength = headers["Content-Length"]; 223 | tests.finished(); 224 | }, 225 | end: function(body) { 226 | assert.equal(this.contentLength, body.length); 227 | tests.finished(); 228 | } 229 | }); 230 | route3.error({ request: true }, { 231 | writeHead: function(status, headers) { 232 | assert.equal(status, 500); 233 | this.contentLength = headers["Content-Length"]; 234 | tests.finished(); 235 | }, 236 | end: function(body) { 237 | assert.equal(this.contentLength, body.length); 238 | tests.finished(); 239 | } 240 | }, {}); 241 | 242 | var staticFile = bee.staticFile("../index.js", "application/x-javascript"); 243 | fs.readFile("../index.js", function(err, data) { 244 | if(err) { throw err; } 245 | 246 | var isHeadWritten = false, setHeaders = {}; 247 | staticFile({ headers: {}, url: "/load-existing-static-file" }, { // Mock response 248 | setHeader: function(type, val) { 249 | setHeaders[type] = val; 250 | }, 251 | writeHead: function(status, headers) { 252 | assert.equal(status, 200); 253 | assert.equal(headers["Content-Type"], "application/x-javascript"); 254 | assert.equal(headers["Content-Length"], data.length); 255 | assert.ok(setHeaders["Cache-Control"]); 256 | assert.ok(setHeaders["ETag"]); 257 | tests.finished(); 258 | isHeadWritten = true; 259 | }, 260 | removeHeader: function(header) { 261 | assert.equal(header, "Set-Cookie"); 262 | assert.ok(!isHeadWritten); 263 | tests.finished(); 264 | }, 265 | end: function(body) { 266 | assert.deepEqual(body, data); 267 | fs.unwatchFile("../index.js"); 268 | tests.finished(); 269 | } 270 | }); 271 | }); 272 | 273 | var static404 = bee.staticFile("../does-not-exists", "not/real"); 274 | static404({ url: "/load-non-existent-static-file" }, { // Mock response 275 | writeHead: function(status, headers) { 276 | assert.equal(status, 404); 277 | assert.notEqual(headers["Content-Type"], "not/real"); 278 | tests.finished(); 279 | }, 280 | end: function(body) { 281 | assert.ok(body); 282 | tests.finished(); 283 | } 284 | }); 285 | 286 | var staticDir = bee.staticDir("../", { 287 | ".json": "application/json", "js": "application/x-javascript" 288 | }); 289 | assert.ok(warnings["Extension found without a leading periond ('.'): 'js'"]); 290 | fs.readFile("../package.json", function(err, data) { 291 | if(err) { throw err; } 292 | 293 | var sum = crypto.createHash("sha1").update(data).digest("hex"); 294 | 295 | var isHeadWritten = false, setHeaders = {}; 296 | staticDir({ headers: {}, url: "/load-existing-file-from-static-dir" }, { 297 | // Mock response of an empty cache 298 | setHeader: function(type, val) { 299 | setHeaders[type] = val; 300 | }, 301 | writeHead: function(status, headers) { 302 | assert.equal(status, 200); 303 | assert.equal(headers["Content-Type"], "application/json"); 304 | assert.equal(headers["Content-Length"], data.length); 305 | assert.ok(setHeaders["Cache-Control"]); 306 | assert.equal(setHeaders["ETag"], sum); 307 | tests.finished(); 308 | isHeadWritten = true; 309 | }, 310 | removeHeader: function(header) { 311 | assert.equal(header, "Set-Cookie"); 312 | assert.ok(!isHeadWritten); 313 | tests.finished(); 314 | }, 315 | end: function(body) { 316 | assert.deepEqual(body, data); 317 | fs.unwatchFile("../package.json"); // Internally beelines watches files for changes 318 | tests.finished(); 319 | } 320 | }, [ "package.json" ]); 321 | }); 322 | fs.readFile("../package.json", function(err, data) { 323 | if(err) { throw err; } 324 | 325 | var sum = crypto.createHash("sha1").update(data).digest("hex"); 326 | 327 | var isHeadWritten = false, setHeaders = {}; 328 | staticDir({ headers: { "if-none-match": sum }, url: "/do-304s-work" }, { 329 | // Mock cached response 330 | setHeader: function(type, val) { 331 | setHeaders[type] = val; 332 | }, 333 | writeHead: function(status, headers) { 334 | assert.equal(status, 304); 335 | assert.ok(setHeaders["Cache-Control"]); 336 | assert.equal(setHeaders["ETag"], sum); 337 | tests.finished(); 338 | isHeadWritten = true; 339 | }, 340 | removeHeader: function(header) { 341 | assert.equal(header, "Set-Cookie"); 342 | assert.ok(!isHeadWritten); 343 | tests.finished(); 344 | }, 345 | end: function(body) { 346 | assert.ok(!body); // Ensure an empty body was sent 347 | fs.unwatchFile("../package.json"); // Internally beelines watches files for changes 348 | tests.finished(); 349 | } 350 | }, [ "package.json" ]); 351 | }); 352 | fs.readFile("../package.json", function(err, data) { 353 | if(err) { throw err; } 354 | 355 | var isHeadWritten = false, setHeaders = {}; 356 | staticDir({ headers: {}, url: "/called-with-optional-3rd-param" }, { // Mock response 357 | setHeader: function(type, val) { }, 358 | writeHead: function(status, headers) { }, 359 | removeHeader: function(header) { }, 360 | end: function(body) { 361 | assert.deepEqual(body, data); 362 | fs.unwatchFile("../package.json"); // Internally beelines watches files for changes 363 | tests.finished(); 364 | } 365 | }, { optional: "third parameter" }, [ "package.json" ]); // Called with optional third parameter 366 | }); 367 | staticDir({ url: "/load-unrecognized-file-extension" }, { // Mock response 368 | writeHead: function(status, headers) { 369 | assert.equal(status, 404); 370 | assert.ok(headers["Content-Type"]); 371 | tests.finished(); 372 | }, 373 | end: function(body) { 374 | assert.ok(body); 375 | tests.finished(); 376 | } 377 | }, [ "README.markdown" ]); 378 | 379 | staticDir({ url: "/attempt-to-insecurely-access-parent-directory" }, { // Mock response 380 | writeHead: function(status, headers) { 381 | assert.equal(status, 404); 382 | assert.ok(headers["Content-Type"]); 383 | tests.finished(); 384 | }, 385 | end: function(body) { 386 | assert.ok(body); 387 | tests.finished(); 388 | } 389 | }, [ "..", "..", "ok.json" ]); 390 | 391 | // Testing express compatibility 392 | var router4 = bee.route({ 393 | "/throw-error": function(req, res, next) { throw Error("500 should catch"); } 394 | }); 395 | router4({ url: "/unknown-url" }, {}, function next() { 396 | tests.finished(); 397 | }); 398 | router4({ url: "/throw-error" }, {}, function next(err) { 399 | assert.equal(err.message, "500 should catch"); 400 | tests.finished(); 401 | }); 402 | 403 | var daNext = function() {}; 404 | router4.add({ 405 | "/test": function(req, res, next) { 406 | assert.strictEqual(next, daNext); 407 | tests.finished(); 408 | }, 409 | "/test/`id`": { 410 | "GET": function() { throw Error("This shouldn't be called"); } 411 | }, 412 | "/names/`last-name`/`first-name`": function(req, res, tokens, vals, next) { 413 | assert.strictEqual(next, daNext); 414 | tests.finished(); 415 | }, 416 | "/static/`path...`": function(req, res, tokens, vals, next) { 417 | assert.strictEqual(next, daNext); 418 | tests.finished(); 419 | }, 420 | "/`user`/static/`path...`": function(req, res, tokens, vals, next) { 421 | assert.strictEqual(next, daNext); 422 | tests.finished(); 423 | }, 424 | "r`^/actors/([\\w]+)/([\\w]+)$`": function(req, res, matches, next) { 425 | assert.strictEqual(next, daNext); 426 | tests.finished(); 427 | }, 428 | "`generics`": [ { 429 | test: function(req) { return req.triggerGeneric; }, 430 | handler: function(req, res, next) { 431 | assert.strictEqual(next, daNext); 432 | tests.finished(); 433 | } 434 | } ], 435 | "`404`": function(req, res, next) { 436 | assert.strictEqual(next, daNext); 437 | tests.finished(); 438 | }, 439 | "`405`": function(req, res, next) { 440 | assert.strictEqual(arguments.length, 3); 441 | assert.strictEqual(next, daNext); 442 | tests.finished(); 443 | }, 444 | "`500`": function(req, res, err, next) { 445 | try { assert.equal(req.url, "/throw-error"); } 446 | catch(e) { 447 | console.error(e.stack); 448 | console.error("Caused by:"); 449 | console.error(err.stack); 450 | process.exit(); 451 | } 452 | 453 | assert.strictEqual(next, daNext); 454 | tests.finished(); 455 | } 456 | }); 457 | 458 | router4({ url: "/test?param=1&woo=2" }, {}, daNext); 459 | router4({ url: "/throw-error" }, {}, daNext); 460 | router4({ url: "/names/smith/will" }, {}, daNext); 461 | router4({ url: "/actors/smith/will" }, {}, daNext); 462 | router4({ url: "/da-oozer/static/pictures/venkman.jpg" }, {}, daNext); 463 | router4({ url: "/static/pictures/actors/smith/will.jpg" }, {}, daNext); 464 | router4({ url: "/random", triggerGeneric: true }, {}, daNext); 465 | router4({ url: "/url-not-found" }, {}, daNext); 466 | router4({ url: "/test/123", method: "POST" }, {}, daNext); 467 | 468 | // Test regex tokens 469 | var router5 = bee.route({ 470 | "/`game`/`user-id:([a-z]{2}-\\d{5})`/`post-id:\\d+`/`file...`": function( 471 | req, res, tokens, vals 472 | ) { 473 | assert.equal(req.url, "/space-wars/ab-12345/1943/pics/friends/will-smith.jpeg"); 474 | assert.equal(tokens, req.params); 475 | assert.equal(tokens["game"], "space-wars"); 476 | assert.equal(tokens["user-id"], "ab-12345"); 477 | assert.equal(tokens["post-id"], "1943"); 478 | assert.equal(tokens["file"], "pics/friends/will-smith.jpeg"); 479 | assert.equal(vals[0], "space-wars"); 480 | assert.equal(vals[1], "ab-12345"); 481 | assert.equal(vals[2], "1943"); 482 | assert.equal(vals[3], "pics/friends/will-smith.jpeg"); 483 | tests.finished(); 484 | }, 485 | "/`foo: foo(?=/bar)`/`rest...`": function(req, res, tokens, vals) { 486 | assert.equal(req.url, "/foo/bar"); 487 | assert.equal(tokens, req.params); 488 | assert.equal(tokens["foo"], "foo"); 489 | assert.equal(tokens["rest"], "bar"); 490 | assert.equal(vals[0], "foo"); 491 | assert.equal(vals[1], "bar"); 492 | tests.finished(); 493 | }, 494 | "/`foo: foo(?!/bar).*`": function(req, res, tokens, vals) { 495 | assert.equal(req.url, "/foo-king"); 496 | assert.equal(tokens, req.params); 497 | assert.equal(tokens["foo"], "foo-king"); 498 | assert.equal(vals[0], "foo-king"); 499 | tests.finished(); 500 | }, 501 | "/`sum-space: ((((spacey)))) `/rule /another-spacey-rule ": function( 502 | req, res, tokens, vals 503 | ) { 504 | if(req.url === "/another-spacey-rule") { tests.finished(); return; } 505 | 506 | assert.equal(req.url, "/spacey/rule"); 507 | assert.equal(tokens, req.params); 508 | assert.equal(tokens["sum-space"], "spacey"); 509 | assert.equal(vals[0], "spacey"); 510 | tests.finished(); 511 | } 512 | }); 513 | 514 | router5({ url: "/space-wars/ab-12345/1943/pics/friends/will-smith.jpeg" }); 515 | router5({ url: "/foo/bar" }); 516 | router5({ url: "/foo-king" }); 517 | router5({ url: "/spacey/rule" }); 518 | router5({ url: "/another-spacey-rule" }); 519 | 520 | process.on("exit", function() { 521 | assert.equal(tests.executed, tests.expected); 522 | console.log("\n\nAll done. Everything passed."); 523 | }); 524 | --------------------------------------------------------------------------------