├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── index.js ├── lib ├── bucket.js ├── table.js └── xor.js ├── makefile ├── package.json └── test ├── fixtures ├── client.js └── jshint.json ├── governance ├── lint_index.js └── lint_lib.js ├── integration ├── ip_error.js ├── ip_ok.js ├── username_error.js ├── username_ok.js ├── xff_error.js └── xff_ok.js └── unit ├── bucket.js ├── table.js └── xor.js /.gitignore: -------------------------------------------------------------------------------- 1 | /* MacOS */ 2 | .DS_Store 3 | 4 | /* NPM */ 5 | /node_modules 6 | npm* 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.8" 4 | - "0.10" -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Andrew Sliwinski 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Micron-throttle 2 | #### Token bucket based HTTP request throttle for Node.js 3 | 4 | [![Build Status](https://travis-ci.org/thisandagain/micron-throttle.png)](https://travis-ci.org/thisandagain/micron-throttle) 5 | 6 | ### Installation 7 | ```bash 8 | npm install micron-throttle 9 | ``` 10 | 11 | ### Basic Use 12 | The throttle module can be used in just about any application that supports the `function (req, res, next)` middleware convention (such as express, connect, union or restify). For example in restify: 13 | ```js 14 | var http = require('restify'), 15 | throttle = require('micron-throttle'); 16 | 17 | // Create the server and pass-in the throttle middleware 18 | var server = restify.createServer(); 19 | server.use(throttle({ 20 | burst: 100, 21 | rate: 50, 22 | ip: true, 23 | overrides: { 24 | '192.168.1.1': { 25 | rate: 0, // unlimited 26 | burst: 0 27 | } 28 | } 29 | })); 30 | 31 | // Define a route 32 | server.get('/hello/:name', function (req, res, next) { 33 | res.send('hello ' + req.params.name); 34 | }); 35 | 36 | // Listen 37 | server.listen(3333); 38 | ``` 39 | 40 | ### Options 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 |
NameDefaultTypeDescription
rate10NumberSteady state number of requests/second to allow
burst25NumberIf available, the amount of requests to burst to
iptrueBooleanDo throttling on a /32 (source IP)
xfffalseBooleanDo throttling on a /32 (X-Forwarded-For)
usernamefalseBooleanDo throttling on req.username
overridesnullObjectPer "key" overrides
tokensTablen/aObjectStorage engine; must support set/get
maxKeys10000NumberIf using the built-in storage table, the maximum distinct throttling keys to allow at a time
61 | 62 | ### Testing 63 | ```bash 64 | npm test 65 | ``` 66 | 67 | ### Credits 68 | This module is adapted from [restify's](https://github.com/mcavage/node-restify) throttle plug-in – originally developed by [Mark Cavage](https://github.com/mcavage). -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Token bucket based HTTP request throttle for Node.js. 3 | * 4 | * @package restify-throttle 5 | * @author Mark Cavage 6 | * Andrew Sliwinski 7 | */ 8 | 9 | /** 10 | * Dependencies 11 | */ 12 | var _ = require('lodash'), 13 | assert = require('assert-plus'), 14 | sprintf = require('util').format; 15 | 16 | var xor = require('./lib/xor.js'), 17 | TokenBucket = require('./lib/bucket.js'), 18 | TokenTable = require('./lib/table.js'); 19 | 20 | var MESSAGE = 'You have exceeded your request rate of %s r/s.'; 21 | 22 | function Throttle () { 23 | var self = this; 24 | 25 | self.burst = 25; 26 | self.rate = 10; 27 | self.ip = false; 28 | self.xff = false; 29 | self.username = false; 30 | self.overrides = null; 31 | self.table = new TokenTable({ 32 | size: 10000 33 | }); 34 | 35 | var rateLimit = function (req, res, next) { 36 | var attr; 37 | if (self.ip) { 38 | attr = req.connection.remoteAddress; 39 | } else if (self.xff) { 40 | attr = req.headers['x-forwarded-for']; 41 | } else if (self.username) { 42 | attr = req.username; 43 | } else { 44 | return next(); 45 | } 46 | 47 | // Before bothering with overrides, see if this request 48 | // even matches 49 | if (!attr) return next(); 50 | 51 | // Check the overrides 52 | var burst = self.burst; 53 | var rate = self.rate; 54 | if (self.overrides && 55 | self.overrides[attr] && 56 | self.overrides[attr].burst !== undefined && 57 | self.overrides[attr].rate !== undefined) { 58 | burst = self.overrides[attr].burst; 59 | rate = self.overrides[attr].rate; 60 | } 61 | 62 | // Check if bucket exists, else create new 63 | var bucket = self.table.get(attr); 64 | 65 | if (!bucket) { 66 | bucket = new TokenBucket({ 67 | capacity: burst, 68 | fillRate: rate 69 | }); 70 | self.table.set(attr, bucket); 71 | } 72 | 73 | // Throttle request 74 | if (!bucket.consume(1)) { 75 | // Until https://github.com/joyent/node/pull/2371 is in 76 | var msg = sprintf(MESSAGE, rate); 77 | res.writeHead(429, 'application/json'); 78 | res.end('{"error":"' + msg + '"}'); 79 | return; 80 | } 81 | 82 | return next(); 83 | }; 84 | 85 | /** 86 | * Creates an API rate limiter that can be plugged into the standard 87 | * restify request handling pipeline. 88 | * 89 | * This throttle gives you three options on which to throttle: 90 | * username, IP address and 'X-Forwarded-For'. IP/XFF is a /32 match, 91 | * so keep that in mind if using it. Username takes the user specified 92 | * on req.username (which gets automagically set for supported Authorization 93 | * types; otherwise set it yourself with a filter that runs before this). 94 | * 95 | * In both cases, you can set a `burst` and a `rate` (in requests/seconds), 96 | * as an integer/float. Those really translate to the `TokenBucket` 97 | * algorithm, so read up on that (or see the comments above...). 98 | * 99 | * In either case, the top level options burst/rate set a blanket throttling 100 | * rate, and then you can pass in an `overrides` object with rates for 101 | * specific users/IPs. You should use overrides sparingly, as we make a new 102 | * TokenBucket to track each. 103 | * 104 | * On the `options` object ip and username are treated as an XOR. 105 | * 106 | * An example options object with overrides: 107 | * 108 | * { 109 | * burst: 10, // Max 10 concurrent requests (if tokens) 110 | * rate: 0.5, // Steady state: 1 request / 2 seconds 111 | * ip: true, // throttle per IP 112 | * overrides: { 113 | * '192.168.1.1': { 114 | * burst: 0, 115 | * rate: 0 // unlimited 116 | * } 117 | * } 118 | * 119 | * 120 | * @param {Object} options required options with: 121 | * - {Number} burst (required). 122 | * - {Number} rate (required). 123 | * - {Boolean} ip (optional). 124 | * - {Boolean} username (optional). 125 | * - {Boolean} xff (optional). 126 | * - {Object} overrides (optional). 127 | * - {Object} tokensTable: a storage engine this plugin 128 | * will use to store throttling keys -> bucket 129 | * mappings. If you don't specify this, the 130 | * default is to use an in-memory O(1) LRU, 131 | * with 10k distinct keys. Any implementation 132 | * just needs to support set/get. 133 | * - {Number} maxKeys: If using the default 134 | * implementation, you can specify how large 135 | * you want the table to be. Default is 10000. 136 | * @return {Function} of type f(req, res, next) to be plugged into a route. 137 | * @throws {TypeError} on bad input. 138 | */ 139 | return function (options) { 140 | // Apply options to instance 141 | _.extend(self, options); 142 | 143 | // Validate options 144 | var keySelection = xor(self.ip, self.xff, self.username); 145 | assert.ok(keySelection, '(ip ^ username ^ xff)'); 146 | assert.number(self.burst, 'options.burst'); 147 | assert.number(self.rate, 'options.rate'); 148 | 149 | // Return middleware 150 | return rateLimit; 151 | }; 152 | } 153 | 154 | module.exports = new Throttle(); -------------------------------------------------------------------------------- /lib/bucket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Token bucket implementation. 3 | * 4 | * @package restify-throttle 5 | * @author Mark Cavage 6 | * Andrew Sliwinski 7 | */ 8 | 9 | /** 10 | * Dependencies 11 | */ 12 | var assert = require('assert-plus'); 13 | 14 | /** 15 | * An implementation of the Token Bucket algorithm. 16 | * 17 | * Basically, in network throttling, there are two "mainstream" 18 | * algorithms for throttling requests, Token Bucket and Leaky Bucket. 19 | * For restify, I went with Token Bucket. For a good description of the 20 | * algorithm, see: http://en.wikipedia.org/wiki/Token_bucket 21 | * 22 | * In the options object, you pass in the total tokens and the fill rate. 23 | * Practically speaking, this means "allow `fill rate` requests/second, 24 | * with bursts up to `total tokens`". Note that the bucket is initialized 25 | * to full. 26 | * 27 | * Also, in googling, I came across a concise python implementation, so this 28 | * is just a port of that. Thanks http://code.activestate.com/recipes/511490 ! 29 | * 30 | * @param {Object} options contains the parameters: 31 | * - {Number} capacity the maximum burst. 32 | * - {Number} fillRate the rate to refill tokens. 33 | */ 34 | function Bucket (options) { 35 | assert.object(options, 'options'); 36 | assert.number(options.capacity, 'options.capacity'); 37 | assert.number(options.fillRate, 'options.fillRate'); 38 | 39 | this.tokens = this.capacity = options.capacity; 40 | this.fillRate = options.fillRate; 41 | this.time = Date.now(); 42 | } 43 | 44 | /** 45 | * Consume N tokens from the bucket. 46 | * 47 | * If there is not capacity, the tokens are not pulled from the bucket. 48 | * 49 | * @param {Number} tokens the number of tokens to pull out. 50 | * @return {Boolean} true if capacity, false otherwise. 51 | */ 52 | Bucket.prototype.consume = function (tokens) { 53 | if (tokens <= this.fill()) { 54 | this.tokens -= tokens; 55 | return true; 56 | } 57 | 58 | return false; 59 | }; 60 | 61 | /** 62 | * Fills the bucket with more tokens. 63 | * 64 | * Rather than do some whacky setTimeout() deal, we just approximate refilling 65 | * the bucket by tracking elapsed time from the last time we touched the bucket. 66 | * 67 | * Simply, we set the bucket size to 68 | * min(totalTokens, current + (fillRate * elapsed time)). 69 | * 70 | * @return {Number} the current number of tokens in the bucket. 71 | */ 72 | Bucket.prototype.fill = function () { 73 | var now = Date.now(); 74 | if (now < this.time) { 75 | // Reset - account for clock drift (like DST) 76 | this.time = now - 1000; 77 | } 78 | 79 | if (this.tokens < this.capacity) { 80 | var delta = this.fillRate * ((now - this.time) / 1000); 81 | this.tokens = Math.min(this.capacity, this.tokens + delta); 82 | } 83 | this.time = now; 84 | 85 | return this.tokens; 86 | }; 87 | 88 | /** 89 | * Export 90 | */ 91 | module.exports = Bucket; -------------------------------------------------------------------------------- /lib/table.js: -------------------------------------------------------------------------------- 1 | /** 2 | * LRU table prototype. 3 | * 4 | * @package restify-throttle 5 | * @author Andrew Sliwinski 6 | */ 7 | 8 | /** 9 | * Dependencies 10 | */ 11 | var assert = require('assert-plus'), 12 | LRU = require('lru-cache'); 13 | 14 | /** 15 | * Constructor 16 | */ 17 | function Table (options) { 18 | assert.object(options, 'options'); 19 | this.table = new LRU(options.size || 10000); 20 | } 21 | 22 | Table.prototype.get = function (key) { 23 | return this.table.get(key); 24 | }; 25 | 26 | Table.prototype.set = function (key, value) { 27 | return this.table.set(key, value); 28 | }; 29 | 30 | /** 31 | * Export 32 | */ 33 | module.exports = Table; -------------------------------------------------------------------------------- /lib/xor.js: -------------------------------------------------------------------------------- 1 | /** 2 | * XOR helper. 3 | * 4 | * @package restify-throttle 5 | * @author Mark Cavage 6 | * Andrew Sliwinski 7 | */ 8 | 9 | /** 10 | * Export 11 | */ 12 | module.exports = function () { 13 | var x = false; 14 | for (var i = 0; i < arguments.length; i++) { 15 | if (arguments[i] && !x) { 16 | x = true; 17 | } else if (arguments[i] && x) { 18 | return false; 19 | } 20 | } 21 | return x; 22 | }; -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | test: 2 | tap test/governance/*.js 3 | tap test/unit/*.js 4 | tap test/integration/*.js 5 | 6 | .PHONY: test -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Andrew Sliwinski (http://andrewsliwinski.com)", 3 | "name": "micron-throttle", 4 | "description": "Token bucket based HTTP request throttle for Node.js", 5 | "version": "0.1.0", 6 | "repository": { 7 | "url": "https://github.com/thisandagain/micron-throttle" 8 | }, 9 | "main": "./lib/index.js", 10 | "scripts": { 11 | "test": "make test" 12 | }, 13 | "dependencies": { 14 | "assert-plus": "~0.1.4", 15 | "lodash": "~2.0.0", 16 | "lru-cache": "~2.3.1" 17 | }, 18 | "devDependencies": { 19 | "async": "~0.2.9", 20 | "hint-hint": "~0.3.0", 21 | "tap": "~0.3.1" 22 | }, 23 | "optionalDependencies": {}, 24 | "engines": { 25 | "node": ">=0.8" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/client.js: -------------------------------------------------------------------------------- 1 | var async = require('async'), 2 | http = require('http'); 3 | 4 | module.exports = function (rate, callback) { 5 | var queue = async.queue(function (task, callback) { 6 | http.get('http://localhost:8881/' + task, function (res) { 7 | var response = (res.statusCode === 200) ? null : res.statusCode; 8 | callback(response); 9 | }).on('error', function (err) { 10 | callback(err); 11 | }); 12 | }, 2); 13 | 14 | for (var i = 0; i < rate; i++) { 15 | queue.push(i, function (err) { 16 | if (err) return callback(err); 17 | }); 18 | } 19 | 20 | queue.drain = function (err) { 21 | if (err) return callback(err); 22 | callback(null); 23 | }; 24 | }; -------------------------------------------------------------------------------- /test/fixtures/jshint.json: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": true, 3 | "devel": true, 4 | "eqeqeq": true, 5 | "immed": true, 6 | "latedef": true, 7 | "maxdepth": 2, 8 | "maxparams": 4, 9 | "newcap": true, 10 | "noarg": true, 11 | "node": true, 12 | "proto": true, 13 | "quotmark": "single", 14 | "undef": true, 15 | "unused": true, 16 | "maxlen": 80 17 | } -------------------------------------------------------------------------------- /test/governance/lint_index.js: -------------------------------------------------------------------------------- 1 | require('hint-hint')( 2 | __dirname + '/../../*.js', 3 | require('../fixtures/jshint.json') 4 | ); -------------------------------------------------------------------------------- /test/governance/lint_lib.js: -------------------------------------------------------------------------------- 1 | require('hint-hint')( 2 | __dirname + '/../../!(node_modules)**/*.js', 3 | require('../fixtures/jshint.json') 4 | ); -------------------------------------------------------------------------------- /test/integration/ip_error.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | throttle({ 8 | burst: 25, 9 | rate: 25, 10 | ip: true 11 | })(req, res, function (err) { 12 | var code = (!err) ? 200 : 429; 13 | res.writeHead(code, {'Content-Type': 'text/plain'}); 14 | res.end('\n'); 15 | }); 16 | }).listen(8881); 17 | 18 | client(100, function (err) { 19 | test('client', function (t) { 20 | t.equal(err, 429, 'error object of expected value'); 21 | t.end(); 22 | }); 23 | }); 24 | 25 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/integration/ip_ok.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | throttle({ 8 | burst: 100, 9 | rate: 100, 10 | ip: true 11 | })(req, res, function (err) { 12 | var code = (!err) ? 200 : 429; 13 | res.writeHead(code, {'Content-Type': 'text/plain'}); 14 | res.end('\n'); 15 | }); 16 | }).listen(8881); 17 | 18 | client(100, function (err) { 19 | test('client', function (t) { 20 | t.equal(err, null, 'error object of expected value'); 21 | t.end(); 22 | }); 23 | }); 24 | 25 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/integration/username_error.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | req.username = 'testuser'; 8 | 9 | throttle({ 10 | burst: 50, 11 | rate: 50, 12 | username: true 13 | })(req, res, function (err) { 14 | var code = (!err) ? 200 : 429; 15 | res.writeHead(code, {'Content-Type': 'text/plain'}); 16 | res.end('\n'); 17 | }); 18 | }).listen(8881); 19 | 20 | client(100, function (err) { 21 | test('client', function (t) { 22 | t.equal(err, 429, 'error object of expected value'); 23 | t.end(); 24 | }); 25 | }); 26 | 27 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/integration/username_ok.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | req.username = 'testuser'; 8 | 9 | throttle({ 10 | burst: 100, 11 | rate: 100, 12 | username: true 13 | })(req, res, function (err) { 14 | var code = (!err) ? 200 : 429; 15 | res.writeHead(code, {'Content-Type': 'text/plain'}); 16 | res.end('\n'); 17 | }); 18 | }).listen(8881); 19 | 20 | client(100, function (err) { 21 | test('client', function (t) { 22 | t.equal(err, null, 'error object of expected value'); 23 | t.end(); 24 | }); 25 | }); 26 | 27 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/integration/xff_error.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | req.headers['x-forwarded-for'] = '127.0.0.1,10.10.10.10'; 8 | 9 | throttle({ 10 | burst: 50, 11 | rate: 50, 12 | xff: true 13 | })(req, res, function (err) { 14 | var code = (!err) ? 200 : 429; 15 | res.writeHead(code, {'Content-Type': 'text/plain'}); 16 | res.end('\n'); 17 | }); 18 | }).listen(8881); 19 | 20 | client(100, function (err) { 21 | test('client', function (t) { 22 | t.equal(err, 429, 'error object of expected value'); 23 | t.end(); 24 | }); 25 | }); 26 | 27 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/integration/xff_ok.js: -------------------------------------------------------------------------------- 1 | var http = require('http'), 2 | test = require('tap').test, 3 | client = require('../fixtures/client.js'), 4 | throttle = require('../../index.js'); 5 | 6 | var server = http.createServer(function (req, res) { 7 | req.headers['x-forwarded-for'] = '127.0.0.1,10.10.10.10'; 8 | 9 | throttle({ 10 | burst: 100, 11 | rate: 100, 12 | xff: true 13 | })(req, res, function (err) { 14 | var code = (!err) ? 200 : 429; 15 | res.writeHead(code, {'Content-Type': 'text/plain'}); 16 | res.end('\n'); 17 | }); 18 | }).listen(8881); 19 | 20 | client(100, function (err) { 21 | test('client', function (t) { 22 | t.equal(err, null, 'error object of expected value'); 23 | t.end(); 24 | }); 25 | }); 26 | 27 | setTimeout(process.exit, 1200); -------------------------------------------------------------------------------- /test/unit/bucket.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test, 2 | Bucket = require('../../lib/bucket.js'); 3 | 4 | test('table', function (t) { 5 | var bucket = new Bucket({ 6 | capacity: 10, 7 | fillRate: 10 8 | }); 9 | 10 | t.ok(bucket.consume(2), 'is true'); 11 | t.ok(bucket.consume(8), 'is true'); 12 | t.notOk(bucket.consume(1), 'is false'); 13 | 14 | setTimeout(function () { 15 | t.ok(bucket.consume(1), 'is true'); 16 | t.ok(bucket.consume(1), 'is true'); 17 | t.ok(bucket.consume(1), 'is true'); 18 | t.ok(bucket.consume(1), 'is true'); 19 | t.notOk(bucket.consume(10), 'is false'); 20 | t.end(); 21 | }, 1000); 22 | }); -------------------------------------------------------------------------------- /test/unit/table.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test, 2 | Table = require('../../lib/table.js'); 3 | 4 | test('table', function (t) { 5 | var table = new Table({size:100}); 6 | 7 | var set = table.set('127.0.0.1', {hello: 'world'}); 8 | var get = table.get('127.0.0.1'); 9 | 10 | t.equal(set, true, 'returns expected value'); 11 | t.deepEqual(get, {hello: 'world'}, 'returns expected value'); 12 | t.end(); 13 | }); -------------------------------------------------------------------------------- /test/unit/xor.js: -------------------------------------------------------------------------------- 1 | var test = require('tap').test, 2 | xor = require('../../lib/xor.js'); 3 | 4 | var suite = [ 5 | xor(true, false, false), 6 | xor(false, false, false), 7 | ]; 8 | 9 | test('xor', function (t) { 10 | t.ok(suite[0], 'returns true'); 11 | t.notOk(suite[1], 'returns false'); 12 | t.end(); 13 | }); --------------------------------------------------------------------------------