├── LICENSE ├── README.md ├── cache.js ├── check.js ├── example ├── chat │ ├── db.js │ ├── index.js │ └── static │ │ └── style.css ├── clock │ └── index.js ├── fs │ ├── files │ │ ├── new_file │ │ ├── new_file.txt │ │ ├── new_file2.txt │ │ ├── new_file3.txt │ │ ├── new_file4.txt │ │ ├── new_file5.txt │ │ └── new_file6.txt │ ├── index.js │ └── static │ │ └── style.css └── tabs │ └── index.js ├── index.js ├── names.js ├── package.json └── util.js /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Dominic Tarr 2 | 3 | Permission is hereby granted, free of charge, 4 | to any person obtaining a copy of this software and 5 | associated documentation files (the "Software"), to 6 | deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom 10 | the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 18 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 20 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # coherence 2 | 3 | frontend framework based on a cache `coherence` 4 | protocol. 5 | 6 | ## Architecture 7 | 8 | Take a traditional RESTful http application, but then supercharge it with 9 | _cache invalidation_. This means just write templates and queries, rendered 10 | serverside, but then the front end checks at certain appropiate times wether 11 | parts of the page have changed, and if so updates just those parts. 12 | Because they way the cache (front end views) are invalidated and updated is 13 | standardized into a protocol, _it is not necessary to write any custom front end javascript_. 14 | 15 | To facillitate this, the back end needs to provide a little extra information 16 | and constraints. Firstly, we assume that application is comprised of a number of views, 17 | which are composed from a layout and partials. The "layout" is a wrapper around every 18 | page in the app, usually adding nav and footer etc. "partials" are templates for just 19 | a part of the page, that are reused across multiple views. This is a very common way 20 | to create an http application. The different thing, is that it needs to be possible 21 | to request just a single partial at a time (as well as the full page) via an http request, 22 | (without the layout). And finally, each rendered partial must have the url to update it 23 | in an attribute, and an cache id and state. Similar to an [etag](https://en.wikipedia.org/wiki/HTTP_ETag) 24 | header (but more powerful) but it represents the cache state of a single element. 25 | 26 | Several examples are provided, the simplest is `examples/clock/index.js`. 27 | it renders a single partial (displaying the current time) and it updates every second. 28 | This `data-id` `data-href` and `data-ts` must be provided. The front end calls 29 | the server to check if it has something newer that `data-ts` and if so, rerequests 30 | the partial from `data-href`. 31 | 32 | ``` html 33 |

Fri Jan 11 2019 00:03:43 GMT+1300 (NZDT)

34 | ``` 35 | 36 | in an etag header, a resource has a single opaque token. It's just a string that 37 | identifies the state of a resource. It could be the hash of a file or the timestamp 38 | a record is updated, or just a random number. In `coherence` cache state is split into 39 | two parts - the `id` and the `ts` (timestamp). The id just identifies the caching resource. 40 | the `ts` indicates how old that record is. Since it's a timestamp - we can also compare whether it's 41 | recent or old. 42 | 43 | The frontend checks with the server to see if things should be updated. It doesn't check all the time 44 | just when it will be useful to do so. When the page loads, it checks, incase this is a fast changing 45 | page. If nothing changes that check just sit open. If something does change, if the user is still 46 | looking at this page, it updates the page and checks again. If the user is not looking at this page 47 | anymore, it just remembers that the pages needs updating, but doesn't do anything until they come back. 48 | (It doesn't update at all until they come back, because the page might change again before they return) 49 | This is not ideal for real time games, but it's fine for form based apps or chatrooms like most react apps. 50 | 51 | In the clock.js example, When the server reports that the current clock is invalid 52 | (which happens every second, in this demo) the partial is requested again from `/clock`, 53 | and the result is inserted into the current document. 54 | 55 | updates are applied with [morphdom](https://www.npmjs.com/package/morphdom), 56 | this makes updates very slick and gives the feeling of using a single page application, but 57 | the feeling of developing a simple html application. 58 | 59 | Because `coherence` only uses a very small amount of front end javascript, memory usage 60 | is very low, similar to a static page, unlike typical react or angular applications with 61 | front end rendering and JSON apis. [your web app is bloated](https://github.com/dominictarr/your-web-app-is-bloated) 62 | 63 | ## implementations 64 | 65 | coherence is intended to be very easy to implement in 66 | other languages, or build into your own favorite framework. 67 | 68 | * [keks/goherence](https://github.com/keks/goherence) 69 | * your implementation here 70 | 71 | ## attributes -- updatable elements 72 | 73 | ### data-id 74 | 75 | set data-id to any string, it represents _the resource_ 76 | that gets updated. The state of a resource is tracked 77 | by id, and not by href. there may be multiple different 78 | hrefs that render a resource with the same id. 79 | 80 | ### data-href 81 | 82 | the url that this resource is rendered from. 83 | when this element updates, it will be reloaded 84 | by calling this href, (with `/partial/` infront) 85 | if the resource has not changed, this url should 86 | return exactly the same content. 87 | 88 | ### data-ts 89 | 90 | a unix timestamp (in milliseconds) that is the time 91 | this resource was last invalidated. 92 | 93 | ## attributes - forms 94 | 95 | there are several extra attributes in coherence 96 | that change the behaviour of forms, to make submissions 97 | smoother. If a form does not have these, the form 98 | will be submitted as it would normally - causing 99 | the page to reload. If any of these are defined, 100 | the submit will be sent via javascript instead, 101 | and changes in other elements must be triggered 102 | by an invalidation. 103 | 104 | ### data-reset 105 | 106 | set on the form 107 | 108 | `
` 109 | 110 | resets the form - clears all values back to default. 111 | 112 | ### data-invalidate=id 113 | 114 | set on the form, the value is a element id. 115 | 116 | `` 117 | 118 | when the form is submitted, any element with 119 | the matching id is considered invalidated, 120 | and is updated immediately. The update request 121 | is not made until after the form submit returns. 122 | 123 | ### autocomplete=off 124 | 125 | set on a text input 126 | 127 | `` 128 | 129 | This is a standard html form attribute, but is useful 130 | with coherence, especially for chat style applications. 131 | it prevents a dropdown 132 | 133 | 134 | ## License 135 | 136 | MIT 137 | -------------------------------------------------------------------------------- /cache.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = function () { 3 | var waiting = [] 4 | var cache = {} 5 | var earliest = Date.now(), latest = Date.now() 6 | function check (opts, cb) { 7 | var ids = {}, since = +opts.since 8 | if(since >= latest) { 9 | return waiting.push(function (_since) { 10 | for(var k in cache) { 11 | if(cache[k] > since) { 12 | ids[k] = cache[k] 13 | } 14 | } 15 | cb(null, {ids: ids, start: earliest}) 16 | }) 17 | } 18 | else { 19 | for(var k in cache) { 20 | if(cache[k] > since) { 21 | ids[k] = cache[k] 22 | } 23 | } 24 | cb(null, {ids: ids, start: earliest}) 25 | } 26 | } 27 | check.invalidate = function (key, ts) { 28 | latest = Math.max(latest, cache[key] = (ts || Date.now())) 29 | //callback every listener? are we sure? 30 | while(waiting.length) waiting.shift()(ts) 31 | return ts 32 | } 33 | 34 | check.invalidateAll = function () { 35 | var ts = earliest = latest = Date.now() 36 | while(waiting.length) waiting.shift()(ts) 37 | return ts 38 | } 39 | 40 | return check 41 | } 42 | -------------------------------------------------------------------------------- /check.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var names = require('./names') 3 | var morph = require('nanomorph') 4 | var forms = require('submit-form-element') 5 | 6 | var cache = {} 7 | window.COHERENCE = {ids: cache} 8 | 9 | var inflight = 0, timer, since, checking = false 10 | 11 | //check wether user has this tab open, if it's not open 12 | //don't check anything again until they come back. 13 | //note: on my tiling window manager, it doesn't notice 14 | //if you left this page open, but switch to another workspace. 15 | //but if you switch tabs it does. 16 | 17 | var onScreen = document.visibilityState == 'visible' 18 | function setOnScreen () { 19 | if(onScreen) return 20 | onScreen = true 21 | check(since) 22 | } 23 | if(!document.visibilityState) { 24 | window.onfocus = setOnScreen 25 | window.onblur = function () { 26 | onScreen = false 27 | } 28 | window.onmouseover = setOnScreen 29 | } 30 | document.addEventListener('visibilitychange', function () { 31 | if(document.visibilityState === 'visible') setOnScreen() 32 | else onScreen = false 33 | }) 34 | 35 | //-- util functions --- 36 | 37 | function xhr (url, cb) { 38 | var req = new XMLHttpRequest() 39 | req.open('get', url) 40 | req.setRequestHeader('Content-Type', 'application/json') 41 | req.onload = function () { 42 | cb(null, req.response) 43 | } 44 | req.onerror = function () { 45 | cb(new Error(req.status + ':' + req.statusText)) 46 | } 47 | req.send() 48 | } 49 | 50 | window.addEventListener('load', scan) 51 | 52 | // --- forms --- 53 | 54 | function isTag(element, type) { 55 | return element.tagName.toLowerCase() == type.toLowerCase() 56 | } 57 | 58 | var clicked_button = null, timer 59 | 60 | //remember which button was clicked, or handle the click if it was a link. 61 | window.addEventListener('click',function (ev) { 62 | //need this hack, because onsubmit event can't tell you what button was pressed. 63 | 64 | if(isTag(ev.target, 'button') || isTag(ev.target, 'input') && ev.target.type == 'submit') { 65 | clicked_button = ev.target 66 | clearTimeout(timer) 67 | timer = setTimeout(function () { 68 | clicked_button = null 69 | },0) 70 | } 71 | //if we have a target for a link click, apply that element. 72 | //unless ctrl is held down, which would open a new tab 73 | else if(!ev.ctrlKey && isTag(ev.target, 'a') && ev.target.dataset[names.Update]) { 74 | var update = document.getElementById(ev.target.dataset[names.Update]) 75 | if(!update) return 76 | ev.preventDefault() 77 | 78 | //use getAttribute instead of ev.target.href because then it will just be / and not have 79 | //http:...com/... 80 | var href = (ev.target.dataset[names.OpenHref] || ev.target.getAttribute('href')) 81 | if(href) 82 | xhr('/' + names.Partial + href, function (err, content) { 83 | if(err) console.error(err) //TODO: what to do with error? 84 | else morph(update, content) 85 | }) 86 | } 87 | }) 88 | 89 | //handle form submit 90 | window.addEventListener('submit', function (ev) { 91 | var form = ev.target 92 | if(form.dataset[names.Update] || form.dataset[names.Invalidate] || form.dataset[names.Reset]) { 93 | ev.preventDefault() 94 | forms.submit(form, clicked_button, function (err, content) { 95 | //what to do with error? 96 | if(form.dataset[names.Invalidate]) 97 | update(form.dataset[names.Invalidate]) 98 | if(form.dataset[names.Update]) { 99 | var target = document.getElementById(form.dataset[names.Update]) 100 | morph(target, content) 101 | } 102 | if(form.dataset[names.Reset]) 103 | form.reset() 104 | }) 105 | } 106 | }) 107 | 108 | // --- checking for and applying updates ------ 109 | 110 | function delay (errors) { 111 | return Math.max(Math.pow(2, errors || 0), 128) * 1e3 112 | } 113 | 114 | function schedule (delay) { 115 | clearTimeout(timer) 116 | delay = delay || 1e2 117 | timer = setTimeout(function () { 118 | if(!onScreen) return //don't check if the user isn't looking! 119 | console.log('check again', onScreen, document.visibilityState) 120 | check(since) 121 | }, delay/2 + delay*Math.random()) 122 | } 123 | 124 | function scan () { 125 | if(since) throw new Error('only scan once!') 126 | since = Infinity 127 | ;[].forEach.call( 128 | document.querySelectorAll('[data-'+names.Timestamp+']'), 129 | function (el) { 130 | cache[el.dataset[names.Identity]] = 131 | since = isNaN(+el.dataset[names.Timestamp]) ? since : Math.min(since, +el.dataset[names.Timestamp]) 132 | }) 133 | 134 | //skip checking if there were no updatable elements found 135 | if(since != Infinity) check(since) 136 | else console.error('coherence: no updatable elements found') 137 | } 138 | 139 | // call the cache server, and see if there has been any updates 140 | // since this page was rendered. 141 | var errors = 0 142 | function check (_since) { 143 | if(_since == undefined) throw new Error('undefined: since') 144 | if(checking) return 145 | checking = true 146 | xhr('/' + names.Coherence + '/' + names.Cache + '?since='+_since, function (err, data) { 147 | checking = false 148 | if(err) { 149 | errors ++ 150 | console.error('error while checking cache, server maybe down?') 151 | console.error(err) 152 | return schedule(delay(errors)) 153 | } 154 | var response, start, ids 155 | try { response = JSON.parse(data) } catch(_) { 156 | errors ++ 157 | return schedule(delay(errors)) 158 | } 159 | ids = response && response.ids 160 | start = response && response.start 161 | errors = 0 162 | if(ids && 'object' === typeof ids) { 163 | var ary = [] 164 | for(var k in ids) { 165 | since = Math.max(since, ids[k] || 0) 166 | ary.push(k) 167 | } 168 | } 169 | since = Math.max(since, start || 0) 170 | 171 | for(var k in cache) { 172 | if(start > cache[k]) 173 | ary.push(k) 174 | } 175 | 176 | if(Array.isArray(ary) && ary.length) ary.forEach(update) 177 | if(!inflight) schedule() 178 | }) 179 | } 180 | 181 | function mutate (el, content) { 182 | // update an element with new content, using morphdom. 183 | 184 | // some node types cannot just simply be created anywhere. 185 | // (such as tbody, can only be inside a table) 186 | // if you just call morph(el, content) it becomes a flattened 187 | // string. 188 | // so, create the same node type as the parent. 189 | // (this will break if you try to update to a different node type) 190 | // 191 | // DocumentFragment looked promising here, but document 192 | // fragment does not have innerHTML! you can only 193 | // use it manually! (I guess I could send the html 194 | // encoded as json... but that wouldn't be as light weight) 195 | if(content) { 196 | var fakeParent = document.createElement(el.parentNode.tagName) 197 | fakeParent.innerHTML = content 198 | morph(el, fakeParent.firstChild) 199 | //sometimes, we want to send more than one tag. 200 | //so that the main tag is updated then some more are appended. 201 | //do this via a document-fragment, which means only 202 | //one reflow (faster layout). 203 | if(fakeParent.children.length > 1) { 204 | var df = document.createDocumentFragment() 205 | for(var i = 1; i< fakeParent.children.length; i++) 206 | df.appendChild(fakeParent.children[i]) 207 | 208 | if(el.nextSibling) 209 | el.parentNode.insertBefore(df, el.nextSibling) 210 | else 211 | el.parentNode.appendChild(df) 212 | } 213 | } else { 214 | //if the replacement is empty, remove el. 215 | el.parentNode.removeChild(el) 216 | } 217 | } 218 | 219 | function update (id) { 220 | console.log('update:'+id) 221 | var el = document.querySelector('[data-'+names.Identity+'='+JSON.stringify(id)+']') 222 | if(!el) { 223 | console.log('could not find id:'+id) 224 | return 225 | //xxxxxxxx 226 | } 227 | //href to update this element 228 | var href = el.dataset[names.PartialHref] 229 | if(href) { 230 | inflight ++ 231 | xhr('/'+names.Partial+href, function (err, content) { 232 | if(!err) mutate(el, content) 233 | //check again in one second 234 | if(--inflight) return 235 | schedule() 236 | }) 237 | } 238 | else { 239 | console.error('cannot update element, missing data-'+names.PartialHref+' attribute') 240 | } 241 | } 242 | 243 | -------------------------------------------------------------------------------- /example/chat/db.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var fs = require('fs') 3 | 4 | module.exports = function (filename, cb) { 5 | var db, history = [] 6 | fs.readFile(filename, 'utf8', function (_, str) { 7 | history = (str || '').split('\n').filter(Boolean).map(function (e) { 8 | //it's theoritically possible that two appends happen 9 | //happen at once and that might cause something invalid so 10 | //do parse inside of try. 11 | try { return JSON.parse(e) } 12 | catch (ignore) { } 13 | }) 14 | if(!history.length) 15 | history.push({ 16 | ts: Date.now(), 17 | author: 'coherence-bot', 18 | text: 'welcome to coherence chat example' 19 | }) 20 | 21 | cb(null, db) 22 | }) 23 | 24 | return db = { 25 | array: function () { return history }, 26 | append: function (data, cb) { 27 | fs.appendFile(filename, JSON.stringify(data)+ '\n', 'utf8', function (err) { 28 | history.push(data) 29 | console.log('append', history) 30 | cb() 31 | }) 32 | }, 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /example/chat/index.js: -------------------------------------------------------------------------------- 1 | var Coherence = require('../..') 2 | var http = require('http') 3 | var QS = require('querystring') 4 | var ago = require('nice-ago') 5 | var dir = process.argv[2] || process.cwd() 6 | var path = require('path') 7 | var cont = require('cont') 8 | var Stack = require('stack') 9 | var BodyParser = require('urlencoded-request-parser') 10 | var Static = require('ecstatic') 11 | 12 | //chat history in lines in this file. 13 | //in practice you'd probably have some sort of database. 14 | var db = require('./db')( 15 | '/tmp/coherence-example-chat.txt', 16 | function () { 17 | coherence.invalidate('latest', Date.now()) 18 | }) 19 | 20 | //render layout. this wraps every full page. 21 | //this is the place to include menu items, etc... 22 | var coherence = Coherence(function (opts, content) { 23 | return ['html', 24 | ['head', 25 | ['meta', {charset: 'UTF-8'}], 26 | ['script', {src: coherence.scriptUrl}], 27 | ['link', {rel: 'stylesheet', href: '/static/style.css'}] 28 | ], 29 | ['body', 30 | ['div.header', 31 | ['div.heading', 'coherence chat'], 32 | ['a', {href: '/setup', 'data-update': 'modal'}, 'setup'], 33 | ], 34 | ['div.page', content], 35 | ['div#modal'] 36 | ] 37 | ] 38 | }) 39 | //the body of the chat. a list of messages. 40 | //the special part is `div.latest` which is where future messages get inserted. 41 | .use('messages', function (opts, apply) { 42 | var start = opts.start | 0 43 | var end = opts.end || db.array().length 44 | return [ 45 | ['div.messages'] 46 | .concat( 47 | db.array() 48 | .slice(opts.start, end) 49 | .map(function (e) { 50 | var date = new Date(e.ts) 51 | return ['div.message', 52 | ['div.meta', 53 | ['label.author', e.author || 'anonymous'], 54 | ' ', 55 | ['label.time', {title: date.toString()}, date.getHours()+':'+date.getMinutes()], 56 | ], 57 | ['div', e.text] 58 | ] 59 | }) 60 | ), 61 | // Empty partial at the end. when this is invalidated, 62 | // new messages will be inserted here. 63 | // Always has the same id, but when it's loaded 64 | // it will be replaced div.messages without an id. 65 | // so it will only be replaced once. 66 | ['div.latest#latest', 67 | apply.cacheAttrs('/messages?start='+end, 'latest', Date.now()) 68 | //{ 69 | // 'data-id': 'latest', 70 | // 'data-href':'/messages?start='+end, 71 | // 'data-ts': Date.now() 72 | // } 73 | ] 74 | ] 75 | }) 76 | //wrapper around messages view that includes a form for entering text. 77 | .use('chat', function (opts, apply) { 78 | var start = Math.max(opts.start || opts.end - 100, 0) 79 | var end = opts.end || db.array().length 80 | return [ 81 | 'div.page', 82 | ['div.chat', apply('messages', {start: start, end: end})], 83 | ['form', { 84 | method: 'POST', 85 | //disables suggestions. only care about text input, 86 | //but that's the only form field. 87 | autocomplete: 'off', 88 | 'data-invalidate': 'latest', 89 | 'data-reset': 'true', 90 | }, 91 | ['input', {type: 'text', name: 'text'}], 92 | ['button', 'submit'] 93 | ] 94 | ] 95 | }) 96 | .use('setup', function (opts, apply, req) { 97 | return [ 98 | 'div.modal', 99 | ['form', { 100 | method: 'POST', 101 | autocomplete: 'off', 102 | }, 103 | ['input', {type: 'text', name: "name", value: req.context.name || 'anonymous'}], 104 | ['input', {type: 'hidden', name: 'type', value: 'setup'}], 105 | ['button', 'submit'] 106 | ] 107 | ] 108 | }) 109 | .setDefault('chat') 110 | 111 | http.createServer(Stack( 112 | function (req, res, next) { 113 | req.context = QS.parse(req.headers.cookie||'') || {} 114 | next() 115 | }, 116 | Static({ 117 | root: path.join(__dirname, 'static'), 118 | baseDir: '/static' 119 | }), 120 | BodyParser(), 121 | function (req, res, next) { 122 | console.log('BODY', req.body) 123 | function redirect () { 124 | //redirect to get so this still works http only app, without javascript 125 | //(although you have to reload to get new messages) 126 | res.setHeader('location', '/chat?cache='+Date.now()+'#latest') 127 | res.statusCode = 303 128 | return res.end('') 129 | } 130 | 131 | if(req.method == 'POST') { 132 | if(req.body.type === 'setup') { 133 | res.setHeader('set-cookie', QS.stringify(req.body)) 134 | redirect() 135 | } 136 | else { 137 | var ts = Date.now() 138 | db.append({ 139 | ts: ts, author: req.context.name, text: req.body.text 140 | }, function () { 141 | coherence.invalidate('latest', ts) 142 | redirect() 143 | }) 144 | } 145 | } 146 | else 147 | next() 148 | }, 149 | coherence 150 | )).listen(3000, function () { 151 | console.error('http://localhost:3000') 152 | }) 153 | 154 | -------------------------------------------------------------------------------- /example/chat/static/style.css: -------------------------------------------------------------------------------- 1 | 2 | .chat { 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | .message { 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .message.meta { 12 | display: flex; 13 | flex-direction: row; 14 | } 15 | 16 | .author { 17 | font-weight: bold; 18 | } 19 | 20 | .time { 21 | font-style: italic; 22 | color: lightblue; 23 | } 24 | 25 | .header { 26 | display: flex; 27 | flex-direction: row; 28 | justify-content: space-between; 29 | } 30 | 31 | .heading { 32 | font-style: bold; 33 | font-size: 20pt; 34 | color: darkblue; 35 | } 36 | 37 | .modal { 38 | content: 'BACKGROUND'; 39 | position: fixed; 40 | top: 0px; 41 | bottom: 1px; 42 | left: 0px; 43 | right: 0px; 44 | background: hsla(0, 0%, 0%, 20%); 45 | z-index: 10; 46 | } 47 | 48 | 49 | .modal>*:first-child { 50 | /* 51 | center vertically and horizontally when you do not 52 | know the size of the element 53 | https://css-tricks.com/centering-css-complete-guide/ 54 | */ 55 | position: absolute; 56 | top: 50%; 57 | left: 50%; 58 | transform: translate(-50%, -50%); 59 | 60 | width: 60%; 61 | margin-left: auto; 62 | margin-right: auto; 63 | border: solid 1px darkblue; 64 | padding: 50px; 65 | z-index: 100; 66 | background: white; 67 | } 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /example/clock/index.js: -------------------------------------------------------------------------------- 1 | var http = require('http') 2 | var Stack = require('stack') 3 | var Coherence = require('../..') 4 | 5 | var coherence = Coherence(function (opts, content) { 6 | return ['html', 7 | ['head', 8 | ['meta', {charset:'utf8'}], 9 | //this script must be loaded on front end! 10 | ['script', {src: coherence.scriptUrl}], 11 | ], 12 | ['body', content] 13 | ] 14 | }) 15 | .use('clock', function (opts, apply) { 16 | var time = new Date() 17 | return ['h1', apply.cacheAttrs('/clock', 'clock', +time), time.toString()] 18 | }) 19 | .setDefault('clock') 20 | 21 | setInterval(function () { 22 | coherence.invalidate('clock', Date.now()) 23 | }, 1000) 24 | 25 | http.createServer(coherence).listen(3000, function () { 26 | console.error('http://localhost:3000') 27 | }) 28 | 29 | -------------------------------------------------------------------------------- /example/fs/files/new_file: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:35:19 NZDT 2018 2 | -------------------------------------------------------------------------------- /example/fs/files/new_file.txt: -------------------------------------------------------------------------------- 1 | hello 2 | Thu Dec 20 21:27:11 NZDT 2018 3 | Thu Dec 20 21:27:15 NZDT 2018 4 | Thu Dec 20 21:27:20 NZDT 2018 5 | Thu Dec 20 21:34:11 NZDT 2018 6 | Thu Dec 20 21:35:23 NZDT 2018 7 | -------------------------------------------------------------------------------- /example/fs/files/new_file2.txt: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:34:16 NZDT 2018 2 | Thu Dec 20 21:34:23 NZDT 2018 3 | -------------------------------------------------------------------------------- /example/fs/files/new_file3.txt: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:35:39 NZDT 2018 2 | -------------------------------------------------------------------------------- /example/fs/files/new_file4.txt: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:36:51 NZDT 2018 2 | Thu Dec 20 21:37:10 NZDT 2018 3 | Thu Dec 20 21:37:20 NZDT 2018 4 | -------------------------------------------------------------------------------- /example/fs/files/new_file5.txt: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:37:35 NZDT 2018 2 | -------------------------------------------------------------------------------- /example/fs/files/new_file6.txt: -------------------------------------------------------------------------------- 1 | Thu Dec 20 21:38:52 NZDT 2018 2 | -------------------------------------------------------------------------------- /example/fs/index.js: -------------------------------------------------------------------------------- 1 | var Coherence = require('../..') 2 | var http = require('http') 3 | var QS = require('querystring') 4 | var ago = require('nice-ago') 5 | var fs = require('fs') 6 | var dir = process.argv[2] || process.cwd() 7 | var path = require('path') 8 | var watch = require('watch').watchTree 9 | var cont = require('cont') 10 | var Stack = require('stack') 11 | var ecstatic = require('ecstatic') 12 | var cp = require('child_process') 13 | 14 | var root = path.resolve(process.cwd(), process.argv[2] || '.') 15 | 16 | var coherence = Coherence(function (opts, content) { 17 | return ['html', 18 | ['head', 19 | ['meta', {charset: 'UTF-8'}], 20 | ['script', {src: coherence.scriptUrl}], 21 | ['link', {rel: 'stylesheet', href: '/static/style.css'}] 22 | ], 23 | ['body', content] 24 | ] 25 | }) 26 | .use('tree', function (opts, apply) { 27 | return ['div.tree', apply('file', {file: opts.file || '.'}) ] 28 | }) 29 | .use('read', function (opts, apply) { 30 | return ['pre.file__raw', cont.to(fs.readFile)(opts.file, 'utf8')] 31 | }) 32 | .use('edit', function (opts, apply) { 33 | return ['form', {method: 'post'}, 34 | ['input', {type:'hidden', value: opts.file, name: 'file'}], 35 | ['textarea.file__raw', {name: 'content'}, 36 | cont.to(fs.readFile)(opts.file, 'utf8') 37 | ], 38 | ['button', 'save'], 39 | ] 40 | }) 41 | .use('file', render_file) 42 | 43 | watch(dir, { 44 | }, function (filename, newStat, oldStat) { 45 | if('object' === typeof filename) return 46 | var ts = Date.now() 47 | //interpret new files and deletes as updates to the directory. 48 | if(!oldStat || newStat.nLink === 0) { 49 | console.log("UPDATE", filename) 50 | filename = path.dirname(filename) 51 | console.log("UPDATE", filename) 52 | } 53 | var id = path.relative(root, filename) || '.' 54 | coherence.invalidate(id, ts) 55 | }) 56 | 57 | function render_file(opts, apply) { 58 | if(!opts.file) throw new Error('file must be provided') 59 | var file = opts.file || '.' 60 | return function (cb) { 61 | fs.stat(path.resolve(root, file), function (err, stat) { 62 | if(err) return cb(err) 63 | var attrs = {'data-href':apply.toUrl('file', opts), 'data-id':file, 'data-ts':apply.since} 64 | 65 | if(stat.isDirectory()) 66 | fs.readdir(path.resolve(root, file), function (err, ls) { 67 | if(err) return cb(err) 68 | cb(null, ['div.file', attrs, 69 | ['div.file__meta', 70 | ['div.file__name', {title: path.join(dir, file)}, 71 | ['a', 72 | {href: apply.toUrl('tree', {file: file})}, 73 | file 74 | ]], 75 | ['div.file__size'], 76 | ['div.file__mtime', stat.mtimeMs], 77 | ], 78 | ['div.dir'].concat(ls.map(function (_file) { 79 | return apply('file', {file: path.join(file, _file)}) 80 | })) 81 | ]) 82 | }) 83 | else 84 | cb(null, ['div.file', attrs, 85 | ['div.file__meta', 86 | ['div.file__name', 87 | ['a', 88 | {href: apply.toUrl('read', {file: file})}, 89 | file 90 | ] 91 | ], 92 | ['div.file__size', ''+stat.size], 93 | ['div.file__mtime', 94 | {title: new Date(stat.mtimeMs).toString()}, 95 | stat.mtimeMs 96 | ] 97 | ] 98 | ]) 99 | }) 100 | } 101 | } 102 | 103 | http.createServer(Stack( 104 | function (req, res, next) { 105 | if(req.method === 'POST') { 106 | console.log("POST", req) 107 | var body = '' 108 | req.on('data', function (d) { 109 | body += d 110 | }) 111 | req.on('end', function (d) { 112 | req.body = QS.parse(body) 113 | console.log("BODY", req.body) 114 | next() 115 | }) 116 | } 117 | else 118 | next() 119 | }, 120 | function (req, res, next) { 121 | var body = req.body 122 | if(req.method === "POST" && body.file && body.content) { 123 | return fs.writeFile( 124 | body.file, 125 | body.content.split('\r').join(''), 126 | function (err) { 127 | if(err) next(err) 128 | else next() 129 | } 130 | ) 131 | } 132 | next() 133 | }, 134 | function (req, res, next) { 135 | if(/^\/static\//.test(req.url)) 136 | fs.createReadStream(__dirname+req.url).pipe(res) 137 | else next() 138 | }, 139 | coherence 140 | )).listen(8010) 141 | 142 | 143 | var ticker = cp.spawn('bash', ['-c', 'while true; do date; sleep 1; done']) 144 | var output = '' 145 | ticker.stdout.on('data', function (d) { 146 | output += d 147 | var ts = Date.now() 148 | console.log('invalidate', 'ticker', ts) 149 | coherence.invalidate('ticker', ts) 150 | }) 151 | 152 | coherence.use('ticker', function (opts, apply) { 153 | var start = opts.start || 0 154 | var tail = opts.end == null 155 | var end = opts.end == null ? output.length : opts.end 156 | return [ 157 | ['pre.stdout', output.substring(start, end)], 158 | tail ? ['pre.stdout', apply.cacheAttrs(apply.toUrl('ticker', {start: end}), 'ticker') 159 | ] : '' 160 | ] 161 | }) 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /example/fs/static/style.css: -------------------------------------------------------------------------------- 1 | .tree { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | .file__meta { 6 | display: flex; 7 | flex-direction: row; 8 | } 9 | .file__name { 10 | padding: 5px; 11 | width: 350px; 12 | overflow: hidden; 13 | } 14 | .file__size { 15 | width: 50px; 16 | text-align: right; 17 | padding: 5px; 18 | background: peachpuff; 19 | } 20 | .file__mtime { 21 | width: 100px; 22 | text-align: right; 23 | padding: 5px; 24 | } 25 | .dir { 26 | display: flex; 27 | flex-direction: column; 28 | border: 1px solid red; 29 | margin: 5px; 30 | } 31 | 32 | textarea { 33 | width: 100%; 34 | height: 95%; 35 | min-height: 0%; 36 | } 37 | button { 38 | height: 5%; 39 | } 40 | form { 41 | margin: 0px; 42 | } 43 | body { 44 | margin: 0px; 45 | } 46 | 47 | pre.stdout { 48 | display: inline; 49 | } 50 | -------------------------------------------------------------------------------- /example/tabs/index.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | var http = require('http') 4 | var Stack = require('stack') 5 | var Coherence = require('../..') 6 | 7 | function tabLink (href) { 8 | var attrs = {href: href} 9 | attrs['data-'+names.Update] = 'target' 10 | return ['a', attrs, name] 11 | } 12 | 13 | var coherence = Coherence(function (opts, content) { 14 | return ['html', 15 | ['head', 16 | ['meta', {charset:'utf8'}], 17 | //this script must be loaded on front end! 18 | ['script', {src: coherence.scriptUrl}], 19 | ], 20 | ['body', 21 | ['nav', 22 | ['ul', 23 | ['li', link('/page?number=1', 'one')], 24 | ['li', link('/page?number=2', 'two')], 25 | ['li', link('/page?number=3', 'three')] 26 | ] 27 | ], 28 | content 29 | ] 30 | ] 31 | }) 32 | .use('page', function (opts) { 33 | return ['h1#target', 'Number:', opts.number || 0] 34 | }) 35 | .setDefault('page') 36 | 37 | http.createServer(coherence).listen(3000, function () { 38 | console.error('http://localhost:3000') 39 | }) 40 | 41 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var URL = require('url') 2 | var QS = require('qs') 3 | var fs = require('fs') 4 | var script = fs.readFileSync(__dirname+'/browser.js', 'utf8') 5 | var path = require('path') 6 | var Cache = require('./cache') 7 | var H = require('../h') 8 | 9 | var u = require('./util') 10 | var names = require('./names') 11 | 12 | function path2Array (path) { 13 | if(Array.isArray(path)) return path 14 | return (path[0] == '/' ? path : '/' + path).split('/').slice(1) 15 | } 16 | 17 | function toPath(path) { 18 | return '/' + path2Array(path).join('/') 19 | } 20 | 21 | var doctype = '' 22 | 23 | function get(obj, path) { 24 | return obj[toPath(path)] 25 | } 26 | function set(obj, path, value) { 27 | if('string' == typeof path) 28 | path = path2Array(path) 29 | return obj['/' + path.join('/')] = value 30 | } 31 | 32 | function Render(layout) { 33 | var renderers = {} 34 | var cache = Cache() 35 | 36 | function render (req, res, next) { 37 | function apply (path, opts) { 38 | var fn = get(renderers, path2Array(path)) 39 | if(!fn) { 40 | throw new Error('no renderer at:'+path) 41 | } 42 | return fn(opts, apply, req) 43 | } 44 | 45 | apply.scriptUrl = render.scriptUrl 46 | apply.toUrl = function (path, opts) { 47 | return '/' + path + (opts ? '?' + QS.stringify(opts) : '') 48 | } 49 | 50 | apply.cacheAttrs = function (href, id, ts) { 51 | return { 52 | 'data-href': href, 53 | 'data-id': id, 54 | 'data-ts': ts || apply.since 55 | } 56 | } 57 | 58 | //if used in stack/connect/express this will be defined already. 59 | next = next || function (err) { 60 | if(!err) err = new Error('not found') 61 | if(err) { 62 | res.statusCode = 500 63 | res.end(err.stack) 64 | } 65 | else { 66 | res.statusCode = 404 67 | res.end('not found') 68 | } 69 | } 70 | 71 | var fn 72 | var url = URL.parse(req.url) 73 | var paths = path2Array(url.pathname) 74 | var opts = QS.parse(url.query) 75 | 76 | //check the cache to see if anything has updated. 77 | if(paths[0] === names.Coherence && paths[1] == names.Cache) { 78 | return cache(opts, function (err, data) { 79 | res.setHeader('Content-Type', 'application/json') 80 | res.statusCode = 200 81 | res.end(JSON.stringify(data)) 82 | }) 83 | } 84 | else 85 | if(req.url === render.scriptUrl) { 86 | res.statusCode = 200 87 | res.setHeader('Content-Type', 'application/javascript') 88 | return res.end(script, 'utf8') 89 | } 90 | //if prefixed with /partial/... then render without the layout (no, headder, nav, etc) 91 | else { 92 | res.statusCode = 200 93 | var useDocType = false 94 | if(paths[0] === names.Partial) { 95 | res.setHeader('Content-Type', 'text/plain') 96 | useDocType = false 97 | fn = get(renderers, paths.slice(1)) 98 | if(!fn) return next(new Error('not found:'+paths)) 99 | val = fn(opts, apply, req) 100 | } 101 | else { 102 | useDocType = true 103 | var fn = get(renderers, paths) 104 | res.setHeader('Content-Type', 'text/html') 105 | if(!fn) return next(new Error('not found:'+paths)) 106 | val = layout(opts, fn(opts, apply, req), apply, req) 107 | } 108 | 109 | u.toHTML(val)(function (err, result) { 110 | if(err) next(err) 111 | else res.end((useDocType ? doctype : '') + H(result)) 112 | }) 113 | } 114 | } 115 | 116 | render.since = Date.now() 117 | 118 | render.use = function (path, fn) { 119 | set(renderers, path2Array(path), fn) 120 | return render 121 | } 122 | 123 | function concat() { 124 | return [].concat.apply([], [].map.call(arguments, path2Array)) 125 | } 126 | 127 | render.group = function (group_path, fn) { 128 | function use (_path, fn) { 129 | set(renderers, concat(group_path, _path), fn) 130 | } 131 | //`{system_path}/{key}` -> `{group_path}/{to}` 132 | use.map = function (_path, key, to) { 133 | render.map(_path, key, concat(group_path, to)) 134 | return use 135 | } 136 | 137 | use.list = function (path, to) { 138 | return render.list(path, concat(group_path, to)) 139 | } 140 | 141 | fn(use) 142 | return render 143 | } 144 | 145 | render.map = function (path, key, to) { 146 | var _path = concat(path, key) 147 | function map (opts, apply, req) { 148 | return get(renderers, to)(opts, apply, req) 149 | } 150 | set(renderers, _path, map) 151 | map.target = to 152 | return render 153 | } 154 | 155 | render.list = function (path, to) { 156 | var list = get(renderers, path) 157 | if(!to && list) 158 | throw new Error('list:'+path+' is already initialized') 159 | else if(to && !list) 160 | throw new Error('list:'+path+' is not yet initialized') 161 | 162 | if(!list) { 163 | var ary = [] 164 | list = function (opts, apply, req) { 165 | return ary.map(function (to) { 166 | var mapped = get(renderers, to) 167 | if(!mapped) return ['div.Error', 'no renderer:', to] 168 | return mapped(opts, apply, req) 169 | }) 170 | } 171 | list.list = ary 172 | set(renderers, path, list) 173 | } 174 | else 175 | get(renderers, path).list.push(to) 176 | return render 177 | } 178 | 179 | render.setDefault = function (path) { 180 | var a = path2Array(path) 181 | set(renderers, [], function (opts, apply, req) { 182 | return get(renderers, a)(opts, apply, req) 183 | }) 184 | return render 185 | } 186 | 187 | render.invalidate = function (key, ts) { 188 | return render.since = cache.invalidate(key, ts) 189 | } 190 | 191 | //invalidate all cache records, this makes the frontend reload everything 192 | render.invalidateAll = function () { 193 | return render.since = cache.invalidateAll() 194 | } 195 | 196 | render.scriptUrl = '/'+names.Coherence + '/' + names.Script + '.js' 197 | 198 | render.dump = function () { 199 | var o = {} 200 | for(var k in renderers) { 201 | if(Array.isArray(renderers[k].list)) 202 | o[k] = renderers[k].list.map(toPath) 203 | else 204 | o[k] = renderers[k].target ? toPath(renderers[k].target) : true 205 | } 206 | 207 | return o 208 | } 209 | 210 | return render 211 | } 212 | 213 | module.exports = Render 214 | -------------------------------------------------------------------------------- /names.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | module.exports = { 4 | //get /partial/..url to load url without layout. 5 | Partial: 'partial', 6 | Coherence: 'coherence', //url at which coherence specific things are under 7 | Script: 'browser', //name of the script which keeps ui up to date. 8 | Cache: 'cache', //query the cache state 9 | 10 | //the following are all set as data-* attributes 11 | 12 | Update: 'update', 13 | 14 | OpenHref: 'href', 15 | UpdateHref: 'href', 16 | PartialHref: 'href', 17 | 18 | Invalidate: 'invalidate', 19 | Reset: 'reset', 20 | Timestamp: 'ts', 21 | Identity: 'id' 22 | } 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coherence-framework", 3 | "description": "realtime framework based on cache coherence", 4 | "version": "1.5.1", 5 | "homepage": "https://github.com/dominictarr/coherence", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/dominictarr/coherence.git" 9 | }, 10 | "dependencies": { 11 | "h": "1", 12 | "libnested": "^1.4.1", 13 | "nanomorph": "^5.4.0", 14 | "pull-paramap": "^1.2.2", 15 | "pull-stream": "^3.6.9", 16 | "qs": "^6.6.0", 17 | "submit-form-element": "^1.0.0", 18 | "cont": "^1.0.3", 19 | "stack": "^0.1.0" 20 | }, 21 | "devDependencies": { 22 | "brfs": "^2.0.2", 23 | "browserify": "^16.2.3", 24 | "ecstatic": "^3.3.1", 25 | "querystring": "^0.2.0", 26 | "urlencoded-request-parser": "^1.0.1" 27 | }, 28 | "browserify": { 29 | "transform": [ 30 | "brfs" 31 | ] 32 | }, 33 | "scripts": { 34 | "build": "browserify check.js > browser.js", 35 | "test": "set -e; for t in test/*.js; do node $t; done" 36 | }, 37 | "author": "Dominic Tarr (http://dominictarr.com)", 38 | "license": "MIT" 39 | } 40 | -------------------------------------------------------------------------------- /util.js: -------------------------------------------------------------------------------- 1 | var URL = require('url') 2 | var QS = require('querystring') 3 | var H = require('h') 4 | var cpara = require('cont').para 5 | var pull = require('pull-stream/pull') 6 | var Collect = require('pull-stream/sinks/collect') 7 | var paramap = require('pull-paramap') 8 | var nested = require('libnested') 9 | 10 | function isFunction (f) { 11 | return 'function' === typeof f 12 | } 13 | 14 | var isArray = Array.isArray 15 | 16 | function isEmpty (e) { 17 | for(var k in e) return false 18 | return true 19 | } 20 | 21 | function isString (s) { 22 | return 'string' === typeof s 23 | } 24 | 25 | exports.toUrl = function toUrl(path, opts) { 26 | return '/'+( 27 | Array.isArray(path) ? path.join('/') : ''+path 28 | ) + ( 29 | !isEmpty(opts) ? '?'+QS.encode(opts) : '' 30 | ) 31 | } 32 | 33 | function toCont (f) { 34 | if(f.length === 1) return function (cb) { 35 | f(function (err, hs) { 36 | exports.toHTML(hs)(cb) 37 | }) 38 | } 39 | else if(f.length === 2) 40 | return function (cb) { 41 | pull( 42 | f, 43 | paramap(function (e, cb) { 44 | exports.toHTML(e)(cb) 45 | }, 32), 46 | Collect(cb) 47 | ) 48 | } 49 | } 50 | 51 | function flatten (a) { 52 | var _a = [] 53 | for(var i = 0; i < a.length; i++) 54 | if(isArray(a[i]) && !isString(a[i][0])) 55 | _a = _a.concat(flatten(a[i])) 56 | else 57 | _a.push(a[i]) 58 | return _a 59 | } 60 | 61 | 62 | //even better would be streaming html, 63 | //not just into arrays. 64 | var k = 0 65 | 66 | function toHTML (hs) { 67 | return function (cb) { 68 | if(!isFunction(cb)) throw new Error('cb must be a function, was:'+cb) 69 | var called = false 70 | var C = ( 71 | isFunction(hs) ? toCont(hs) 72 | : isArray(hs) ? cpara(hs.map(toHTML)) 73 | : function (cb) { 74 | if(!called) { 75 | called = true 76 | cb(null, hs) 77 | } 78 | else 79 | throw new Error('called twice') 80 | } 81 | ) 82 | 83 | C(function (err, val) { 84 | if(err) cb(err) 85 | else if(isArray(val) && isString(val[0])) { 86 | cb(null, flatten(val)) 87 | } else 88 | cb(null, val) 89 | }) 90 | } 91 | } 92 | 93 | exports.toHTML = toHTML 94 | 95 | exports.createHiddenInputs = function createHiddenInputs (meta, _path) { 96 | _path = _path ? [].concat(_path) : [] 97 | var hidden = [] 98 | nested.each(meta, function (value, path) { 99 | if(value !== undefined) 100 | hidden.push(['input', { 101 | name: _path.concat(path).map(function (e) { return '['+e+']' }).join(''), 102 | value: value, 103 | type: 'hidden' 104 | }]) 105 | }, true) 106 | return hidden 107 | } 108 | --------------------------------------------------------------------------------