├── .gitignore ├── CHANGELOG.md ├── COPYING ├── README.md ├── examples ├── chat │ ├── index.html │ ├── package.json │ └── server.js ├── common │ └── client │ │ ├── jquery-1.11.0.min.js │ │ ├── json2.js │ │ ├── pollymer-1.1.1.js │ │ └── websockhop-1.0.1.js └── counter │ ├── .gitignore │ ├── index.html │ ├── package.json │ └── server.js ├── gulpfile.js ├── lib ├── Pollymer.js └── WebSockHop.js ├── package.json ├── protocol.md ├── src ├── Aspects │ ├── Changes │ │ ├── ChangesAspect.js │ │ └── Engine │ │ │ ├── ChangesEngineUnit.js │ │ │ ├── ChangesResource.js │ │ │ ├── ChangesWaitConnection.js │ │ │ └── ChangesWaitConnectionsMap.js │ └── Value │ │ ├── Engine │ │ ├── MultiplexWaitConnection.js │ │ ├── MultiplexWaitConnectionsMap.js │ │ ├── MultiplexWebSocketConnection.js │ │ ├── MultiplexWebSocketConnectionsMap.js │ │ ├── ValueEngineUnit.js │ │ ├── ValueResource.js │ │ ├── ValueWaitConnection.js │ │ └── ValueWaitConnectionsMap.js │ │ └── ValueAspect.js ├── Engine │ ├── Connection.js │ ├── ConnectionsMap.js │ ├── Engine.js │ ├── EngineResource.js │ └── EngineUnit.js ├── ResourceHandling │ ├── Aspect.js │ ├── Events.js │ ├── LiveResource.js │ ├── LiveResourceFactory.js │ ├── ResourceHandler.js │ └── ResourceHandlerFactory.js ├── main.js ├── no-console.js ├── utils.getWindowLocationHref.js ├── utils.getWindowLocationHrefBrowser.js ├── utils.js ├── utils.mapWebSocketUrls.js └── utils.parseLinkHeader.js └── tests └── test1.js /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | build/output 4 | dist 5 | .idea/ 6 | 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | LiveResource Changelog 2 | ====================== 3 | 4 | v. 0.1.0 (05-20-2014) - Initial Release. 5 | v. 0.1.1 (08-23-2015) - Rewritten using ES6, Browserify, and Gulp. -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 Fanout, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | LiveResource 2 | ============ 3 | Authors: Justin Karneges , Katsuyuki Ohmuro 4 | Mailing List: http://lists.fanout.io/listinfo.cgi/fanout-users-fanout.io 5 | 6 | LiveResource is a JavaScript library and protocol specification for receiving live updates of web resources. 7 | 8 | License 9 | ------- 10 | 11 | LiveResource is offered under the MIT license. See the COPYING file. 12 | 13 | Dependencies 14 | ------------ 15 | 16 | * json2.js 17 | * Pollymer 18 | * WebSockHop 19 | 20 | Usage 21 | ----- 22 | 23 | We all work with web resources. What if we had a nice way to know when they change? 24 | 25 | ```javascript 26 | var resource = new LiveResource('http://example.com/path/to/object'); 27 | resource.on('value', function (data) { 28 | // the resource's value has been initially retrieved or changed 29 | }); 30 | ``` 31 | 32 | The above code will make a GET request to the specified resource URI to retrieve its value and to discover if it supports live updates. The `value` callback will be triggered once the initial value has been received. If the resource supports live updates, then the LiveResource library will begin listening for updates and trigger the `value` callback again whenever the resource changes. If the resource does not support live updates, then `value` will be emitted only once. Think of this code like a fancy AJAX request, with automatic realtime updates capability. 33 | 34 | LiveResource uses WebSockets and HTTP long-polling to receive updates in realtime. It differs from other realtime solutions by providing an interface modeled around synchronization rather than messaging or sockets. 35 | 36 | Server 37 | ------ 38 | 39 | There is no official LiveResource server. Rather, any server application can be modified to speak the LiveResource protocol in order to be compatible with clients. 40 | 41 | If you're using Node.js and Express, you can use the `express-liveresource` package to easily realtimify your REST endpoints in just a few lines of code. Otherwise, you can look for other libraries or implement the protocol directly (see the Protocol section). 42 | 43 | For example, to enable live updates of an object resource, first make sure the resource supports ETags: 44 | 45 | ```javascript 46 | app.get('/path/to/object', function (req, res) { 47 | var value = ... object value ... 48 | var etag = '"' + ... object hash ... + '"'; 49 | res.set('ETag', etag); 50 | 51 | var inm = req.get('If-None-Match'); 52 | if (inm == etag) { 53 | res.status(304).end(); 54 | } else { 55 | res.status(200).json(value); 56 | } 57 | }); 58 | ``` 59 | 60 | Initialize the LiveResource subsystem as part of your server startup: 61 | 62 | ```javascript 63 | var ExpressLiveResource = require('express-liveresource').ExpressLiveResource; 64 | var liveResource = new ExpressLiveResource(app); 65 | ``` 66 | 67 | Then, whenever the object has been updated, call: 68 | 69 | ```javascript 70 | liveResource.updated('/path/to/object'); 71 | ``` 72 | 73 | Examples 74 | -------- 75 | 76 | To run the simple counter example: 77 | 78 | ```sh 79 | cd examples/counter 80 | npm i 81 | npm start 82 | ``` 83 | 84 | Then open a browser to http://localhost:3000/ 85 | 86 | Protocol 87 | -------- 88 | 89 | LiveResource is designed first and foremost as an open protocol, to enable the possibility of many compatible client and server implementations. For full protocol details, see the the `protocol.md` file. 90 | 91 | Resources indicate support for live updates via `Link` headers in their HTTP responses. The simplest live updates mechanism is HTTP long-polling, using a rel type of `value-wait`. 92 | 93 | For example, suppose a client fetches a resource: 94 | 95 | ``` 96 | GET /path/to/object HTTP/1.1 97 | ``` 98 | 99 | The server can indicate support for live updates by including a `Link` header in the response: 100 | 101 | ``` 102 | HTTP/1.1 200 OK 103 | ETag: "b1946ac9" 104 | Link: ; rel=value-wait 105 | Content-Type: application/json 106 | 107 | {"foo": "bar"} 108 | ``` 109 | 110 | The `value-wait` link means that the client can perform a long-polling request for the object's value. This is done by supplying a `Wait` header in the request, along with `If-None-Match` to check against the object's ETag: 111 | 112 | ``` 113 | GET /path/to/object HTTP/1.1 114 | If-None-Match: "b1946ac9" 115 | Wait: 60 116 | ``` 117 | 118 | If the data changes while the request is open, then the new data is returned immediately: 119 | 120 | ``` 121 | HTTP/1.1 200 OK 122 | ETag: "2492d234" 123 | Link: ; rel=value-wait 124 | Content-Type: application/json 125 | 126 | {"foo": "baz"} 127 | ``` 128 | 129 | If the data does not change for the duration of time specified in the `Wait` header, then a 304 is eventually returned: 130 | 131 | ``` 132 | HTTP/1.1 304 Not Modified 133 | ETag: "b1946ac9" 134 | Link: ; rel=value-wait 135 | Content-Length: 0 136 | ``` 137 | 138 | What's nice about LiveResource's long-polling mechanism is that it is simple and stateless. There are no sessions nor hacky stream-over-HTTP emulations. 139 | 140 | Beyond this basic overview, the LiveResource protocol also supports collection resources as well as WebSocket and Webhook live updates mechanisms. See the the `protocol.md` file for complete details. 141 | 142 | Credit 143 | ------ 144 | 145 | LiveResource was inspired by RealCrowd's API: http://code.realcrowd.com/restful-realtime/ 146 | -------------------------------------------------------------------------------- /examples/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 30 | 31 | 106 | 107 | 108 | 109 |

Chat

110 | 111 |
112 |
113 | Nickname: 114 | 115 |
116 |
117 | 118 |
119 |
120 |
121 | 122 | 123 |
124 |
125 | 126 | 127 | -------------------------------------------------------------------------------- /examples/chat/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat", 3 | "description": "liveresource test app", 4 | "version": "0.0.1", 5 | "private": true, 6 | "dependencies": { 7 | "express": "4.x", 8 | "express-liveresource": "0.1.x", 9 | "body-parser": "1.5.2" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /examples/chat/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var bodyParser = require('body-parser'); 3 | var ExpressLiveResource = require('express-liveresource').ExpressLiveResource; 4 | var path = require('path'); 5 | var url = require('url'); 6 | 7 | // setup server 8 | 9 | var app = express(); 10 | var server = app.listen(3000, function () { 11 | console.log('Listening on port %d', server.address().port); 12 | }); 13 | app.use(bodyParser.urlencoded({extended: false})); 14 | var liveResource = new ExpressLiveResource(app); 15 | liveResource.listenWebSocket(server); 16 | 17 | app.use(function(req, res, next) { 18 | u = url.parse(req.url) 19 | if(u.pathname.substr(-3) != '.js' && u.pathname.substr(-4) != '.map' && u.pathname.substr(-1) != '/') { 20 | u.pathname += '/'; 21 | res.redirect(301, url.format(u)); 22 | } else { 23 | next(); 24 | } 25 | }); 26 | 27 | // in-memory chat data 28 | 29 | var rooms = {}; 30 | 31 | var roomGetOrCreate = function (id) { 32 | var room = rooms[id]; 33 | if (room == null) { 34 | room = {id: id, messages: []}; 35 | rooms[id] = room; 36 | } 37 | return room; 38 | }; 39 | 40 | var roomAppendMessage = function (room, from, text) { 41 | room.messages.push({from: from, text: text}); 42 | }; 43 | 44 | var roomGetMessagesAfter = function (room, pos, limit) { 45 | var out = []; 46 | for (var n = pos + 1; n < room.messages.length && out.length < limit; ++n) { 47 | out.push(room.messages[n]); 48 | } 49 | return out; 50 | }; 51 | 52 | var roomGetMessagesBefore = function (room, pos, limit) { 53 | var out = []; 54 | for (var n = pos - 1; n >= 0 && out.length < limit; --n) { 55 | out.push(room.messages[n]); 56 | } 57 | return out; 58 | }; 59 | 60 | // front-end files 61 | 62 | app.get('/', express.static(__dirname)); 63 | app.get('/liveresource.js', function(req, res) { 64 | var filePath = path.resolve(__dirname + '/../../build/output/liveresource-latest.js'); 65 | res.sendFile(filePath); 66 | }); 67 | app.get('/liveresource-latest.js.map', function(req, res) { 68 | var filePath = path.resolve(__dirname + '/../../build/output/liveresource-latest.js.map'); 69 | res.sendFile(filePath); 70 | }); 71 | app.get(/^\/.*\.js$/, express.static(__dirname + '/../common/client')); 72 | 73 | // chat api 74 | 75 | var changesLink = function (room, pos) { 76 | return '/chat/' + room.id + '/message/?after=' + pos; 77 | }; 78 | 79 | var changesLinkHeader = function (room, pos) { 80 | return '<' + changesLink(room, pos) + '>; rel=changes'; 81 | }; 82 | 83 | app.head('/chat/:id/message/', function (req, res) { 84 | var room = roomGetOrCreate(req.params.id); 85 | res.set('Link', changesLinkHeader(room, room.messages.length)); 86 | res.send('') 87 | }); 88 | 89 | app.get('/chat/:id/message/', function (req, res) { 90 | var room = roomGetOrCreate(req.params.id); 91 | 92 | var limit = req.param('limit'); 93 | if (limit != null) { 94 | limit = parseInt(limit); 95 | } else { 96 | limit = 50; 97 | } 98 | 99 | var after = req.param('after'); 100 | if (after != null) { 101 | after = parseInt(after); 102 | } 103 | 104 | var messages = null; 105 | var changesPos = null; 106 | if (after != null) { 107 | if (after < 0 || after > room.messages.length) { 108 | res.status(404).end(); 109 | return; 110 | } 111 | messages = roomGetMessagesAfter(room, after - 1, limit); 112 | changesPos = after + messages.length; 113 | } else { 114 | messages = roomGetMessagesBefore(room, room.messages.length, limit); 115 | changesPos = room.messages.length; 116 | } 117 | 118 | res.set('Link', changesLinkHeader(room, changesPos)); 119 | res.status(200).json(messages); 120 | }); 121 | 122 | app.post('/chat/:id/message/', function (req, res) { 123 | var room = roomGetOrCreate(req.params.id); 124 | var prevChangesLink = changesLink(room, room.messages.length); 125 | 126 | roomAppendMessage(room, req.body.from, req.body.text); 127 | 128 | liveResource.updated(req.url, { 129 | prevChangesLink: prevChangesLink, 130 | query: {limit: '1'}, 131 | getItems: function (body) { return body; } 132 | }); 133 | 134 | res.send('Ok\n'); 135 | }); 136 | -------------------------------------------------------------------------------- /examples/common/client/json2.js: -------------------------------------------------------------------------------- 1 | /* 2 | http://www.JSON.org/json2.js 3 | 2011-10-19 4 | 5 | Public Domain. 6 | 7 | NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK. 8 | 9 | See http://www.JSON.org/js.html 10 | 11 | 12 | This code should be minified before deployment. 13 | See http://javascript.crockford.com/jsmin.html 14 | 15 | USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO 16 | NOT CONTROL. 17 | 18 | 19 | This file creates a global JSON object containing two methods: stringify 20 | and parse. 21 | 22 | JSON.stringify(value, replacer, space) 23 | value any JavaScript value, usually an object or array. 24 | 25 | replacer an optional parameter that determines how object 26 | values are stringified for objects. It can be a 27 | function or an array of strings. 28 | 29 | space an optional parameter that specifies the indentation 30 | of nested structures. If it is omitted, the text will 31 | be packed without extra whitespace. If it is a number, 32 | it will specify the number of spaces to indent at each 33 | level. If it is a string (such as '\t' or ' '), 34 | it contains the characters used to indent at each level. 35 | 36 | This method produces a JSON text from a JavaScript value. 37 | 38 | When an object value is found, if the object contains a toJSON 39 | method, its toJSON method will be called and the result will be 40 | stringified. A toJSON method does not serialize: it returns the 41 | value represented by the name/value pair that should be serialized, 42 | or undefined if nothing should be serialized. The toJSON method 43 | will be passed the key associated with the value, and this will be 44 | bound to the value 45 | 46 | For example, this would serialize Dates as ISO strings. 47 | 48 | Date.prototype.toJSON = function (key) { 49 | function f(n) { 50 | // Format integers to have at least two digits. 51 | return n < 10 ? '0' + n : n; 52 | } 53 | 54 | return this.getUTCFullYear() + '-' + 55 | f(this.getUTCMonth() + 1) + '-' + 56 | f(this.getUTCDate()) + 'T' + 57 | f(this.getUTCHours()) + ':' + 58 | f(this.getUTCMinutes()) + ':' + 59 | f(this.getUTCSeconds()) + 'Z'; 60 | }; 61 | 62 | You can provide an optional replacer method. It will be passed the 63 | key and value of each member, with this bound to the containing 64 | object. The value that is returned from your method will be 65 | serialized. If your method returns undefined, then the member will 66 | be excluded from the serialization. 67 | 68 | If the replacer parameter is an array of strings, then it will be 69 | used to select the members to be serialized. It filters the results 70 | such that only members with keys listed in the replacer array are 71 | stringified. 72 | 73 | Values that do not have JSON representations, such as undefined or 74 | functions, will not be serialized. Such values in objects will be 75 | dropped; in arrays they will be replaced with null. You can use 76 | a replacer function to replace those with JSON values. 77 | JSON.stringify(undefined) returns undefined. 78 | 79 | The optional space parameter produces a stringification of the 80 | value that is filled with line breaks and indentation to make it 81 | easier to read. 82 | 83 | If the space parameter is a non-empty string, then that string will 84 | be used for indentation. If the space parameter is a number, then 85 | the indentation will be that many spaces. 86 | 87 | Example: 88 | 89 | text = JSON.stringify(['e', {pluribus: 'unum'}]); 90 | // text is '["e",{"pluribus":"unum"}]' 91 | 92 | 93 | text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t'); 94 | // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]' 95 | 96 | text = JSON.stringify([new Date()], function (key, value) { 97 | return this[key] instanceof Date ? 98 | 'Date(' + this[key] + ')' : value; 99 | }); 100 | // text is '["Date(---current time---)"]' 101 | 102 | 103 | JSON.parse(text, reviver) 104 | This method parses a JSON text to produce an object or array. 105 | It can throw a SyntaxError exception. 106 | 107 | The optional reviver parameter is a function that can filter and 108 | transform the results. It receives each of the keys and values, 109 | and its return value is used instead of the original value. 110 | If it returns what it received, then the structure is not modified. 111 | If it returns undefined then the member is deleted. 112 | 113 | Example: 114 | 115 | // Parse the text. Values that look like ISO date strings will 116 | // be converted to Date objects. 117 | 118 | myData = JSON.parse(text, function (key, value) { 119 | var a; 120 | if (typeof value === 'string') { 121 | a = 122 | /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value); 123 | if (a) { 124 | return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4], 125 | +a[5], +a[6])); 126 | } 127 | } 128 | return value; 129 | }); 130 | 131 | myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) { 132 | var d; 133 | if (typeof value === 'string' && 134 | value.slice(0, 5) === 'Date(' && 135 | value.slice(-1) === ')') { 136 | d = new Date(value.slice(5, -1)); 137 | if (d) { 138 | return d; 139 | } 140 | } 141 | return value; 142 | }); 143 | 144 | 145 | This is a reference implementation. You are free to copy, modify, or 146 | redistribute. 147 | */ 148 | 149 | /*jslint evil: true, regexp: true */ 150 | 151 | /*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply, 152 | call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours, 153 | getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join, 154 | lastIndex, length, parse, prototype, push, replace, slice, stringify, 155 | test, toJSON, toString, valueOf 156 | */ 157 | 158 | 159 | // Create a JSON object only if one does not already exist. We create the 160 | // methods in a closure to avoid creating global variables. 161 | 162 | var JSON; 163 | if (!JSON) { 164 | JSON = {}; 165 | } 166 | 167 | (function () { 168 | 'use strict'; 169 | 170 | function f(n) { 171 | // Format integers to have at least two digits. 172 | return n < 10 ? '0' + n : n; 173 | } 174 | 175 | if (typeof Date.prototype.toJSON !== 'function') { 176 | 177 | Date.prototype.toJSON = function (key) { 178 | 179 | return isFinite(this.valueOf()) 180 | ? this.getUTCFullYear() + '-' + 181 | f(this.getUTCMonth() + 1) + '-' + 182 | f(this.getUTCDate()) + 'T' + 183 | f(this.getUTCHours()) + ':' + 184 | f(this.getUTCMinutes()) + ':' + 185 | f(this.getUTCSeconds()) + 'Z' 186 | : null; 187 | }; 188 | 189 | String.prototype.toJSON = 190 | Number.prototype.toJSON = 191 | Boolean.prototype.toJSON = function (key) { 192 | return this.valueOf(); 193 | }; 194 | } 195 | 196 | var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 197 | escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g, 198 | gap, 199 | indent, 200 | meta = { // table of character substitutions 201 | '\b': '\\b', 202 | '\t': '\\t', 203 | '\n': '\\n', 204 | '\f': '\\f', 205 | '\r': '\\r', 206 | '"' : '\\"', 207 | '\\': '\\\\' 208 | }, 209 | rep; 210 | 211 | 212 | function quote(string) { 213 | 214 | // If the string contains no control characters, no quote characters, and no 215 | // backslash characters, then we can safely slap some quotes around it. 216 | // Otherwise we must also replace the offending characters with safe escape 217 | // sequences. 218 | 219 | escapable.lastIndex = 0; 220 | return escapable.test(string) ? '"' + string.replace(escapable, function (a) { 221 | var c = meta[a]; 222 | return typeof c === 'string' 223 | ? c 224 | : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 225 | }) + '"' : '"' + string + '"'; 226 | } 227 | 228 | 229 | function str(key, holder) { 230 | 231 | // Produce a string from holder[key]. 232 | 233 | var i, // The loop counter. 234 | k, // The member key. 235 | v, // The member value. 236 | length, 237 | mind = gap, 238 | partial, 239 | value = holder[key]; 240 | 241 | // If the value has a toJSON method, call it to obtain a replacement value. 242 | 243 | if (value && typeof value === 'object' && 244 | typeof value.toJSON === 'function') { 245 | value = value.toJSON(key); 246 | } 247 | 248 | // If we were called with a replacer function, then call the replacer to 249 | // obtain a replacement value. 250 | 251 | if (typeof rep === 'function') { 252 | value = rep.call(holder, key, value); 253 | } 254 | 255 | // What happens next depends on the value's type. 256 | 257 | switch (typeof value) { 258 | case 'string': 259 | return quote(value); 260 | 261 | case 'number': 262 | 263 | // JSON numbers must be finite. Encode non-finite numbers as null. 264 | 265 | return isFinite(value) ? String(value) : 'null'; 266 | 267 | case 'boolean': 268 | case 'null': 269 | 270 | // If the value is a boolean or null, convert it to a string. Note: 271 | // typeof null does not produce 'null'. The case is included here in 272 | // the remote chance that this gets fixed someday. 273 | 274 | return String(value); 275 | 276 | // If the type is 'object', we might be dealing with an object or an array or 277 | // null. 278 | 279 | case 'object': 280 | 281 | // Due to a specification blunder in ECMAScript, typeof null is 'object', 282 | // so watch out for that case. 283 | 284 | if (!value) { 285 | return 'null'; 286 | } 287 | 288 | // Make an array to hold the partial results of stringifying this object value. 289 | 290 | gap += indent; 291 | partial = []; 292 | 293 | // Is the value an array? 294 | 295 | if (Object.prototype.toString.apply(value) === '[object Array]') { 296 | 297 | // The value is an array. Stringify every element. Use null as a placeholder 298 | // for non-JSON values. 299 | 300 | length = value.length; 301 | for (i = 0; i < length; i += 1) { 302 | partial[i] = str(i, value) || 'null'; 303 | } 304 | 305 | // Join all of the elements together, separated with commas, and wrap them in 306 | // brackets. 307 | 308 | v = partial.length === 0 309 | ? '[]' 310 | : gap 311 | ? '[\n' + gap + partial.join(',\n' + gap) + '\n' + mind + ']' 312 | : '[' + partial.join(',') + ']'; 313 | gap = mind; 314 | return v; 315 | } 316 | 317 | // If the replacer is an array, use it to select the members to be stringified. 318 | 319 | if (rep && typeof rep === 'object') { 320 | length = rep.length; 321 | for (i = 0; i < length; i += 1) { 322 | if (typeof rep[i] === 'string') { 323 | k = rep[i]; 324 | v = str(k, value); 325 | if (v) { 326 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 327 | } 328 | } 329 | } 330 | } else { 331 | 332 | // Otherwise, iterate through all of the keys in the object. 333 | 334 | for (k in value) { 335 | if (Object.prototype.hasOwnProperty.call(value, k)) { 336 | v = str(k, value); 337 | if (v) { 338 | partial.push(quote(k) + (gap ? ': ' : ':') + v); 339 | } 340 | } 341 | } 342 | } 343 | 344 | // Join all of the member texts together, separated with commas, 345 | // and wrap them in braces. 346 | 347 | v = partial.length === 0 348 | ? '{}' 349 | : gap 350 | ? '{\n' + gap + partial.join(',\n' + gap) + '\n' + mind + '}' 351 | : '{' + partial.join(',') + '}'; 352 | gap = mind; 353 | return v; 354 | } 355 | } 356 | 357 | // If the JSON object does not yet have a stringify method, give it one. 358 | 359 | if (typeof JSON.stringify !== 'function') { 360 | JSON.stringify = function (value, replacer, space) { 361 | 362 | // The stringify method takes a value and an optional replacer, and an optional 363 | // space parameter, and returns a JSON text. The replacer can be a function 364 | // that can replace values, or an array of strings that will select the keys. 365 | // A default replacer method can be provided. Use of the space parameter can 366 | // produce text that is more easily readable. 367 | 368 | var i; 369 | gap = ''; 370 | indent = ''; 371 | 372 | // If the space parameter is a number, make an indent string containing that 373 | // many spaces. 374 | 375 | if (typeof space === 'number') { 376 | for (i = 0; i < space; i += 1) { 377 | indent += ' '; 378 | } 379 | 380 | // If the space parameter is a string, it will be used as the indent string. 381 | 382 | } else if (typeof space === 'string') { 383 | indent = space; 384 | } 385 | 386 | // If there is a replacer, it must be a function or an array. 387 | // Otherwise, throw an error. 388 | 389 | rep = replacer; 390 | if (replacer && typeof replacer !== 'function' && 391 | (typeof replacer !== 'object' || 392 | typeof replacer.length !== 'number')) { 393 | throw new Error('JSON.stringify'); 394 | } 395 | 396 | // Make a fake root object containing our value under the key of ''. 397 | // Return the result of stringifying the value. 398 | 399 | return str('', {'': value}); 400 | }; 401 | } 402 | 403 | 404 | // If the JSON object does not yet have a parse method, give it one. 405 | 406 | if (typeof JSON.parse !== 'function') { 407 | JSON.parse = function (text, reviver) { 408 | 409 | // The parse method takes a text and an optional reviver function, and returns 410 | // a JavaScript value if the text is a valid JSON text. 411 | 412 | var j; 413 | 414 | function walk(holder, key) { 415 | 416 | // The walk method is used to recursively walk the resulting structure so 417 | // that modifications can be made. 418 | 419 | var k, v, value = holder[key]; 420 | if (value && typeof value === 'object') { 421 | for (k in value) { 422 | if (Object.prototype.hasOwnProperty.call(value, k)) { 423 | v = walk(value, k); 424 | if (v !== undefined) { 425 | value[k] = v; 426 | } else { 427 | delete value[k]; 428 | } 429 | } 430 | } 431 | } 432 | return reviver.call(holder, key, value); 433 | } 434 | 435 | 436 | // Parsing happens in four stages. In the first stage, we replace certain 437 | // Unicode characters with escape sequences. JavaScript handles many characters 438 | // incorrectly, either silently deleting them, or treating them as line endings. 439 | 440 | text = String(text); 441 | cx.lastIndex = 0; 442 | if (cx.test(text)) { 443 | text = text.replace(cx, function (a) { 444 | return '\\u' + 445 | ('0000' + a.charCodeAt(0).toString(16)).slice(-4); 446 | }); 447 | } 448 | 449 | // In the second stage, we run the text against regular expressions that look 450 | // for non-JSON patterns. We are especially concerned with '()' and 'new' 451 | // because they can cause invocation, and '=' because it can cause mutation. 452 | // But just to be safe, we want to reject all unexpected forms. 453 | 454 | // We split the second stage into 4 regexp operations in order to work around 455 | // crippling inefficiencies in IE's and Safari's regexp engines. First we 456 | // replace the JSON backslash pairs with '@' (a non-JSON character). Second, we 457 | // replace all simple value tokens with ']' characters. Third, we delete all 458 | // open brackets that follow a colon or comma or that begin the text. Finally, 459 | // we look to see that the remaining characters are only whitespace or ']' or 460 | // ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval. 461 | 462 | if (/^[\],:{}\s]*$/ 463 | .test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@') 464 | .replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']') 465 | .replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { 466 | 467 | // In the third stage we use the eval function to compile the text into a 468 | // JavaScript structure. The '{' operator is subject to a syntactic ambiguity 469 | // in JavaScript: it can begin a block or an object literal. We wrap the text 470 | // in parens to eliminate the ambiguity. 471 | 472 | j = eval('(' + text + ')'); 473 | 474 | // In the optional fourth stage, we recursively walk the new structure, passing 475 | // each name/value pair to a reviver function for possible transformation. 476 | 477 | return typeof reviver === 'function' 478 | ? walk({'': j}, '') 479 | : j; 480 | } 481 | 482 | // If the text is not JSON parseable, then a SyntaxError is thrown. 483 | 484 | throw new SyntaxError('JSON.parse'); 485 | }; 486 | } 487 | }()); 488 | -------------------------------------------------------------------------------- /examples/common/client/pollymer-1.1.1.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Pollymer JavaScript Library v1.1.2 3 | * Copyright 2013-2014 Fanout, Inc. 4 | * Released under the MIT license (see COPYING file in source distribution) 5 | */ 6 | (function(factory) { 7 | "use strict"; 8 | var DEBUG = true; 9 | var isWindow = function(variable) { 10 | return variable && variable.document && variable.location && variable.alert && variable.setInterval; 11 | }; 12 | if (!isWindow(window)) { 13 | throw "The current version of Pollymer may only be used within the context of a browser."; 14 | } 15 | var debugMode = DEBUG && typeof(window.console) !== "undefined"; 16 | if (typeof define === 'function' && define['amd']) { 17 | // AMD anonymous module 18 | define(['exports'], function(exports) { factory(exports, window, debugMode); }); 19 | } else { 20 | // No module loader (plain 4 | 5 | 6 | 7 | 8 | 9 | 45 | 46 | 47 | 48 |

Counter:

49 |

50 | 51 |

52 | 53 |

Counter:

54 |

55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /examples/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter", 3 | "description": "liveresource test app", 4 | "version": "0.0.1", 5 | "private": true, 6 | "dependencies": { 7 | "express": "4.x", 8 | "express-liveresource": "0.1.x" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /examples/counter/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var ExpressLiveResource = require('express-liveresource').ExpressLiveResource; 3 | var path = require('path'); 4 | var url = require('url'); 5 | 6 | // setup server 7 | 8 | var app = express(); 9 | var server = app.listen(3000, function () { 10 | console.log('Listening on port %d', server.address().port); 11 | }); 12 | var liveResource = new ExpressLiveResource(app); 13 | liveResource.listenWebSocket(server); 14 | 15 | app.use(function(req, res, next) { 16 | u = url.parse(req.url) 17 | if(u.pathname.substr(-3) != '.js' && u.pathname.substr(-4) != '.map' && u.pathname.substr(-1) != '/') { 18 | u.pathname += '/'; 19 | res.redirect(301, url.format(u)); 20 | } else { 21 | next(); 22 | } 23 | }); 24 | 25 | // in-memory counter data 26 | 27 | var counters = {}; 28 | 29 | var getCounter = function (id) { 30 | var value = counters[id]; 31 | if (value == null) { 32 | value = 0; 33 | } 34 | return value; 35 | }; 36 | 37 | var incCounter = function (id) { 38 | var value = counters[id]; 39 | if (value == null) { 40 | value = 0; 41 | } 42 | ++value; 43 | counters[id] = value; 44 | return value; 45 | }; 46 | 47 | // front-end files 48 | 49 | app.get('/', express.static(__dirname)); 50 | app.get('/liveresource.js', function(req, res) { 51 | var filePath = path.resolve(__dirname + '/../../build/output/liveresource-latest.js'); 52 | res.sendFile(filePath); 53 | }); 54 | app.get('/liveresource-latest.js.map', function(req, res) { 55 | var filePath = path.resolve(__dirname + '/../../build/output/liveresource-latest.js.map'); 56 | res.sendFile(filePath); 57 | }); 58 | app.get(/^\/.*\.js$/, express.static(__dirname + '/../common/client')); 59 | 60 | // counter api 61 | 62 | app.head('/counter/:id/', function (req, res) { 63 | var value = getCounter(req.params.id); 64 | var etag = '"' + value + '"'; 65 | res.set('ETag', etag); 66 | res.send('') 67 | }); 68 | 69 | app.get('/counter/:id/', function (req, res) { 70 | var value = getCounter(req.params.id); 71 | var etag = '"' + value + '"'; 72 | res.set('ETag', etag); 73 | 74 | var inm = req.get('If-None-Match'); 75 | if (inm == etag) { 76 | res.status(304).end(); 77 | } else { 78 | res.status(200).json(value); 79 | } 80 | }); 81 | 82 | app.post('/counter/:id/', function (req, res) { 83 | incCounter(req.params.id); 84 | liveResource.updated(req.url); 85 | res.send('Ok\n'); 86 | }); 87 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'); 4 | var gutil = require('gulp-util'); 5 | 6 | var doBuild = function(options) { 7 | var browserify = require('browserify'); 8 | var source = require('vinyl-source-stream'); 9 | var buffer = require('vinyl-buffer'); 10 | var uglify = require('gulp-uglify'); 11 | var sourcemaps = require('gulp-sourcemaps'); 12 | var aliasify = require('aliasify'); 13 | var babelify = require('babelify'); 14 | var header = require('gulp-header'); 15 | 16 | var debug = options.debug; 17 | var entryPoint = options.entryPoint; 18 | var expose = options.expose; 19 | var fileNameBase = options.fileNameBase; 20 | 21 | // Build banner 22 | var pkg = require('./package.json'); 23 | var banner = ['/*!', 24 | ' * <%= pkg.description %> v<%= pkg.version %>', 25 | ' * (c) <%= pkg.author %> - <%= pkg.homepage %>', 26 | ' * License: <%= pkg.licenses[0].type %> (<%= pkg.licenses[0].url %>)', 27 | ' */', 28 | ''].join('\n'); 29 | 30 | var headerTask = header(banner, { pkg: pkg }); 31 | 32 | // output file name 33 | var outputFileName = fileNameBase + '-latest' + (debug ? '' : '.min') + '.js'; 34 | 35 | // set up the browserify instance on a task basis 36 | var b = browserify({ debug: debug, standalone: expose, paths: [ './node_modules', './src', './lib' ] }); 37 | b.transform(babelify); 38 | b.require(require.resolve(entryPoint), { entry: true }) 39 | 40 | if (!debug) { 41 | var aliasifyOptions = { aliases: { 42 | 'console': './src/no-console' 43 | }}; 44 | b = b.transform(aliasify, aliasifyOptions); 45 | } 46 | 47 | var pipe = b.bundle() 48 | .on('error', gutil.log.bind(gutil, 'Browserify Error')) 49 | .pipe(source(outputFileName)) 50 | .pipe(buffer()); 51 | 52 | if (debug) { 53 | pipe = pipe.pipe(sourcemaps.init({loadMaps: true})); 54 | } 55 | 56 | if (!debug) { 57 | pipe = pipe.pipe(uglify()); 58 | } 59 | 60 | return pipe 61 | .pipe(sourcemaps.write('./')) 62 | .pipe(headerTask) 63 | .pipe(gulp.dest('./build/output/')); 64 | }; 65 | 66 | gulp.task('default', [ 'build-debug', 'build-min' ]); 67 | 68 | gulp.task('build-debug', function () { 69 | return doBuild({entryPoint: './src/main.js', expose: 'LiveResource', fileNameBase: 'liveresource', debug: true}); 70 | }); 71 | 72 | gulp.task('build-min', function () { 73 | return doBuild({entryPoint: './src/main.js', expose: 'LiveResource', fileNameBase: 'liveresource', debug: false}); 74 | }); 75 | 76 | -------------------------------------------------------------------------------- /lib/Pollymer.js: -------------------------------------------------------------------------------- 1 | module.exports = Pollymer; -------------------------------------------------------------------------------- /lib/WebSockHop.js: -------------------------------------------------------------------------------- 1 | module.exports = WebSockHop; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liveresource", 3 | "description": "LiveResource library", 4 | "homepage": "https://github.com/fanout/liveresource", 5 | "version": "0.1.1", 6 | "license": "MIT", 7 | "author": "fanout", 8 | "main": "./src/main.js", 9 | "scripts": { 10 | "build-debug": "gulp build-debug", 11 | "build-min": "gulp build-min", 12 | "test": "node tests/*.js" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/fanout/liveresource.git" 17 | }, 18 | "bugs": "https://github.com/fanout/liveresource/issues", 19 | "licenses": [ 20 | { 21 | "type": "MIT", 22 | "url": "http://www.opensource.org/licenses/mit-license.php" 23 | } 24 | ], 25 | "browser": { 26 | "./src/utils.getWindowLocationHref.js": "./src/utils.getWindowLocationHrefBrowser.js" 27 | }, 28 | "devDependencies": { 29 | "aliasify": "^1.7.2", 30 | "babelify": "^6.1.3", 31 | "browserify": "^11.0.0", 32 | "gulp": "^3.9.0", 33 | "gulp-header": "^1.5.0", 34 | "gulp-sourcemaps": "^1.5.2", 35 | "gulp-uglify": "^1.2.0", 36 | "gulp-util": "^3.0.6", 37 | "tape": "^4.0.1", 38 | "uglify-js": "^2.4.24", 39 | "vinyl-buffer": "^1.0.0", 40 | "vinyl-source-stream": "^1.1.0", 41 | "watchify": "^3.3.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /protocol.md: -------------------------------------------------------------------------------- 1 | LiveResource Protocol 2 | ===================== 3 | 4 | Many web services are built using RESTful design patterns surrounding objects and collections, and front-end developers are comfortable working with these kinds of services. What if we just had a way to notify when objects/collections were updated? 5 | 6 | This document describes a realtime updates protocol based around web resources. Resources indicate support for realtime updates using Link headers in HTTP responses. For example: 7 | 8 | GET /resource HTTP/1.1 9 | ... 10 | 11 | HTTP/1.1 200 OK 12 | Link: ; rel="value-wait value-stream" 13 | Link: ; rel=value-callback 14 | ... 15 | 16 | This information can also be obtained with the HEAD method, if you want to see what a resource supports without receiving its content. 17 | 18 | There are two notification types ("value" and "changes") and three notification mechanisms ("wait", "stream", and "callback"). As a result, the following link relations are defined based on the various possible combinations: 19 | 20 | Link rel | Meaning 21 | -------------------- | ---------------------------------------------------------------------------------------------------- 22 | value-wait | Long-polling updates of a resource's entire value 23 | value-stream | Server-sent events (SSE) updates of a resource's entire value 24 | value-callback | Base URI for subscription management of HTTP callback (webhook) updates of a resource's entire value 25 | changes | Immediate updates of a collection's children 26 | changes-wait | Long-polling updates of a collection's children 27 | changes-stream | Server-sent events (SSE) updates of a collection's children 28 | changes-callback | Base URI for subscription management of HTTP callback (webhook) updates of a collection's children 29 | 30 | The "changes" link type is an outlier, used to poll a collection resource for changes immediately without any deferred response behavior. A similar link type is not needed for "value" polling since this is assumed to be possible with a plain GET on the resource. 31 | 32 | Additionally, there are link types for multiplexing: 33 | 34 | Link rel | Meaning 35 | -------------------- | ---------------------------------------------------------------------------------------------------- 36 | multiplex-wait | Base URI for accessing multiple resources in a single request, with support for long-polling 37 | multiplex-stream | Base URI for accessing multiple resources via a single server-sent events (SSE) stream 38 | multiplex-ws | Base URI for accessing multiple resources via a single WebSocket connection 39 | 40 | Below, we'll go over how the long-polling mechanisms can be used with common web objects and collections. 41 | 42 | Objects 43 | ------- 44 | 45 | Object resources may announce support for updates via long-polling by including an ETag and appropriate Link header: 46 | 47 | GET /object HTTP/1.1 48 | ... 49 | 50 | HTTP/1.1 200 OK 51 | ETag: "b1946ac9" 52 | Link: ; rel=value-wait 53 | ... 54 | 55 | To make use of the value-wait link, the client issues a GET to the linked URI with the If-None-Match header set to the value of the received ETag. The client also provides the Wait header with a timeout value in seconds. 56 | 57 | GET /object HTTP/1.1 58 | If-None-Match: "b1946ac9" 59 | Wait: 60 60 | ... 61 | 62 | If the data changes while the request is open, then the new data is returned: 63 | 64 | HTTP/1.1 200 OK 65 | ETag: "2492d234" 66 | Link: ; rel=value-wait 67 | ... 68 | 69 | If the data does not change, then a 304 is returned: 70 | 71 | HTTP/1.1 304 Not Modified 72 | ETag: "b1946ac9" 73 | Link: ; rel=value-wait 74 | Content-Length: 0 75 | 76 | If the object is deleted while the request is open, then a 404 is returned: 77 | 78 | HTTP/1.1 404 Not Found 79 | ... 80 | 81 | Collections 82 | ----------- 83 | 84 | Like objects, collection resources announce support via Link headers: 85 | 86 | GET /collection/ HTTP/1.1 87 | ... 88 | 89 | HTTP/1.1 200 OK 90 | Link: ; rel="changes changes-wait" 91 | ... 92 | 93 | Unlike objects which use ETags, collections encode a checkpoint in the provided "changes" and/or "changes-wait" URIs. The client should request against these URIs to receive updates to the collection. The changes-wait URI is used for long-polling. 94 | 95 | Collection resources always return items in the collection. The changes URIs should return all items that were modified after some recent checkpoint (such as the current time). If there are items, they should be returned with code 200. If there are no such items, an empty list should be returned. 96 | 97 | The changes URIs MAY have a limited validity period. If the server considers a URI too old to process, it can return 404, which should signal the client to start over and obtain fresh changes URIs. 98 | 99 | For realtime updates, the client provides a Wait header to make the request act as a long poll: 100 | 101 | GET /collection/?after=1395174448&max=50 HTTP/1.1 102 | Wait: 60 103 | ... 104 | 105 | Any request can be made against the collection to receive a changes URIs. For example, a news feed may want to obtain the most recent N news items and be notified of updates going forward. To accomplish this, the client could make a request to a collection resource of news items, perhaps with certain query parameters indicating an "order by created_time desc limit N" effect. The client would then receive these items and use them for initial display. The response to this request would also contain changes URIs though, which the client could then begin long-polling against to receive any changes. 106 | 107 | This spec does not dictate the format of a collections response, but it does make the following requirements: 1) Elements in a collection MUST have an id value somewhere, that if concatenated with the collection resource would produce a direct link to the object it represents, and 2) Elements in a collection SHOULD have a deleted flag, to support checking for deletions. Clients must be able to understand the format of the collections they interact with, and be able to determine the id or deleted state of any such items. 108 | 109 | Webhooks 110 | -------- 111 | 112 | A resource can indicate support for update notifications via callback by including a "callback" Link type: 113 | 114 | GET /object HTTP/1.1 115 | ... 116 | 117 | HTTP/1.1 200 OK 118 | ETag: "b1946ac9" 119 | Link: ; rel=value-callback 120 | ... 121 | 122 | The link points to a collection resource that manages callback URI registrations. The behavior of this endpoint follows the outline at http://resthooks.org/ 123 | 124 | To subscribe http://example.org/receiver/ to a resource: 125 | 126 | POST /object/subscription/ HTTP/1.1 127 | Content-Type: application/x-www-form-urlencoded 128 | 129 | callback_uri=http:%2F%2Fexample.org%2Freceiver%2F 130 | 131 | Server responds: 132 | 133 | HTTP/1.1 201 Created 134 | Location: http://example.com/object/subscription/http:%2F%2Fexample.org%2Freceiver%2F 135 | Content-Length: 0 136 | 137 | The subscription's id will be the encoded URI that was subscribed. To unsubscribe, delete the subscription's resource URI: 138 | 139 | DELETE /object/subscription/http:%2F%2Fexample.org%2Freceiver%2F HTTP/1.1 140 | 141 | Update notifications are delivered via HTTP POST to each subscriber URI. The Location header is set to the value of the resource that was subscribed to. For example: 142 | 143 | POST /receiver/ HTTP/1.1 144 | Location: http://example.com/object 145 | ... 146 | 147 | In the case of object resources, the body of the POST request contains the entire object value. If the object was deleted, then an empty body is sent. 148 | 149 | For collection resources, the POST request body contains the response that would normally have been sent to a request for the changes URI. The request should also contain two Link headers, with rel=changes and rel=prev-changes. The recipient can compare the currently known changes link with the prev-changes link to ensure a callback was not missed. If it was, the client can resync by performing a GET against the currently known changes link. 150 | 151 | Server-Sent Events 152 | ------------------ 153 | 154 | A resource can indicate support for notifications via Server-Sent Events by including a "stream" Link type: 155 | 156 | GET /object HTTP/1.1 157 | ... 158 | 159 | HTTP/1.1 200 OK 160 | ETag: "b1946ac9" 161 | Link: ; rel=value-stream 162 | ... 163 | 164 | The link points to a Server-Sent Events capable endpoint that streams updates related to the resource. The client can then access the stream: 165 | 166 | GET /object/stream/ HTTP/1.1 167 | ... 168 | 169 | HTTP/1.1 200 OK 170 | Content-Type: text/event-stream 171 | ... 172 | 173 | Event ids should be used to allow recovery after disconnect. For example, an object resource might use ETags for event ids. Events are not named. For value-stream URIs, each event is a JSON value of the object itself, or an empty string if the object was deleted. For changes-stream URIs, each event is a JSON object of the same format that is normally returned when retrieving elements from the collection (e.g. a JSON list). 174 | 175 | Multiplexing 176 | ------------ 177 | 178 | In order to reduce the number of needed TCP connections in client applications, servers may support multiplexing many long-polling requests or Server-Sent Events connections together. This is indicated by providing Links of type "multiplex-wait" and/or "multiplex-stream". For example, suppose there are two object URIs of interest: 179 | 180 | GET /objectA HTTP/1.1 181 | ... 182 | 183 | HTTP/1.1 200 OK 184 | ETag: "b1946ac9" 185 | Link: ; rel=value-wait 186 | Link: ; rel=multiplex-wait 187 | ... 188 | 189 | GET /objectB HTTP/1.1 190 | ... 191 | 192 | HTTP/1.1 200 OK 193 | ETag: "d3b07384" 194 | Link: ; rel=value-wait 195 | Link: ; rel=multiplex-wait 196 | ... 197 | 198 | The client can detect that both of these objects are accessible via the same multiplex endpoint "/multi/". Both resources can then be checked for updates in a single long-polling request. This is done by passing each resource URI as a query parameter named "u". Each "u" param should be immediately followed by an "inm" param (meaning If-None-Match) to specify the ETag to check against for the preceding URI. Collection resources do not use a inm param, since the checkpoint is encoded in the URI itself. 199 | 200 | GET /multi/?u=%2FobjectA&inm="b1946ac9"&u=%2FobjectB&inm="d3b07384" HTTP/1.1 201 | Wait: 60 202 | ... 203 | 204 | The multiplex response uses a special format of a JSON object, where each child member is named for a URI that has response data. For example: 205 | 206 | HTTP/1.1 200 OK 207 | Content-Type: application/liveresource-multiplex 208 | 209 | { 210 | "/objectA": { 211 | "code": 200, 212 | "headers": { 213 | "ETag": "\"2492d234\"" 214 | }, 215 | "body": { "foo": "bar" } 216 | } 217 | } 218 | 219 | If the request is a long-polling request and only one URI has response data, then response data for the others should not be included. 220 | 221 | Multiplexing SSE is also possible: 222 | 223 | GET /multi/?u=%2FobjectA&u=%2FobjectB HTTP/1.1 224 | Accept: text/event-stream 225 | ... 226 | 227 | In this case, each message is encapsulated in a JSON object, with "uri" and "body" fields. The "uri" field contains the URI that the message is for. The "body" field contains a JSON-parsed value of what would normally have been sent over a non-multiplexed SSE connection. If the value is empty, then "body" should be set to null or not included. 228 | 229 | WebSockets 230 | ---------- 231 | 232 | A resource can indicate support for notifications via WebSocket by including a "multiplex-ws" Link type: 233 | 234 | GET /object HTTP/1.1 235 | ... 236 | 237 | HTTP/1.1 200 OK 238 | ETag: "b1946ac9" 239 | Link: ; rel=multiplex-ws 240 | ... 241 | 242 | The link points to a WebSocket capable endpoint. The client can then connect to establish a bi-directional session for handling subscriptions to resources and receiving notifications about them. The client and server must negotiate the "liveresource" protocol using Sec-WebSocket-Protocol headers. The wire protocol uses JSON-formatted messages. 243 | 244 | Once connected, the client can subscribe to a resource: 245 | 246 | { "id": "1", "type": "subscribe", "mode": "value", "uri": "/objectA" } 247 | 248 | Server acks: 249 | 250 | { "id": "1", "type": "subscribed" } 251 | 252 | The client can also unsubscribe: 253 | 254 | { "id": "2", "type": "unsubscribe", "mode": "value", "uri": "/objectA" } 255 | 256 | Server acks: 257 | 258 | { "id": "2", "type": "unsubscribed" } 259 | 260 | The 'id' field is used to match up requests and responses. The client does not have to wait for a response in order to make more requests over the socket. The server is not required to respond to requests in order. Subscriptions can have mode "value" or "changes". More than one subscription can be established over a single connection. 261 | 262 | The server notifies the client by sending a message of type "event". For values: 263 | 264 | { 265 | "type": "event", 266 | "uri": "/objectA", 267 | "headers": { 268 | "ETag": "..." 269 | }, 270 | "body": { ... } 271 | } 272 | 273 | For changes: 274 | 275 | { 276 | "type": "event", 277 | "uri": "/collection/", 278 | "headers": { 279 | "Link": "; rel=changes, ; rel=prev-changes", 280 | }, 281 | "body": [ ... ] 282 | } 283 | 284 | Notifications do not have guaranteed delivery. The client can detect for errors and recover by fetching the original URI or the changes URI. 285 | 286 | JavaScript API 287 | -------------- 288 | 289 | It should be possible to create a simple JavaScript API that supports fetching and syncing against web resources that conform to the above spec. 290 | 291 | Example usage for objects: 292 | 293 | // get and listen for updates of a user profile resource 294 | var user = new LiveResource('http://example.com/users/justin'); 295 | 296 | user.on('value', function(data) { 297 | // the content of the 'justin' resource has been initially received or changed 298 | }); 299 | 300 | user.on('removed', function() { 301 | // the 'justin' resource has been removed 302 | }); 303 | 304 | Example usage for collections: 305 | 306 | // fetch the first 20 items in justin's contact list ordered by first name. 307 | // this will also return an updates URI for the resource, which will be 308 | // utilized in the background to listen for updates going forward 309 | var contacts = new LiveResource( 310 | 'http://example.com/users/justin/contacts/?order=first&max=20' 311 | ); 312 | 313 | contacts.on('child-added', function(item) { 314 | // an item was created, with uri {contacts.uri}/{item.id}/ 315 | }); 316 | 317 | contacts.on('child-changed', function(item) { 318 | // an item was changed, with uri {contacts.uri}/{item.id}/ 319 | }); 320 | 321 | contacts.on('child-removed', function(item_id) { 322 | // an item was removed, with uri {contacts.uri}/{item_id}/ 323 | }); 324 | 325 | // if there was other metadata in the response that would be useful out of band, 326 | // then it can be obtained through the object. e.g. contacts.initialRequest.responseHeaders 327 | // could be used to fish out a Link rel=next for paging to the next 20 items 328 | // of the contact list ordered by first name. 329 | 330 | // if the app already has the updates link to use and doesn't want the JS API to 331 | // perform any initial query, it can be specified directly. for example, this could 332 | // happen if the app already accessed the collection using some separate AJAX 333 | // calls, or if the updates URI was written directly into the HTML by the server 334 | // when serving the page. 335 | var contacts = new LiveResource({ 336 | 'updates': 'http://example.com/users/justin/contacts/?after=1395174448&max=50' 337 | }); 338 | 339 | The JavaScript API implementation could start by using long-polling for updates and then attempt an upgrade to Server-Sent Events for compatible browsers. 340 | -------------------------------------------------------------------------------- /src/Aspects/Changes/ChangesAspect.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var Pollymer = require('Pollymer'); 4 | var Aspect = require('ResourceHandling/Aspect'); 5 | var ChangesResource = require('Aspects/Changes/Engine/ChangesResource'); 6 | 7 | var parseLinkHeader = require('utils.parseLinkHeader'); 8 | 9 | class ChangesAspect extends Aspect { 10 | constructor(resourceHandler) { 11 | super(resourceHandler); 12 | this.started = false; 13 | } 14 | 15 | start() { 16 | var request = new Pollymer.Request(); 17 | request.on('finished', (code, result, headers) => { 18 | 19 | var headerValues = this.parseHeaders(headers, this._resourceHandler.uri); 20 | 21 | if (code >= 200 && code < 300) { 22 | // 304 if not changed, don't trigger changes 23 | if (code < 300) { 24 | for (var n = 0; n < result.length; ++n) { 25 | if (result[n].deleted) { 26 | this._resourceHandler.trigger('child-deleted', this._resourceHandler, result[n]); 27 | } else { 28 | this._resourceHandler.trigger('child-added', this._resourceHandler, result[n]); 29 | } 30 | } 31 | } 32 | if (headerValues.changesWaitUri) { 33 | var engine = this._resourceHandler.resourceHandlerFactory.engine; 34 | engine.addResourceHandler(this._resourceHandler, ChangesAspect.InterestType, () => new ChangesResource( 35 | this._resourceHandler.uri, 36 | headerValues.changesWaitUri 37 | )); 38 | if (!this.started) { 39 | this.started = true; 40 | this._resourceHandler.trigger('ready', this._resourceHandler); 41 | } 42 | request = null; 43 | } else { 44 | debug.info('no changes-wait link'); 45 | } 46 | } else if (code >= 400) { 47 | request.retry(); 48 | } 49 | }); 50 | request.start('HEAD', this._resourceHandler.uri); 51 | } 52 | 53 | parseHeaders(headers, baseUri) { 54 | var changesWaitUri = null; 55 | 56 | utils.forEachOwnKeyValue(headers, (key, header) => { 57 | 58 | var k = key.toLowerCase(); 59 | if (k == 'link') { 60 | var links = parseLinkHeader(header); 61 | if (links && links['changes-wait']) { 62 | changesWaitUri = utils.toAbsoluteUri(baseUri, links['changes-wait']['href']); 63 | } 64 | } 65 | 66 | }); 67 | 68 | var result = {}; 69 | 70 | if (changesWaitUri) { 71 | debug.info('changes-wait: [' + changesWaitUri + ']'); 72 | result.changesWaitUri = changesWaitUri; 73 | } 74 | 75 | return result; 76 | } 77 | 78 | static get InterestType() { return 'changes'; } 79 | static get Events() { return ['child-added', 'child-removed']; } 80 | } 81 | 82 | module.exports = ChangesAspect; -------------------------------------------------------------------------------- /src/Aspects/Changes/Engine/ChangesEngineUnit.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | 3 | var EngineUnit = require('Engine/EngineUnit'); 4 | var ChangesWaitConnectionsMap = require('Aspects/Changes/Engine/ChangesWaitConnectionsMap'); 5 | var ChangesResource = require('Aspects/Changes/Engine/ChangesResource'); 6 | 7 | class ChangesEngineUnit extends EngineUnit { 8 | constructor(engine) { 9 | super(engine); 10 | this._changesWaitConnectionsMap = new ChangesWaitConnectionsMap(this); 11 | } 12 | 13 | update() { 14 | 15 | var changesWaitItems = {}; 16 | utils.forEachOwnKeyValue(this._resources, (resUri, res) => { 17 | if (res.changesWaitUri) { 18 | changesWaitItems[res.changesWaitUri] = res; 19 | } 20 | }); 21 | 22 | var changesWaitEndpoints = {}; 23 | utils.forEachOwnKeyValue(changesWaitItems, (endpointUri, endpoint) => { 24 | changesWaitEndpoints[endpointUri] = { endpointUri, item: endpoint }; 25 | }); 26 | 27 | this._changesWaitConnectionsMap.adjustEndpoints(changesWaitEndpoints); 28 | 29 | } 30 | 31 | get InterestType() { 32 | return 'changes'; 33 | } 34 | } 35 | 36 | module.exports = ChangesEngineUnit; -------------------------------------------------------------------------------- /src/Aspects/Changes/Engine/ChangesResource.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var parseLinkHeader = require('utils.parseLinkHeader'); 3 | 4 | var EngineResource = require('Engine/EngineResource'); 5 | 6 | class ChangesResource extends EngineResource { 7 | constructor(uri, changesWaitUri) { 8 | super(uri); 9 | this.changesWaitUri = changesWaitUri; 10 | } 11 | 12 | updateItem(headers, result) { 13 | 14 | utils.forEachOwnKeyValue(headers, (key, header) => { 15 | var lkey = key.toLowerCase(); 16 | if (lkey == 'link') { 17 | var links = parseLinkHeader(header); 18 | if (links && links['changes-wait']) { 19 | this.changesWaitUri = links['changes-wait']['href']; 20 | return false; 21 | } 22 | } 23 | }); 24 | 25 | for (var i = 0; i < this.owners.length; i++) { 26 | var owner = this.owners[i]; 27 | for (var n = 0; n < result.length; ++n) { 28 | if (result[n].deleted) { 29 | owner.trigger('child-deleted', owner, result[n]); 30 | } else { 31 | owner.trigger('child-added', owner, result[n]); 32 | } 33 | } 34 | } 35 | 36 | } 37 | } 38 | 39 | module.exports = ChangesResource; -------------------------------------------------------------------------------- /src/Aspects/Changes/Engine/ChangesWaitConnection.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var parseLinkHeader = require('utils.parseLinkHeader'); 4 | var Pollymer = require('Pollymer'); 5 | 6 | var Connection = require('Engine/Connection'); 7 | 8 | class ChangesWaitConnection extends Connection { 9 | constructor(engine, endpoint) { 10 | super(engine); 11 | 12 | this.uri = endpoint.endpointUri; 13 | this.request = new Pollymer.Request(); 14 | this.res = endpoint.item; 15 | this.isActive = false; 16 | 17 | this.request.on("finished", (code, result, headers) => { 18 | this.isActive = false; 19 | 20 | if (code >= 200 && code < 300) { 21 | this.res.updateItem(headers, result); 22 | } 23 | 24 | this._engine.update(); 25 | }); 26 | } 27 | 28 | hasChanged(endpoint) { 29 | return this.res.uri != endpoint.item.uri; 30 | } 31 | 32 | abort() { 33 | this.request.abort(); 34 | } 35 | 36 | refresh(endpoint) { 37 | if (!this.isActive) { 38 | var requestUri = this.uri; 39 | debug.info(`Changes Wait Request URI: ${requestUri}`); 40 | this.request.start('GET', requestUri, { 41 | 'Wait': 55 42 | }); 43 | this.isActive = true; 44 | } 45 | } 46 | } 47 | 48 | module.exports = ChangesWaitConnection; -------------------------------------------------------------------------------- /src/Aspects/Changes/Engine/ChangesWaitConnectionsMap.js: -------------------------------------------------------------------------------- 1 | var ConnectionsMap = require('Engine/ConnectionsMap'); 2 | var ChangesWaitConnection = require('Aspects/Changes/Engine/ChangesWaitConnection'); 3 | 4 | class ChangesWaitConnectionsMap extends ConnectionsMap { 5 | constructor(engineUnit) { 6 | super(engineUnit); 7 | } 8 | 9 | get label() { return 'Changes Wait'; } 10 | 11 | newConnection(engine, endpoint) { 12 | return new ChangesWaitConnection(engine, endpoint); 13 | } 14 | } 15 | 16 | module.exports = ChangesWaitConnectionsMap; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/MultiplexWaitConnection.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var Pollymer = require('Pollymer'); 4 | 5 | var ValueResource = require('Aspects/Value/Engine/ValueResource'); 6 | var Connection = require('Engine/Connection'); 7 | 8 | class MultiplexWaitConnection extends Connection { 9 | constructor(engine, endpoint, engineUnit) { 10 | super(engine); 11 | 12 | this.uri = endpoint.endpointUri; 13 | this.request = new Pollymer.Request(); 14 | this.resItems = endpoint.items.slice(); 15 | this.isActive = false; 16 | 17 | this.request.on("finished", (code, result, headers) => { 18 | this.isActive = false; 19 | 20 | if (code >= 200 && code < 300) { 21 | 22 | utils.forEachOwnKeyValue(result, (uri, item) => { 23 | 24 | debug.info(`got data for uri: ${uri}`); 25 | var absoluteUri = utils.toAbsoluteUri(this.uri, uri); 26 | ValueResource.updateValueItemMultiplex(engineUnit._resources, absoluteUri, item.headers, item.body); 27 | 28 | }); 29 | 30 | } 31 | 32 | this._engine.update(); 33 | }); 34 | } 35 | 36 | hasChanged(endpoint) { 37 | var removedOrChanged = false; 38 | if (endpoint.items.length != this.resItems.length) { 39 | removedOrChanged = true 40 | } else { 41 | var preferredEndpointItemUris = []; 42 | var i; 43 | for (i = 0; i < endpoint.items.length; i++) { 44 | preferredEndpointItemUris.push(endpoint.items[i].uri); 45 | } 46 | preferredEndpointItemUris.sort(); 47 | 48 | var pollResourceItemUris = []; 49 | for (i = 0; i < this.resItems.length; i++) { 50 | pollResourceItemUris.push(this.resItems[i].uri); 51 | } 52 | pollResourceItemUris.sort(); 53 | 54 | for (i = 0; i < preferredEndpointItemUris.length; i++) { 55 | if (preferredEndpointItemUris[i] != pollResourceItemUris[i]) { 56 | removedOrChanged = true; 57 | break; 58 | } 59 | } 60 | } 61 | return removedOrChanged; 62 | } 63 | 64 | abort() { 65 | this.request.abort(); 66 | } 67 | 68 | refresh(endpoint) { 69 | if (!this.isActive) { 70 | var urlSegments = []; 71 | for (var i = 0; i < this.resItems.length; i++) { 72 | var res = this.resItems[i]; 73 | var uri = res.uri; 74 | urlSegments.push(`u=${encodeURIComponent(uri)}&inm=${encodeURIComponent(res.etag)}`); 75 | } 76 | var requestUri = `${this.uri}?${urlSegments.join('&')}`; 77 | 78 | debug.info(`Multiplex Wait Request URI: ${requestUri}`); 79 | this.request.start('GET', requestUri, { 80 | 'Wait': 55 81 | }); 82 | this.isActive = true; 83 | } 84 | } 85 | } 86 | 87 | module.exports = MultiplexWaitConnection; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/MultiplexWaitConnectionsMap.js: -------------------------------------------------------------------------------- 1 | var ConnectionsMap = require('Engine/ConnectionsMap'); 2 | var MultiplexWaitConnection = require('Aspects/Value/Engine/MultiplexWaitConnection'); 3 | 4 | class MultiplexWaitConnectionsMap extends ConnectionsMap { 5 | constructor(engineUnit) { 6 | super(engineUnit); 7 | } 8 | 9 | get label() { return 'Multiplex Wait'; } 10 | 11 | newConnection(engine, endpoint) { 12 | return new MultiplexWaitConnection(engine, endpoint, this._engineUnit); 13 | } 14 | } 15 | 16 | module.exports = MultiplexWaitConnectionsMap; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/MultiplexWebSocketConnection.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var mapWebSocketUrls = require('utils.mapWebSocketUrls'); 4 | var WebSockHop = require('WebSockHop'); 5 | 6 | var Connection = require('Engine/Connection'); 7 | var ValueResource = require('Aspects/Value/Engine/ValueResource'); 8 | 9 | class MultiplexWebSocketConnection extends Connection { 10 | constructor(engine, endpoint, engineUnit) { 11 | super(engine); 12 | 13 | var endpointUri = endpoint.endpointUri; 14 | 15 | this.uri = endpointUri; 16 | this.socket = new WebSockHop(endpointUri); 17 | this.subscribedItems = {}; 18 | this.isConnected = false; 19 | this.isRetrying = false; 20 | 21 | this.socket.formatter = new WebSockHop.JsonFormatter(); 22 | 23 | this.socket.on("opened", () => { 24 | this.socket.on("message", data => { 25 | 26 | var uri = data.uri; 27 | var absoluteUri = utils.toAbsoluteUri(endpointUri, uri); 28 | var httpUri = mapWebSocketUrls.mapWebSocketUrlToHttpUrl(absoluteUri); 29 | 30 | ValueResource.updateValueItemMultiplex(engineUnit._resources, httpUri, data.headers, data.body); 31 | 32 | }); 33 | this.subscribedItems = {}; 34 | this.isConnected = true; 35 | this.isRetrying = false; 36 | 37 | this._engine.update(); 38 | }); 39 | 40 | this.socket.on("closed", () => { 41 | this.isConnected = false; 42 | this.isRetrying = false; 43 | }); 44 | 45 | this.socket.on("error", () => { 46 | this.isConnected = false; 47 | this.isRetrying = true; 48 | }); 49 | } 50 | 51 | subscribe(uri) { 52 | var type = 'subscribe', mode = 'value'; 53 | this.socket.request({type, mode, uri}, result => { 54 | if (result.type == 'subscribed') { 55 | connection.subscribedItems[uri] = uri; 56 | } 57 | }); 58 | } 59 | 60 | unsubscribe(uri) { 61 | var type = 'unsubscribe', mode = 'value'; 62 | this.socket.request({type, mode, uri}, result => { 63 | if (result.type == 'unsubscribed') { 64 | delete connection.subscribedItems[uri]; 65 | } 66 | }) 67 | } 68 | 69 | mapToHttpUri(uri) { 70 | var absoluteUri = utils.toAbsoluteUri(this.uri, uri); 71 | return mapWebSocketUrls.mapWebSocketUrlToHttpUrl(absoluteUri); 72 | } 73 | 74 | checkSubscriptions(items) { 75 | 76 | var endpointUri = this.uri; 77 | debug.info(`Multiplex WebSocket Request URI: ${endpointUri}`); 78 | 79 | var subscribedItems = {}; 80 | utils.forEachOwnKeyValue(this.subscribedItems, (uri, value) => { 81 | subscribedItems[uri] = value; 82 | }); 83 | 84 | for (var i = 0; i < items.length; i++) { 85 | var httpUri = this.mapToHttpUri(items[i].uri); 86 | if (httpUri in subscribedItems) { 87 | delete subscribedItems[httpUri]; 88 | } else { 89 | this.subscribe(httpUri); 90 | } 91 | } 92 | 93 | utils.forEachOwnKeyValue(subscribedItems, uri => { 94 | this.unsubscribe(uri); 95 | }); 96 | 97 | } 98 | 99 | close() { 100 | if (this.isConnected) { 101 | this.socket.close(); 102 | } else { 103 | this.socket.abort(); 104 | } 105 | } 106 | 107 | hasChanged(endpoint) { 108 | return endpoint.items.length == 0; 109 | } 110 | 111 | abort() { 112 | this.socket.abort(); 113 | } 114 | 115 | refresh(endpoint) { 116 | if (this.isConnected) { 117 | this.checkSubscriptions(endpoint.items); 118 | } 119 | } 120 | } 121 | 122 | module.exports = MultiplexWebSocketConnection; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/MultiplexWebSocketConnectionsMap.js: -------------------------------------------------------------------------------- 1 | var ConnectionsMap = require('Engine/ConnectionsMap'); 2 | var MultiplexWebSocketConnection = require('Aspects/Value/Engine/MultiplexWebSocketConnection'); 3 | 4 | class MultiplexWebSocketConnectionsMap extends ConnectionsMap { 5 | constructor(engineUnit) { 6 | super(engineUnit); 7 | } 8 | 9 | get label() { return 'Multiplex Web Socket'; } 10 | 11 | newConnection(engine, endpoint) { 12 | return new MultiplexWebSocketConnection(engine, endpoint, this._engineUnit); 13 | } 14 | } 15 | 16 | module.exports = MultiplexWebSocketConnectionsMap; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/ValueEngineUnit.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | 3 | var EngineUnit = require('Engine/EngineUnit'); 4 | var ValueResource = require('Aspects/Value/Engine/ValueResource'); 5 | var ValueWaitConnectionsMap = require('Aspects/Value/Engine/ValueWaitConnectionsMap'); 6 | var MultiplexWebSocketConnectionsMap = require('Aspects/Value/Engine/MultiplexWebSocketConnectionsMap'); 7 | var MultiplexWaitConnectionsMap = require('Aspects/Value/Engine/MultiplexWaitConnectionsMap'); 8 | 9 | class ValueEngineUnit extends EngineUnit { 10 | constructor(engine) { 11 | super(engine); 12 | this._valueWaitConnectionsMap = new ValueWaitConnectionsMap(this); 13 | this._multiplexWebSocketConnectionsMap = new MultiplexWebSocketConnectionsMap(this); 14 | this._multiplexWaitConnectionsMap = new MultiplexWaitConnectionsMap(this); 15 | } 16 | 17 | update() { 18 | 19 | var valueWaitItems = {}; 20 | var multiplexWebSocketItems = {}; 21 | var multiplexWaitItems = {}; 22 | 23 | utils.forEachOwnKeyValue(this._resources, (resUri, res) => { 24 | if (res.multiplexWebSocketUri) { 25 | var multiplexWebSocketPoll = utils.getOrCreateKey(multiplexWebSocketItems, res.multiplexWebSocketUri, () => ({items: []})); 26 | multiplexWebSocketPoll.items.push(res); 27 | } else if (res.multiplexWaitUri) { 28 | var multiplexWaitPoll = utils.getOrCreateKey(multiplexWaitItems, res.multiplexWaitUri, () => ({items: []})); 29 | multiplexWaitPoll.items.push(res); 30 | } else { 31 | valueWaitItems[res.valueWaitUri] = res; 32 | } 33 | }); 34 | 35 | var valueWaitEndpoints = {}; 36 | var multiplexWebSocketEndpoints = {}; 37 | var multiplexWaitEndpoints = {}; 38 | utils.forEachOwnKeyValue(multiplexWebSocketItems, (endpointUri, endpoint) => { 39 | multiplexWebSocketEndpoints[endpointUri] = { endpointUri, items: endpoint.items }; 40 | }); 41 | utils.forEachOwnKeyValue(multiplexWaitItems, (endpointUri, endpoint) => { 42 | if (endpoint.items.length > 1 || !endpoint.items[0].valueWaitUri) { 43 | multiplexWaitEndpoints[endpointUri] = { endpointUri, items: endpoint.items }; 44 | } else { 45 | valueWaitItems[endpoint.items[0].valueWaitUri] = endpoint.items[0]; 46 | } 47 | }); 48 | utils.forEachOwnKeyValue(valueWaitItems, (endpointUri, endpoint) => { 49 | valueWaitEndpoints[endpointUri] = { endpointUri, item: endpoint }; 50 | }); 51 | 52 | this._valueWaitConnectionsMap.adjustEndpoints(valueWaitEndpoints); 53 | this._multiplexWebSocketConnectionsMap.adjustEndpoints(multiplexWebSocketEndpoints); 54 | this._multiplexWaitConnectionsMap.adjustEndpoints(multiplexWaitEndpoints); 55 | 56 | } 57 | 58 | get InterestType() { 59 | return 'value'; 60 | } 61 | } 62 | 63 | module.exports = ValueEngineUnit; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/ValueResource.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | 3 | var ValueEngineUnit = require('Aspects/Value/Engine/ValueEngineUnit'); 4 | var EngineResource = require('Engine/EngineResource'); 5 | 6 | class ValueResource extends EngineResource { 7 | constructor(uri, etag, valueWaitUri, multiplexWaitUri, multiplexWebSocketUri) { 8 | super(uri); 9 | this.etag = etag; 10 | this.valueWaitUri = valueWaitUri; 11 | this.multiplexWaitUri = multiplexWaitUri; 12 | this.multiplexWebSocketUri = multiplexWebSocketUri; 13 | } 14 | 15 | updateItem(headers, result) { 16 | 17 | utils.forEachOwnKeyValue(headers, (key, header) => { 18 | var lowercaseKey = key.toLocaleLowerCase(); 19 | if (lowercaseKey == 'etag') { 20 | this.etag = header; 21 | return false; 22 | } 23 | }); 24 | 25 | for (var i = 0; i < this.owners.length; i++) { 26 | var owner = this.owners[i]; 27 | owner.trigger('value', owner, result); 28 | } 29 | 30 | } 31 | 32 | static updateValueItemMultiplex(resources, uri, headers, result) { 33 | 34 | utils.forEachOwnKeyValue(resources, (resourceUri, resource) => { 35 | if (resourceUri == uri) { 36 | resource.updateItem(headers, result); 37 | } 38 | }); 39 | 40 | } 41 | } 42 | 43 | module.exports = ValueResource; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/ValueWaitConnection.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var parseLinkHeader = require('utils.parseLinkHeader'); 4 | var Pollymer = require('Pollymer'); 5 | 6 | var Connection = require('Engine/Connection'); 7 | 8 | class ValueWaitConnection extends Connection { 9 | constructor(engine, endpoint) { 10 | super(engine); 11 | 12 | this.uri = endpoint.endpointUri; 13 | this.request = new Pollymer.Request(); 14 | this.res = endpoint.item; 15 | this.isActive = false; 16 | 17 | this.request.on("finished", (code, result, headers) => { 18 | this.isActive = false; 19 | 20 | if (code >= 200 && code < 300) { 21 | this.res.updateItem(headers, result); 22 | } 23 | 24 | this._engine.update(); 25 | }); 26 | } 27 | 28 | hasChanged(endpoint) { 29 | return this.res.uri != endpoint.item.uri; 30 | } 31 | 32 | abort() { 33 | this.request.abort(); 34 | } 35 | 36 | refresh(endpoint) { 37 | if (!this.isActive) { 38 | var requestUri = this.uri; 39 | debug.info(`Value Wait Request URI: ${requestUri}`); 40 | this.request.start('GET', requestUri, { 41 | 'If-None-Match': this.res.etag, 42 | 'Wait': 55 43 | }); 44 | this.isActive = true; 45 | } 46 | } 47 | } 48 | 49 | module.exports = ValueWaitConnection; -------------------------------------------------------------------------------- /src/Aspects/Value/Engine/ValueWaitConnectionsMap.js: -------------------------------------------------------------------------------- 1 | var ConnectionsMap = require('Engine/ConnectionsMap'); 2 | var ValueWaitConnection = require('Aspects/Value/Engine/ValueWaitConnection'); 3 | 4 | class ValueWaitConnectionsMap extends ConnectionsMap { 5 | constructor(engineUnit) { 6 | super(engineUnit); 7 | } 8 | 9 | get label() { return 'Value Wait'; } 10 | 11 | newConnection(engine, endpoint) { 12 | return new ValueWaitConnection(engine, endpoint); 13 | } 14 | } 15 | 16 | module.exports = ValueWaitConnectionsMap; -------------------------------------------------------------------------------- /src/Aspects/Value/ValueAspect.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var Pollymer = require('Pollymer'); 4 | var Aspect = require('ResourceHandling/Aspect'); 5 | var ValueResource = require('Aspects/Value/Engine/ValueResource'); 6 | 7 | var mapWebSocketUrls = require('utils.mapWebSocketUrls'); 8 | var parseLinkHeader = require('utils.parseLinkHeader'); 9 | 10 | class ValueAspect extends Aspect { 11 | constructor(resourceHandler) { 12 | super(resourceHandler); 13 | } 14 | 15 | start() { 16 | var request = new Pollymer.Request(); 17 | request.on('finished', (code, result, headers) => { 18 | 19 | var headerValues = this.parseHeaders(headers, this._resourceHandler.uri); 20 | 21 | if (code >= 200 && code < 400) { 22 | // 304 if not changed, don't trigger value 23 | if (code < 300) { 24 | this._resourceHandler.trigger('value', this._resourceHandler, result); 25 | } 26 | if (headerValues.etag) { 27 | var engine = this._resourceHandler.resourceHandlerFactory.engine; 28 | engine.addResourceHandler(this._resourceHandler, ValueAspect.InterestType, () => new ValueResource( 29 | this._resourceHandler.uri, 30 | headerValues.etag, 31 | headerValues.valueWaitUri, 32 | headerValues.multiplexWaitUri, 33 | headerValues.multiplexWsUri 34 | )); 35 | } else { 36 | debug.info('no etag'); 37 | } 38 | request = null; 39 | } else if (code >= 400) { 40 | if (code == 404) { 41 | this._resourceHandler.trigger('removed', this._resourceHandler); 42 | request = null; 43 | } else { 44 | request.retry(); 45 | } 46 | } 47 | }); 48 | request.start('GET', this._resourceHandler.uri); 49 | } 50 | 51 | parseHeaders(headers, baseUri) { 52 | var etag = null; 53 | var valueWaitUri = null; 54 | var multiplexWaitUri = null; 55 | var multiplexWsUri = null; 56 | 57 | utils.forEachOwnKeyValue(headers, (key, header) => { 58 | 59 | var k = key.toLowerCase(); 60 | if (k == 'etag') { 61 | etag = header; 62 | } else if (k == 'link') { 63 | var links = parseLinkHeader(header); 64 | if (links && links['value-wait']) { 65 | valueWaitUri = utils.toAbsoluteUri(baseUri, links['value-wait']['href']); 66 | } 67 | if (links && links['multiplex-wait']) { 68 | multiplexWaitUri = utils.toAbsoluteUri(baseUri, links['multiplex-wait']['href']); 69 | } 70 | if (links && links['multiplex-ws']) { 71 | multiplexWsUri = mapWebSocketUrls.mapHttpUrlToWebSocketUrl(utils.toAbsoluteUri(baseUri, links['multiplex-ws']['href'])); 72 | } 73 | } 74 | 75 | }); 76 | 77 | var result = {}; 78 | 79 | if (etag) { 80 | debug.info('etag: [' + etag + ']'); 81 | result.etag = etag; 82 | } 83 | 84 | if (valueWaitUri) { 85 | debug.info('value-wait: [' + valueWaitUri + ']'); 86 | result.valueWaitUri = valueWaitUri; 87 | } 88 | 89 | if (multiplexWaitUri) { 90 | debug.info('multiplex-wait: [' + multiplexWaitUri + ']'); 91 | result.multiplexWaitUri = multiplexWaitUri; 92 | } 93 | 94 | if (multiplexWsUri) { 95 | debug.info('multiplex-ws: [' + multiplexWsUri + ']'); 96 | result.multiplexWsUri = multiplexWsUri; 97 | } 98 | 99 | return result; 100 | } 101 | 102 | static get InterestType() { return 'value'; } 103 | static get Events() { return ['value', 'removed']; } 104 | } 105 | 106 | module.exports = ValueAspect; -------------------------------------------------------------------------------- /src/Engine/Connection.js: -------------------------------------------------------------------------------- 1 | class Connection { 2 | constructor(engine) { 3 | this._engine = engine; 4 | } 5 | 6 | hasChanged(endpoint) { 7 | throw false; 8 | } 9 | 10 | abort() { 11 | } 12 | 13 | refresh(endpoint) { 14 | } 15 | } 16 | 17 | module.exports = Connection; -------------------------------------------------------------------------------- /src/Engine/ConnectionsMap.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | 4 | class ConnectionsMap { 5 | constructor(engineUnit) { 6 | this._engineUnit = engineUnit; 7 | this._connections = {}; 8 | } 9 | 10 | adjustEndpoints(preferredEndpointsMap) { 11 | 12 | // _connections is a mapping of endpointUri -> connection 13 | // preferredEndpointsMap is a mapping of endpointUri -> endpoint 14 | 15 | // Keep track of list of new endpoints to enable 16 | var newEndpoints = {}; 17 | utils.forEachOwnKeyValue(preferredEndpointsMap, (endpointUri, endpoint) => { 18 | newEndpoints[endpointUri] = endpoint; 19 | }); 20 | 21 | // Make a list of endpoints to disable... 22 | var endpointsToDisable = []; 23 | utils.forEachOwnKeyValue(this._connections, (endpointUri, connection) => { 24 | // This item is already known, so remove endpoint from "new endpoints". 25 | delete newEndpoints[endpointUri]; 26 | 27 | var removedOrChanged = false; 28 | if (!(endpointUri in preferredEndpointsMap)) { 29 | // If item is not in the preferred endpoints map, then it has been 30 | // removed. Mark for disabling. 31 | removedOrChanged = true; 32 | } else { 33 | // If item is in the preferred endpoints map, then 34 | // call "changeTest" to decide whether this item has changed. 35 | var endpoint = preferredEndpointsMap[endpointUri]; 36 | removedOrChanged = connection.hasChanged(endpoint); 37 | } 38 | if (removedOrChanged) { 39 | // If marked, add to "delete" list 40 | endpointsToDisable.push(endpointUri); 41 | } 42 | }); 43 | 44 | // ... and disable them. 45 | for (var i = 0; i < endpointsToDisable.length; i++) { 46 | var endpointUri = endpointsToDisable[i]; 47 | debug.info(`Remove '${this.label}' endpoint - '${endpointUri}'.`); 48 | var connection = this._connections[endpointUri]; 49 | connection.abort(); 50 | delete this._connections[endpointUri]; 51 | } 52 | 53 | // Create new requests for endpoints that need them. 54 | utils.forEachOwnKeyValue(newEndpoints, (endpointUri, endpoint) => { 55 | debug.info(`Adding '${this.label}' endpoint - '${endpointUri}'.`); 56 | this._connections[endpointUri] = this.newConnection(this._engineUnit.engine, endpoint); 57 | }); 58 | 59 | // For any current endpoint, make sure they are running. 60 | utils.forEachOwnKeyValue(this._connections, (endpointUri, connection) => { 61 | var endpoint = preferredEndpointsMap[endpointUri]; 62 | connection.refresh(endpoint); 63 | }); 64 | } 65 | 66 | get label() { 67 | throw 'unsupported'; 68 | } 69 | 70 | newConnection(endpoint, engine) { 71 | throw 'unsupported'; 72 | } 73 | } 74 | 75 | module.exports = ConnectionsMap; -------------------------------------------------------------------------------- /src/Engine/Engine.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | var mapWebSocketUrls = require('utils.mapWebSocketUrls'); 4 | 5 | class Engine { 6 | constructor() { 7 | this._engineUnits = []; 8 | this._updatePending = false; 9 | } 10 | 11 | update() { 12 | if (!this._updatePending) { 13 | this._updatePending = true; 14 | process.nextTick(() => { 15 | this._updatePending = false; 16 | 17 | // restart our long poll 18 | debug.info('engine: setup long polls'); 19 | 20 | for(var i = 0; i < this._engineUnits.length; i++) { 21 | var engineUnit = this._engineUnits[i]; 22 | engineUnit.update(); 23 | } 24 | }); 25 | } 26 | } 27 | 28 | addResourceHandler(resourceHandler, interestType, createResource) { 29 | for(var i = 0; i < this._engineUnits.length; i++) { 30 | var engineUnit = this._engineUnits[i]; 31 | if (engineUnit.InterestType == interestType) { 32 | engineUnit.addResourceHandler(resourceHandler, createResource); 33 | this.update(); 34 | break; 35 | } 36 | } 37 | } 38 | 39 | addEngineUnit(engineUnit) { 40 | this._engineUnits.push(engineUnit); 41 | engineUnit.engine = this; 42 | } 43 | } 44 | 45 | module.exports = Engine; -------------------------------------------------------------------------------- /src/Engine/EngineResource.js: -------------------------------------------------------------------------------- 1 | class EngineResource { 2 | constructor(uri) { 3 | this.uri = uri; 4 | this.owners = []; 5 | } 6 | } 7 | 8 | module.exports = EngineResource; -------------------------------------------------------------------------------- /src/Engine/EngineUnit.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | 3 | class EngineUnit { 4 | constructor() { 5 | this.engine = null; 6 | this._resources = {}; 7 | } 8 | 9 | update() { 10 | } 11 | 12 | addResourceHandler(resourceHandler, createResource) { 13 | var resource = utils.getOrCreateKey(this._resources, resourceHandler.uri, createResource); 14 | resource.owners.push(resourceHandler); 15 | return resource; 16 | } 17 | 18 | get InterestType() { 19 | return null; 20 | } 21 | } 22 | 23 | module.exports = EngineUnit; -------------------------------------------------------------------------------- /src/ResourceHandling/Aspect.js: -------------------------------------------------------------------------------- 1 | class Aspect { 2 | constructor(resourceHandler) { 3 | this._resourceHandler = resourceHandler; 4 | } 5 | } 6 | 7 | module.exports = Aspect; -------------------------------------------------------------------------------- /src/ResourceHandling/Events.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | 4 | class Events { 5 | constructor() { 6 | this._events = {}; 7 | } 8 | 9 | _getHandlersForType(type) { 10 | if (!(type in this._events)) { 11 | this._events[type] = []; 12 | } 13 | return this._events[type]; 14 | } 15 | 16 | on(type, handler) { 17 | var handlers = this._getHandlersForType(type); 18 | handlers.push(handler); 19 | } 20 | 21 | off(type, handler = null) { 22 | if (handler != null) { 23 | var handlers = this._getHandlersForType(type); 24 | utils.removeFromArray(handlers, handler); 25 | } else { 26 | delete this._events[type]; 27 | } 28 | } 29 | 30 | trigger(type, obj, ...args) { 31 | var handlers = this._getHandlersForType(type).slice(); 32 | for (var i = 0, n = handlers.length; i < n; i++) { 33 | var handler = handlers[i]; 34 | handler.apply(obj, args); 35 | } 36 | } 37 | } 38 | 39 | module.exports = Events; -------------------------------------------------------------------------------- /src/ResourceHandling/LiveResource.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var getWindowLocationHref = require('utils.getWindowLocationHref'); 3 | 4 | var Events = require('ResourceHandling/Events'); 5 | 6 | class LiveResource { 7 | constructor(resourceHandlerFactory, uri) { 8 | this._events = new Events(); 9 | 10 | var windowLocationHref = getWindowLocationHref(); 11 | var absoluteUri = utils.toAbsoluteUri(windowLocationHref, uri); 12 | this._resourceHandler = resourceHandlerFactory.getHandlerForUri(absoluteUri); 13 | this._resourceHandler.addLiveResource(this); 14 | } 15 | 16 | on(type, handler) { 17 | this._events.on(type, handler); 18 | this._resourceHandler.addEvent(type); 19 | } 20 | 21 | off(type, handler = null) { 22 | this._events.off(type, handler); 23 | } 24 | 25 | cancel() { 26 | this._events = null; 27 | if (this._resourceHandler != null) { 28 | this._resourceHandler.removeLiveResource(this); 29 | this._resourceHandler = null; 30 | } 31 | } 32 | } 33 | 34 | module.exports = LiveResource; -------------------------------------------------------------------------------- /src/ResourceHandling/LiveResourceFactory.js: -------------------------------------------------------------------------------- 1 | var LiveResource = require('ResourceHandling/LiveResource'); 2 | 3 | class LiveResourceFactory { 4 | constructor(resourceHandlerFactory) { 5 | this.resourceHandlerFactory = resourceHandlerFactory; 6 | } 7 | 8 | getLiveResourceClass() { 9 | var factory = this; 10 | return class { 11 | constructor(uri) { 12 | return new LiveResource(factory.resourceHandlerFactory, uri); 13 | } 14 | }; 15 | } 16 | } 17 | 18 | module.exports = LiveResourceFactory; -------------------------------------------------------------------------------- /src/ResourceHandling/ResourceHandler.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var debug = require('console'); 3 | 4 | class ResourceHandler { 5 | constructor(resourceHandlerFactory, uri) { 6 | this.resourceHandlerFactory = resourceHandlerFactory; 7 | this.uri = uri; 8 | 9 | this._aspects = {}; 10 | this._liveResources = []; 11 | } 12 | 13 | addLiveResource(liveResource) { 14 | this._liveResources.push(liveResource); 15 | } 16 | 17 | removeLiveResource(liveResource) { 18 | utils.removeFromArray(this._liveResources, liveResource); 19 | } 20 | 21 | trigger(event, target, ...args) { 22 | var count = this._liveResources.length; 23 | for (var i = 0; i < count; i++) { 24 | var liveResource = this._liveResources[i]; 25 | liveResource._events.trigger(event, target, ...args); 26 | } 27 | } 28 | 29 | addEvent(type) { 30 | var interestType = this.resourceHandlerFactory.findInterestTypeForEvent(type); 31 | if (!(interestType in this._aspects)) { 32 | var aspectClass = this.resourceHandlerFactory.getAspectClass(interestType); 33 | if (aspectClass != null) { 34 | var aspect = new aspectClass(this); 35 | this._aspects[interestType] = aspect; 36 | aspect.start(); 37 | } 38 | } 39 | } 40 | } 41 | 42 | module.exports = ResourceHandler; -------------------------------------------------------------------------------- /src/ResourceHandling/ResourceHandlerFactory.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var ResourceHandler = require('ResourceHandling/ResourceHandler'); 3 | 4 | class ResourceHandlerFactory { 5 | constructor(engine) { 6 | this.engine = engine; 7 | this._resources = {}; 8 | this._aspectClasses = {}; 9 | } 10 | 11 | getHandlerForUri(uri) { 12 | return utils.getOrCreateKey(this._resources, uri, () => new ResourceHandler(this, uri)); 13 | } 14 | 15 | addAspectClass(aspectClass) { 16 | var interestType = aspectClass.InterestType; 17 | this._aspectClasses[interestType] = aspectClass; 18 | } 19 | 20 | getAspectClass(interestType) { 21 | return this._aspectClasses[interestType]; 22 | } 23 | 24 | findInterestTypeForEvent(eventName) { 25 | var interestType = null; 26 | utils.forEachOwnKeyValue(this._aspectClasses, (key, value) => { 27 | if (utils.findInArray(value.Events, eventName) >= 0) { 28 | interestType = key; 29 | return false; 30 | } 31 | }); 32 | return interestType; 33 | } 34 | } 35 | 36 | module.exports = ResourceHandlerFactory; -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | // Build the default LiveResource constructor 2 | 3 | // 1. Create a new engine 4 | var Engine = require('Engine/Engine'); 5 | var engine = new Engine(); 6 | 7 | // 2. Add engine units 8 | var ValueEngineUnit = require('Aspects/Value/Engine/ValueEngineUnit'); 9 | engine.addEngineUnit(new ValueEngineUnit()); 10 | 11 | var ChangesEngineUnit = require('Aspects/Changes/Engine/ChangesEngineUnit'); 12 | engine.addEngineUnit(new ChangesEngineUnit()); 13 | 14 | // 3. Create a new ResourceHandlerFactory and pass in the engine. 15 | var ResourceHandlerFactory = require('ResourceHandling/ResourceHandlerFactory'); 16 | var resourceHandlerFactory = new ResourceHandlerFactory(engine); 17 | 18 | // 4. Add aspect factories 19 | var ValueAspect = require('Aspects/Value/ValueAspect'); 20 | resourceHandlerFactory.addAspectClass(ValueAspect); 21 | 22 | var ChangesAspect = require('Aspects/Changes/ChangesAspect'); 23 | resourceHandlerFactory.addAspectClass(ChangesAspect); 24 | 25 | // 5. Create a new LiveResourceFactory and pass in the ResourceHandlerFactory. 26 | var LiveResourceFactory = require('ResourceHandling/LiveResourceFactory'); 27 | var liveResourceFactory = new LiveResourceFactory(resourceHandlerFactory); 28 | 29 | // 6. Call getCreate() of the LiveResourceFactory, which returns 30 | // a class whose constructor will create a LiveResource instance. 31 | var liveResourceClass = liveResourceFactory.getLiveResourceClass(); 32 | 33 | module.exports = liveResourceClass; -------------------------------------------------------------------------------- /src/no-console.js: -------------------------------------------------------------------------------- 1 | var __no_op = function() {}; 2 | module.exports = { 3 | log: __no_op, 4 | error: __no_op, 5 | warn: __no_op, 6 | info: __no_op 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils.getWindowLocationHref.js: -------------------------------------------------------------------------------- 1 | var getWindowLocationHref = function() { 2 | return "http://example.com"; 3 | }; 4 | 5 | module.exports = getWindowLocationHref; -------------------------------------------------------------------------------- /src/utils.getWindowLocationHrefBrowser.js: -------------------------------------------------------------------------------- 1 | var getWindowLocationHref = function() { 2 | return window.location.href; 3 | }; 4 | 5 | module.exports = getWindowLocationHref; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | var findInArray = function(array, item) { 2 | for (var i = 0, length = array.length; i < length; i++) { 3 | if (array[i] === item) { 4 | return i; 5 | } 6 | } 7 | return -1; 8 | }; 9 | 10 | var isInArray = function(array, item) { 11 | return !(findInArray(array, item) < 0); 12 | }; 13 | 14 | var removeFromArray = function(array, item) { 15 | var again = true; 16 | while (again) { 17 | var index = utils.findInArray(array, item); 18 | if (index != -1) { 19 | array.splice(index, 1); 20 | } else { 21 | again = false; 22 | } 23 | } 24 | }; 25 | 26 | var parseURI = function(url) { 27 | var m = String(url).replace(/^\s+|\s+$/g, '').match(/^([^:\/?#]+:)?(\/\/(?:[^:@]*(?::[^:@]*)?@)?(([^:\/?#]*)(?::(\d*))?))?([^?#]*)(\?[^#]*)?(#[\s\S]*)?/); 28 | // authority = '//' + user + ':' + pass '@' + hostname + ':' port 29 | return (m ? { 30 | href : m[0] || '', 31 | protocol : m[1] || '', 32 | authority: m[2] || '', 33 | host : m[3] || '', 34 | hostname : m[4] || '', 35 | port : m[5] || '', 36 | pathname : m[6] || '', 37 | search : m[7] || '', 38 | hash : m[8] || '' 39 | } : null); 40 | } 41 | 42 | var toAbsoluteUri = function(base, href) {// RFC 3986 43 | 44 | function removeDotSegments(input) { 45 | var output = []; 46 | input.replace(/^(\.\.?(\/|$))+/, '') 47 | .replace(/\/(\.(\/|$))+/g, '/') 48 | .replace(/\/\.\.$/, '/../') 49 | .replace(/\/?[^\/]*/g, function (p) { 50 | if (p === '/..') { 51 | output.pop(); 52 | } else { 53 | output.push(p); 54 | } 55 | }); 56 | return output.join('').replace(/^\//, input.charAt(0) === '/' ? '/' : ''); 57 | } 58 | 59 | href = parseURI(href || ''); 60 | base = parseURI(base || ''); 61 | 62 | return !href || !base ? null : (href.protocol || base.protocol) + 63 | (href.protocol || href.authority ? href.authority : base.authority) + 64 | removeDotSegments(href.protocol || href.authority || href.pathname.charAt(0) === '/' ? href.pathname : (href.pathname ? ((base.authority && !base.pathname ? '/' : '') + base.pathname.slice(0, base.pathname.lastIndexOf('/') + 1) + href.pathname) : base.pathname)) + 65 | (href.protocol || href.authority || href.pathname ? href.search : (href.search || base.search)) + 66 | href.hash; 67 | } 68 | 69 | var forEachOwnKeyValue = function(obj, predicate) { 70 | for(var key in obj) { 71 | if (obj.hasOwnProperty(key)) { 72 | var result = predicate(key, obj[key]); 73 | if (typeof(result) != 'undefined' && !result) { 74 | break; 75 | } 76 | } 77 | } 78 | }; 79 | 80 | var getOrCreateKey = function(obj, key, create) { 81 | if (!(key in obj)) { 82 | obj[key] = create(); 83 | } 84 | return obj[key]; 85 | }; 86 | 87 | var beginsWith = function(str, find) { 88 | return str.substring(0, find.length) == find; 89 | }; 90 | 91 | var replaceStart = function(str, find, replace) { 92 | return replace + str.substring(find.length); 93 | }; 94 | 95 | module.exports = { 96 | findInArray, 97 | isInArray, 98 | removeFromArray, 99 | toAbsoluteUri, 100 | forEachOwnKeyValue, 101 | getOrCreateKey, 102 | beginsWith, 103 | replaceStart 104 | }; 105 | -------------------------------------------------------------------------------- /src/utils.mapWebSocketUrls.js: -------------------------------------------------------------------------------- 1 | var utils = require('utils'); 2 | var getWindowLocationHref = require('utils.getWindowLocationHref'); 3 | 4 | module.exports = { 5 | mapHttpUrlToWebSocketUrl: function(uri) { 6 | var windowLocationHref = getWindowLocationHref(); 7 | var absoluteUri = utils.toAbsoluteUri(windowLocationHref, uri); 8 | 9 | var converted = absoluteUri; 10 | if (utils.beginsWith(absoluteUri, "http://")) { 11 | converted = utils.replaceStart(absoluteUri, "http://", "ws://"); 12 | } else if (utils.beginsWith(absoluteUri, "https://")) { 13 | converted = utils.replaceStart(absoluteUri, "https://", "wss://"); 14 | } 15 | 16 | if (!utils.beginsWith(converted, "ws://") && !utils.beginsWith(converted, "wss://")) { 17 | throw "not valid"; 18 | } 19 | 20 | return converted; 21 | }, 22 | mapWebSocketUrlToHttpUrl: function(url) { 23 | var converted = url; 24 | if (utils.beginsWith(url, "ws://")) { 25 | converted = utils.replaceStart(url, "ws://", "http://"); 26 | } else if (utils.beginsWith(url, "wss://")) { 27 | converted = utils.replaceStart(url, "wss://", "https://"); 28 | } 29 | 30 | if (!utils.beginsWith(converted, "http://") && !utils.beginsWith(converted, "https://")) { 31 | throw "not valid"; 32 | } 33 | 34 | return converted; 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/utils.parseLinkHeader.js: -------------------------------------------------------------------------------- 1 | var debug = require('console'); 2 | 3 | // TODO: REFACTOR THIS 4 | // ** PARSE LINK HEADER ** 5 | // returns object with structure: 6 | // { reltype1: { href: url, otherparam1: val }, reltype2: { ... } } 7 | // or return null if parse fails 8 | 9 | module.exports = function (header) { 10 | if (header.length == 0) 11 | return null; 12 | 13 | var links = {}; 14 | 15 | var at = 0; 16 | var readLink = true; 17 | while (readLink) { 18 | // skip ahead to next non-space char 19 | for (; at < header.length; ++at) { 20 | if (header[at] != ' ') 21 | break; 22 | } 23 | if (at >= header.length || header[at] != '<') 24 | return null; 25 | 26 | var start = at + 1; 27 | var end = header.indexOf('>', at); 28 | if (end == -1) 29 | return null; 30 | 31 | var url = header.substring(start, end); 32 | 33 | at = end + 1; 34 | 35 | readLink = false; 36 | var readParams = false; 37 | if (at < header.length) { 38 | if (header[at] == ',') { 39 | readLink = true; 40 | ++at; 41 | } else if (header[at] == ';') { 42 | readParams = true; 43 | ++at; 44 | } else { 45 | return null; 46 | } 47 | } 48 | 49 | var rel = null; 50 | var params = {}; 51 | while (readParams) { 52 | // skip ahead to next non-space char 53 | for (; at < header.length; ++at) { 54 | if (header[at] != ' ') 55 | break; 56 | } 57 | if (at >= header.length) 58 | return null; 59 | 60 | start = at; 61 | 62 | // find end of param name 63 | for (; at < header.length; ++at) { 64 | if (header[at] == '=' || header[at] == ',' || header[at] == ';') 65 | break; 66 | } 67 | end = at; 68 | 69 | var name = header.substring(start, end); 70 | var val = null; 71 | 72 | if (at < header.length && header[at] == '=') { 73 | // read value 74 | ++at; 75 | if(at < header.length && header[at] == '\"') { 76 | start = at + 1; 77 | 78 | // find end of quoted param value 79 | at = header.indexOf('\"', start); 80 | if (at == -1) 81 | return null; 82 | end = at; 83 | 84 | val = header.substring(start, end); 85 | 86 | ++at; 87 | } else { 88 | start = at; 89 | 90 | // find end of param value 91 | for (; at < header.length; ++at) { 92 | if (header[at] == ',' || header[at] == ';') 93 | break; 94 | } 95 | end = at; 96 | 97 | val = header.substring(start, end); 98 | } 99 | } 100 | 101 | readParams = false; 102 | if (at < header.length) { 103 | if (header[at] == ',') { 104 | readLink = true; 105 | ++at; 106 | } else if (header[at] == ';') { 107 | readParams = true; 108 | ++at; 109 | } else { 110 | return null; 111 | } 112 | } 113 | 114 | if (name == 'rel') 115 | rel = val; 116 | else 117 | params[name] = val; 118 | } 119 | 120 | if (rel) { 121 | var rels = rel.split(' '); 122 | for (var i = 0; i < rels.length; ++i) { 123 | debug.info('link: url=[' + url + '], rel=[' + rels[i] + ']'); 124 | var link = {}; 125 | link.rel = rels[i]; 126 | link.href = url; 127 | for (var paramName in params) { 128 | if (!params.hasOwnProperty(paramName)) 129 | continue; 130 | link[paramName] = params[paramName]; 131 | } 132 | links[link.rel] = link; 133 | } 134 | } 135 | } 136 | 137 | return links; 138 | }; -------------------------------------------------------------------------------- /tests/test1.js: -------------------------------------------------------------------------------- 1 | var test = require('tape'); 2 | var LiveResource = require('../src/main.js'); 3 | 4 | test('new returns LiveResource object', function(t) { 5 | var result = new LiveResource('dummy-path'); 6 | t.equal(result.constructor, LiveResource); 7 | t.end(); 8 | }); 9 | --------------------------------------------------------------------------------