├── Procfile ├── .gitignore ├── Makefile ├── package.json ├── test ├── persistence.coffee └── config.coffee ├── README ├── config.js ├── static ├── jquery.customslide.js ├── jquery.easydate-0.2.4.min.js ├── hookscope.js └── index.html ├── persistence.js ├── main.js └── clients.js /Procfile: -------------------------------------------------------------------------------- 1 | web: node main.js -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | run.sh -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | ./node_modules/.bin/mocha \ 3 | --reporter list \ 4 | -r should 5 | 6 | watch: 7 | ./node_modules/.bin/mocha \ 8 | --reporter list \ 9 | -r should \ 10 | -G -w 11 | 12 | .PHONY: test watch -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hookscope", 3 | "version": "0.2.0", 4 | "dependencies": { 5 | "express": "2.5.6", 6 | "redis": "0.7.1", 7 | "hiredis": "0.1.13", 8 | "socket.io": "0.8.7", 9 | "underscore": "1.3.0" 10 | } 11 | } -------------------------------------------------------------------------------- /test/persistence.coffee: -------------------------------------------------------------------------------- 1 | describe 'persistence module', -> 2 | it 'should import', -> 3 | persistence = require '../persistence' 4 | 5 | describe '', -> 6 | before -> 7 | persistence = require '../persistence' 8 | 9 | it 'should create a client', -> 10 | client = -------------------------------------------------------------------------------- /test/config.coffee: -------------------------------------------------------------------------------- 1 | describe 'config module', -> 2 | it 'should import', -> 3 | config = require '../config' 4 | 5 | it 'should have sensible defaults', -> 6 | config = (require '../config').config 7 | 8 | config.persist.should.be.false 9 | config.redisHost.should.eql("localhost") 10 | config.redisPort.should.eql("6379") 11 | 12 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Start main.js using node.js: 5 | $ node-dev main.js 6 | 7 | Open main.html in a browser. 8 | 9 | 10 | Test 11 | ==== 12 | 13 | Install should and mocha: 14 | $ npm install mocha coffee-script should 15 | $ make test 16 | 17 | 18 | Bugs/Todo 19 | ========= 20 | 21 | * Does not support multiple pages accessing the same channel. 22 | * Does not support multiple node instances. -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var url = require('url'); 3 | var redisConfig = url.parse(process.env.REDISTOGO_URL || ""); 4 | 5 | exports.config = { 6 | // If hostname is in the form of channel.domain.tld 7 | subDomainChannel: false, 8 | 9 | // Subdomains (or rather, channels) to pass to static serving 10 | staticSubDomains: ["www", "localhost"], 11 | 12 | // The domain used to host this app 13 | domain: "localhost:8000", 14 | 15 | // The subdirectory to listen for webhooks on. 16 | hookDir: "hook", 17 | 18 | // HTTP server details 19 | host: "127.0.0.1", 20 | port: process.env.PORT || 8000, 21 | 22 | // Persist requests y/n (if true, configure redis below) 23 | persist: !!(redisConfig.hostname && redisConfig.hostname.length) || false, 24 | 25 | // Redis connection 26 | redisHost: redisConfig.hostname || "localhost", 27 | redisPort: redisConfig.port || "6379", 28 | redisAuth: (redisConfig.auth || "").split(":")[1] || "auth string here", 29 | 30 | // Redis expiry of unused channels 31 | expire: 60*60*24*7, 32 | 33 | // Maximum requests per channel 34 | maxlen: 20, 35 | 36 | // Ignore favicon.ico requests? 37 | ignoreFavicon: true, 38 | 39 | // Custom socket.io options 40 | // This is useful to circumvent server restrictions 41 | // like on Heroku: 42 | socketOpts: { 43 | "transports": ["xhr-polling"], 44 | "polling duration": 10 }, 45 | 46 | // The directory from which to serve the static files 47 | webRoot: path.join(path.dirname(__filename), 'static') 48 | 49 | 50 | } -------------------------------------------------------------------------------- /static/jquery.customslide.js: -------------------------------------------------------------------------------- 1 | // Thanks to "jnn" for this code 2 | // http://stackoverflow.com/a/7266950/154399 3 | $.effects.customSlide = function(o) { 4 | 5 | return this.queue(function() { 6 | 7 | // Create element 8 | var el = $(this), 9 | props = ['position', 'top', 'bottom', 'left', 'right']; 10 | 11 | // Set options 12 | var mode = $.effects.setMode(el, o.options.mode || 'show'); // Set Mode 13 | var direction = o.options.direction || 'left'; // Default Direction 14 | // Adjust 15 | $.effects.save(el, props); 16 | el.show(); // Save & Show 17 | $.effects.createWrapper(el).css({ 18 | overflow: 'hidden' 19 | }); // Create Wrapper 20 | var ref = (direction == 'up' || direction == 'down') ? 'top' : 'left'; 21 | var motion = (direction == 'up' || direction == 'left') ? 'pos' : 'neg'; 22 | var distance = o.options.distance || (ref == 'top' ? el.outerHeight({ 23 | margin: true 24 | }) : el.outerWidth({ 25 | margin: true 26 | })); 27 | if (mode == 'show') el.parent().css('height', 0); 28 | 29 | if (mode == 'show') el.css(ref, motion == 'pos' ? (isNaN(distance) ? "-" + distance : -distance) : distance); // Shift 30 | // Animation 31 | var animation = {}; 32 | animation[ref] = (mode == 'show' ? (motion == 'pos' ? '+=' : '-=') : (motion == 'pos' ? '-=' : '+=')) + distance; 33 | 34 | el.parent().animate({ 35 | height: (mode == 'show' ? distance : 0) 36 | }, { 37 | queue: false, 38 | duration: o.duration, 39 | easing: o.options.easing 40 | }); 41 | el.animate(animation, { 42 | queue: false, 43 | duration: o.duration, 44 | easing: o.options.easing, 45 | complete: function() { 46 | if (mode == 'hide') el.hide(); // Hide 47 | $.effects.restore(el, props); 48 | $.effects.removeWrapper(el); // Restore 49 | if (o.callback) o.callback.apply(this, arguments); // Callback 50 | el.dequeue(); 51 | } 52 | }); 53 | 54 | }); 55 | 56 | }; -------------------------------------------------------------------------------- /persistence.js: -------------------------------------------------------------------------------- 1 | var config = require('./config').config; 2 | 3 | exports.createClient = function createClient(port, host, auth) { 4 | var redis = require('redis'); 5 | 6 | client = redis.createClient(port, host); 7 | if (auth) { client.auth(auth) }; 8 | 9 | return new Persistence(client); 10 | } 11 | 12 | /* class Persistence 13 | * 14 | * methods: 15 | * - pushRequest(channel, data, callback) 16 | * Adds a new request (data) to an already or not existing 17 | * channel. 18 | * - getRequests(channel, callback) 19 | * Returns the saved requests for this channel. 20 | * - channelExists(channel, callback) 21 | * Checks if the channel already exists 22 | * callback is called with one parameter (exists or not) 23 | * - touchChannel(channel, callback) 24 | * Update the expiry of channel to now+timeout 25 | */ 26 | 27 | function Persistence(client) { 28 | var self = this; 29 | this.client = client; 30 | 31 | self.channelExists = function channelExists(channel, callback) { 32 | client.exists("list:" + channel, function(err, data) { 33 | callback(data); 34 | }); 35 | }; 36 | 37 | self.pushRequest = function pushRequest(channel, data, callback) { 38 | self.channelExists(channel, function(exists) { 39 | var multi = self.client.multi(); 40 | 41 | if (!exists) { 42 | // update the list of channels here 43 | 44 | } 45 | 46 | multi.lpush("list:" + channel, JSON.stringify(data)); 47 | multi.ltrim("list:" + channel, 0, config.maxlen-1); 48 | multi.exec(callback); 49 | 50 | self.touch(channel); 51 | }); 52 | }; 53 | 54 | self.getRequests = function getRequests(channel, callback) { 55 | self.channelExists(channel, function(exists) { 56 | if (exists) { 57 | self.client.lrange("list:" + channel, 0, 100, function(error, result) { 58 | if (result) { 59 | parsedResult = []; 60 | 61 | for(i=0; i0)){return}for(var s in r.units){var w=r.units[s];if((w.past_only&&v<0)||(w.future_only&&v>0)){continue}if(u0)){continue}if(p=0;s--){var u=t.units[s];if(u.past_only){continue}return(u.limit-p)*1000+100}}}function i(q,r){var p=q.data("easydate.date");if(isNaN(p)){var s;var t=Date.parse(s=q.attr("title"))||Date.parse(s=q.html());if(!isNaN(t)){p=new Date();p.setTime(t);q.data("easydate.date",p);if(r.set_title&&!q.attr("title")){q.attr("title",s)}}}return p}function b(r){var s=r.data("easydate.settings");var p=e.data(r[0]);a[p]=r;delete k[p];var q=i(r,s);if(isNaN(q)){return}r.html(j(q,s));if(s.live){var t=m(q,s);if(!isNaN(t)){if(t>2147483647){t=2147483647}var u=setTimeout(function(){b(r)},Math.round(t));k[p]=u}}}e.fn.easydate=function(p){var q=e.extend({},d,p);this.data("easydate.settings",q);this.removeData("easydate.date");this.each(function(){var r=e.data(this);if(!isNaN(k[r])){clearTimeout(k[r]);delete k[r]}b(e(this))})}})(jQuery); -------------------------------------------------------------------------------- /static/hookscope.js: -------------------------------------------------------------------------------- 1 | function label(name) { 2 | return $("[data-label=" + name + "]"); 3 | } 4 | 5 | function setLabel(name, value) { 6 | label(name).html(value); 7 | label(name).filter("a").attr("href", value); 8 | } 9 | 10 | function createRequestView(request) { 11 | var el = $($("#request-template").data("compiled")(request)); 12 | var tbody = el.find(".headers-table"); 13 | 14 | el.find(".show-more-headers").click(function() { 15 | var minHeight = tbody.data("minSliderHeight"); 16 | var maxHeight = tbody.data("maxSliderHeight"); 17 | var curHeight = tbody.height(); 18 | 19 | if(curHeight == minHeight){ 20 | tbody.animate({ 21 | height: maxHeight 22 | }, 500); 23 | 24 | } else { 25 | tbody.animate({ 26 | height: minHeight 27 | }, "normal"); 28 | 29 | } 30 | 31 | el.find(".show-more-headers-label").toggle(); 32 | }); 33 | 34 | return el; 35 | } 36 | 37 | function calculateSliderHeights(el) { 38 | var minHeight = 0; 39 | var maxHeight = 0; 40 | 41 | el.find(".popular-header").each(function(index, tr) { 42 | minHeight += $(tr).height(); 43 | }); 44 | 45 | maxHeight = minHeight; 46 | 47 | el.find(".extra-header").each(function(index, tr) { 48 | maxHeight += $(tr).height(); 49 | }); 50 | 51 | // If first time, resize to minimum 52 | if (el.data("minSliderHeight")) { 53 | el.css("height", minHeight); 54 | } else { 55 | // Not first time so resize 56 | if (el.height() == el.data("maxSliderHeight")) { 57 | el.css("height", maxHeight); 58 | } else { 59 | el.css("height", minHeight); 60 | } 61 | } 62 | 63 | el.data("minSliderHeight", minHeight); 64 | el.data("maxSliderHeight", maxHeight); 65 | } 66 | 67 | $(window).resize(function() { 68 | $(".headers-table").each(function(index, el) { 69 | calculateSliderHeights($(el)); 70 | }); 71 | }); 72 | 73 | // Add dropdown functionality 74 | $(function() { 75 | $('#topbar').dropdown(); 76 | }); 77 | 78 | // Compile all the templates 79 | $(function() { 80 | $("[type='text/template']").each(function(index, element) { 81 | $(element).data("compiled", _.template(element.text)); 82 | }); 83 | }); 84 | 85 | 86 | $(function() { 87 | 88 | if(!window.location.hash) { 89 | window.location.hash = "#" + createRandomWord(6); 90 | } 91 | 92 | label("channelname").html(window.location.hash); 93 | 94 | 95 | var socket = io.connect(); 96 | 97 | socket.on('connect', function() { 98 | setLabel("status", "connected"); 99 | socket.emit("set channel", window.location.hash.slice(1)); 100 | }); 101 | 102 | socket.on('disconnect', function() { 103 | setLabel("status", "disconnected"); 104 | }); 105 | 106 | socket.on('history', function(data) { 107 | for (var i = 0; i < data.length; i++) { 108 | if (console) { console.log(data[i]); } 109 | 110 | var el = createRequestView(data[i]).appendTo("#requests"); 111 | calculateSliderHeights(el.find(".headers-table")); 112 | } 113 | 114 | $(".easydate").easydate(); 115 | }); 116 | 117 | socket.on('request', function(data) { 118 | console.log(data); 119 | var el = createRequestView(data).prependTo("#requests"); 120 | calculateSliderHeights(el.find(".headers-table")); 121 | 122 | el.show("customSlide", { direction: "up" }, 1000); 123 | $(".easydate").easydate(); 124 | }); 125 | 126 | socket.on('setUrls', function(urls) { 127 | formatted = []; 128 | _.each(urls, function(url) { 129 | if (url[0] == "/") { 130 | formatted.push("http://" + window.location.host + url); 131 | } else { 132 | formatted.push("http://" + url + "/"); 133 | } 134 | }); 135 | 136 | document.webhookUrl = formatted[0]; 137 | setLabel("url", formatted[0]); 138 | }); 139 | 140 | $("#clear").click(function(e) { 141 | e.preventDefault(); 142 | $("#requests").attr("id", "oldrequests").hide("customSlide", { direction: "up" }, 1000); 143 | $("
", { id: "requests" }).prependTo("#requests-list"); 144 | 145 | socket.emit("clear"); 146 | }); 147 | 148 | $("#sample").click(function(e) { 149 | e.preventDefault(); 150 | sampleRequest(document.webhookUrl); 151 | }) 152 | }); 153 | 154 | function sampleRequest(url) { 155 | $.ajax({ 156 | url: url, 157 | type: "POST", 158 | data: { 159 | "sample-request": true, 160 | "non-trivial-json": '{"datetime": "' + new Date() + '", "elements": ["car", "house", "casino"]}', 161 | "greeting": "I hope you're having fun playing with Hookscope!", 162 | } 163 | }); 164 | } 165 | 166 | // createRandomWord by James Padolsey 167 | // http://james.padolsey.com/javascript/random-word-generator/ 168 | function createRandomWord(length) { 169 | var consonants = 'bcdfghjklmnpqrstvwxyz', 170 | vowels = 'aeiou', 171 | rand = function(limit) { 172 | return Math.floor(Math.random()*limit); 173 | }, 174 | i, word='', length = parseInt(length,10), 175 | consonants = consonants.split(''), 176 | vowels = vowels.split(''); 177 | 178 | for (i=0;i 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 35 | 128 | 129 | 130 | 131 | 132 | 133 |
134 |
135 |
136 | Hookscope 137 | 155 | 158 |
159 |
160 |
161 | 162 | 163 | 164 | 165 |
166 |
167 |
168 |
169 | 170 |
171 |

Webhook Debugging

172 |

Working with webhooks is awesome, but sometimes it's hard to know what data is sent along. Use Hookscope to find out and get the details!

173 |

Set as your webhook endpoint, and any requests will appear above. 174 |

175 |

Sample Request »

176 |
177 | 178 |
179 |
180 |

Localhost

181 |

Hookscope is very easy to set up on your own PC. The only thing you need is Node.js. If you want the requestlog to persist when you shut down the page, you can use an optional Redis database, but Hookscope works fine without as well.

182 |

View details »

183 |
184 |
185 |

Heroku

186 |

Hookscope has sensible default settings out of the box and runs straight from Github on Heroku. You can just do a pull+push. If you add the Redis-To-Go Heroku Add-on, Hookscope will automatically detect and use it to save and receive requests without having to be opened in a browser.

187 |

View details »

188 |
189 |
190 |

Heading

191 |

Donec sed odio dui. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper. Fusce dapibus, tellus ac cursus commodo, tortor mauris condimentum nibh, ut fermentum massa justo sit amet risus.

192 |

View details »

193 |
194 |
195 |
196 |

© David Verhasselt 2011

197 | | 198 | connecting... 199 |
200 | 201 |
202 | 203 | 206 | --------------------------------------------------------------------------------