├── .gitignore
├── .npmignore
├── .travis.yml
├── LICENSE
├── Makefile
├── README.md
├── examples
├── clock.coffee
├── public
│ ├── create.html
│ ├── img
│ │ └── jsoneditor-icons.png
│ ├── index.html
│ ├── json.html
│ ├── json_list.html
│ ├── jsoneditor-min.css
│ ├── jsoneditor-min.js
│ ├── jsoneditor.js
│ ├── jsoneditor.min.css
│ ├── list.html
│ ├── multi.html
│ ├── react.html
│ └── ws.html
├── server.coffee
└── ws.coffee
├── lib
├── client
│ ├── connection.js
│ ├── doc.js
│ ├── emitter.js
│ ├── index.js
│ ├── query.js
│ └── textarea.js
├── index.js
├── server
│ ├── index.js
│ ├── rest.js
│ ├── session.js
│ └── useragent.js
└── types
│ ├── README.md
│ ├── index.js
│ ├── json-api.js
│ ├── text-api.js
│ └── text-tp2-api.js
├── metadata.md
├── package.json
└── test
├── browser
├── connection.coffee
├── doc.coffee
├── queries.coffee
└── subscribed.coffee
├── helpers
├── fixtures.coffee
├── index.coffee
├── mersenne.js
├── ot_number.js
├── phantom.coffee
├── server.coffee
├── socket.coffee
└── webclient.coffee
├── mocha.opts
└── server
├── connection.coffee
├── doc.coffee
├── integration.coffee
├── json-api.coffee
├── middleware.coffee
├── query.coffee
├── rest.coffee
├── session.coffee
├── testhelpers.coffee
├── text-api.coffee
└── useragent.coffee
/.gitignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | .DS_Store
3 | node_modules
4 | dist/*
5 | coverage
6 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | *.swp
2 | .DS_Store
3 | node_modules
4 | coverage
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 |
3 | node_js:
4 | - 0.10
5 |
6 | services:
7 | - redis-server
8 |
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Licensed under the standard MIT license:
2 |
3 | Copyright 2011-2014 Joseph Gentle.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: all test clean webclient
2 |
3 | UGLIFY = node_modules/.bin/uglifyjs -d WEB=true
4 | BROWSERIFY = node_modules/.bin/browserify
5 |
6 | all: build minify
7 |
8 | build:
9 | mkdir -p dist
10 | $(BROWSERIFY) -s sharejs lib/client/index.js -o dist/share.js
11 |
12 | minify:
13 | $(UGLIFY) -cm --lint dist/share.js > dist/share.min.js
14 |
15 | clean:
16 | rm -rf dist/*
17 |
--------------------------------------------------------------------------------
/examples/clock.coffee:
--------------------------------------------------------------------------------
1 |
2 | module.exports = (args...) ->
3 | # Make SOLR thing.
4 |
5 | name: 'solr'
6 |
7 | submit: (cName, docName, opData, snapshot, callback) ->
8 | console.log "set snapshot for #{cName} to ", snapshot
9 | callback()
10 |
11 | query: (cName, query, callback) ->
12 | console.log 'running query'
13 | callback null, results:[], extra:(new Date()).getSeconds()
14 |
--------------------------------------------------------------------------------
/examples/public/create.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
47 |
--------------------------------------------------------------------------------
/examples/public/img/jsoneditor-icons.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/josephg/ShareJS/414e1d6a4e0d3f06a83bfe001bc81f2cd6891475/examples/public/img/jsoneditor-icons.png
--------------------------------------------------------------------------------
/examples/public/index.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
33 |
--------------------------------------------------------------------------------
/examples/public/json.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
83 |
84 |
JSON Client API example (check it out in source, and on the wiki)
85 |
--------------------------------------------------------------------------------
/examples/public/json_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
101 |
102 | Reorder the trains
103 |
104 |
--------------------------------------------------------------------------------
/examples/public/jsoneditor-min.css:
--------------------------------------------------------------------------------
1 | .jsoneditor .field,.jsoneditor .value,.jsoneditor .readonly{border:1px solid transparent;min-height:16px;min-width:32px;padding:2px;margin:1px;word-wrap:break-word;float:left}.jsoneditor .field p,.jsoneditor .value p{margin:0}.jsoneditor .value{word-break:break-word}.jsoneditor .readonly{min-width:16px;color:gray}.jsoneditor .empty{border-color:lightgray;border-style:dashed;border-radius:2px}.jsoneditor .field.empty{background-image:url('img/jsoneditor-icons.png');background-position:0 -144px}.jsoneditor .value.empty{background-image:url('img/jsoneditor-icons.png');background-position:-48px -144px}.jsoneditor .separator{padding:3px 0;vertical-align:top;color:gray}.jsoneditor .field[contenteditable=true]:focus,.jsoneditor .field[contenteditable=true]:hover,.jsoneditor .value[contenteditable=true]:focus,.jsoneditor .value[contenteditable=true]:hover,.jsoneditor .field.highlight,.jsoneditor .value.highlight{background-color:#ffffab;border:1px solid yellow;border-radius:2px}.jsoneditor .field.highlight-active,.jsoneditor .field.highlight-active:focus,.jsoneditor .field.highlight-active:hover,.jsoneditor .value.highlight-active,.jsoneditor .value.highlight-active:focus,.jsoneditor .value.highlight-active:hover{background-color:#fe0;border:1px solid #ffc700;border-radius:2px}.jsoneditor button{width:24px;height:24px;padding:0;margin:0;border:0;cursor:pointer;background:transparent url('img/jsoneditor-icons.png')}.jsoneditor button.collapsed{background-position:0 -48px}.jsoneditor button.expanded{background-position:0 -72px}.jsoneditor button.contextmenu{background-position:-48px -72px}.jsoneditor button.contextmenu:hover,.jsoneditor button.contextmenu:focus,.jsoneditor button.contextmenu.selected{background-position:-48px -48px}.jsoneditor div.content *:focus{outline:0}.jsoneditor div.content button:focus{background-color:#f5f5f5;outline:#e5e5e5 solid 1px}.jsoneditor button.invisible{visibility:hidden;background:0}div.jsoneditor{color:#1a1a1a;border:1px solid #97b0f8;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;width:100%;height:100%;overflow:auto;position:relative;padding:0}.jsoneditor table.content{border-collapse:collapse;border-spacing:0;width:100%;margin:0}.jsoneditor div.outer{width:100%;height:100%;margin:-35px 0 0 0;padding:35px 0 0 0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.jsoneditor div.content{width:100%;height:100%;position:relative;overflow:auto}.jsoneditor textarea.content{width:100%;height:100%;margin:0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;border:0;background-color:white;resize:none}.jsoneditor tr.highlight{background-color:#ffffab}.jsoneditor button.dragarea{background:url('img/jsoneditor-icons.png') -72px -72px;cursor:move}.jsoneditor button.dragarea:hover,.jsoneditor button.dragarea:focus{background-position:-72px -48px}.jsoneditor tr,.jsoneditor th,.jsoneditor td{padding:0;margin:0}.jsoneditor td{vertical-align:top}.jsoneditor td.tree{vertical-align:top}.jsoneditor .field,.jsoneditor .value,.jsoneditor td,.jsoneditor th,.jsoneditor textarea{font-family:droid sans mono,monospace,courier new,courier,sans-serif;font-size:10pt;color:#1a1a1a}.jsoneditor .menu{width:100%;height:35px;padding:2px;margin:0;overflow:hidden;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;color:#1a1a1a;background-color:#d5ddf6;border-bottom:1px solid #97b0f8}.jsoneditor .menu button{width:26px;height:26px;margin:2px;padding:2px;border-radius:2px;border:1px solid #aec0f8;background:#e3eaf6 url('img/jsoneditor-icons.png')}.jsoneditor .menu button:hover{background-color:#f0f2f5}.jsoneditor .menu button:active{background-color:#fff}.jsoneditor .menu button:disabled{background-color:#e3eaf6}.jsoneditor .menu button.collapse-all{background-position:0 -96px}.jsoneditor .menu button.expand-all{background-position:0 -120px}.jsoneditor .menu button.undo{background-position:-24px -96px}.jsoneditor .menu button.undo:disabled{background-position:-24px -120px}.jsoneditor .menu button.redo{background-position:-48px -96px}.jsoneditor .menu button.redo:disabled{background-position:-48px -120px}.jsoneditor .menu button.compact{background-position:-72px -96px}.jsoneditor .menu button.format{background-position:-72px -120px}.jsoneditor .menu a{font-family:arial,sans-serif;font-size:10pt;color:#97b0f8;vertical-align:middle}.jsoneditor .menu a:hover{color:red}.jsoneditor .menu a.poweredBy{font-size:8pt;position:absolute;right:0;top:0;padding:10px}.jsoneditor-contextmenu{position:absolute}.jsoneditor-contextmenu ul{position:relative;left:0;top:0;width:124px;background:white;border:1px solid #d3d3d3;box-shadow:2px 2px 12px rgba(128,128,128,0.3);z-index:1;list-style:none;margin:0;padding:0}.jsoneditor-contextmenu ul li button{padding:0;margin:0;width:124px;height:24px;border:0;cursor:pointer;color:#4d4d4d;background:transparent;line-height:24px;text-align:left}.jsoneditor-contextmenu ul li button::-moz-focus-inner{padding:0;border:0}.jsoneditor-contextmenu ul li button:hover,.jsoneditor-contextmenu ul li button:focus{color:#1a1a1a;background-color:#f5f5f5;outline:0}.jsoneditor-contextmenu ul li button.default{width:92px}.jsoneditor-contextmenu ul li button.expand{float:right;width:32px;height:24px;border-left:1px solid #e5e5e5}.jsoneditor-contextmenu div.icon{float:left;width:24px;height:24px;border:0;padding:0;margin:0;background-image:url('img/jsoneditor-icons.png')}.jsoneditor-contextmenu ul li button div.expand{float:right;width:24px;height:24px;padding:0;margin:0 4px 0 0;background:url('img/jsoneditor-icons.png') 0 -72px;opacity:.4}.jsoneditor-contextmenu ul li button:hover div.expand,.jsoneditor-contextmenu ul li button:focus div.expand,.jsoneditor-contextmenu ul li.selected div.expand,.jsoneditor-contextmenu ul li button.expand:hover div.expand,.jsoneditor-contextmenu ul li button.expand:focus div.expand{opacity:1}.jsoneditor-contextmenu .separator{height:0;border-top:1px solid #e5e5e5;padding-top:5px;margin-top:5px}.jsoneditor-contextmenu button.remove>.icon{background-position:-24px -24px}.jsoneditor-contextmenu button.remove:hover>.icon,.jsoneditor-contextmenu button.remove:focus>.icon{background-position:-24px 0}.jsoneditor-contextmenu button.append>.icon{background-position:0 -24px}.jsoneditor-contextmenu button.append:hover>.icon,.jsoneditor-contextmenu button.append:focus>.icon{background-position:0 0}.jsoneditor-contextmenu button.insert>.icon{background-position:0 -24px}.jsoneditor-contextmenu button.insert:hover>.icon,.jsoneditor-contextmenu button.insert:focus>.icon{background-position:0 0}.jsoneditor-contextmenu button.duplicate>.icon{background-position:-48px -24px}.jsoneditor-contextmenu button.duplicate:hover>.icon,.jsoneditor-contextmenu button.duplicate:focus>.icon{background-position:-48px 0}.jsoneditor-contextmenu button.sort-asc>.icon{background-position:-168px -24px}.jsoneditor-contextmenu button.sort-asc:hover>.icon,.jsoneditor-contextmenu button.sort-asc:focus>.icon{background-position:-168px 0}.jsoneditor-contextmenu button.sort-desc>.icon{background-position:-192px -24px}.jsoneditor-contextmenu button.sort-desc:hover>.icon,.jsoneditor-contextmenu button.sort-desc:focus>.icon{background-position:-192px 0}.jsoneditor-contextmenu ul li ul li .selected{background-color:#d5ddf6}.jsoneditor-contextmenu ul li{overflow:hidden}.jsoneditor-contextmenu ul li ul{display:none;position:relative;left:-10px;top:0;border:0;box-shadow:inset 0 0 10px rgba(128,128,128,0.5);padding:0 10px;-webkit-transition:all .3s ease-out;-moz-transition:all .3s ease-out;-o-transition:all .3s ease-out;transition:all .3s ease-out}.jsoneditor-contextmenu ul li ul li button{padding-left:24px}.jsoneditor-contextmenu ul li ul li button:hover,.jsoneditor-contextmenu ul li ul li button:focus{background-color:#f5f5f5}.jsoneditor-contextmenu button.type-string>.icon{background-position:-144px -24px}.jsoneditor-contextmenu button.type-string:hover>.icon,.jsoneditor-contextmenu button.type-string:focus>.icon,.jsoneditor-contextmenu button.type-string.selected>.icon{background-position:-144px 0}.jsoneditor-contextmenu button.type-auto>.icon{background-position:-120px -24px}.jsoneditor-contextmenu button.type-auto:hover>.icon,.jsoneditor-contextmenu button.type-auto:focus>.icon,.jsoneditor-contextmenu button.type-auto.selected>.icon{background-position:-120px 0}.jsoneditor-contextmenu button.type-object>.icon{background-position:-72px -24px}.jsoneditor-contextmenu button.type-object:hover>.icon,.jsoneditor-contextmenu button.type-object:focus>.icon,.jsoneditor-contextmenu button.type-object.selected>.icon{background-position:-72px 0}.jsoneditor-contextmenu button.type-array>.icon{background-position:-96px -24px}.jsoneditor-contextmenu button.type-array:hover>.icon,.jsoneditor-contextmenu button.type-array:focus>.icon,.jsoneditor-contextmenu button.type-array.selected>.icon{background-position:-96px 0}.jsoneditor .search input,.jsoneditor .search .results{font-family:arial,sans-serif;font-size:10pt;color:#1a1a1a}.jsoneditor .search{position:absolute;right:2px;top:2px}.jsoneditor .search .frame{border:1px solid #97b0f8;background-color:white;padding:0 2px;margin:0}.jsoneditor .search .frame table{border-collapse:collapse}.jsoneditor .search input{width:120px;border:0;outline:0;margin:1px}.jsoneditor .search .results{color:#4d4d4d;padding-right:5px;line-height:24px}.jsoneditor .search button{width:16px;height:24px;padding:0;margin:0;border:0;background:url('img/jsoneditor-icons.png');vertical-align:top}.jsoneditor .search button:hover{background-color:transparent}.jsoneditor .search button.refresh{width:18px;background-position:-99px -73px}.jsoneditor .search button.next{cursor:pointer;background-position:-124px -73px}.jsoneditor .search button.next:hover{background-position:-124px -49px}.jsoneditor .search button.previous{cursor:pointer;background-position:-148px -73px;margin-right:2px}.jsoneditor .search button.previous:hover{background-position:-148px -49px}
--------------------------------------------------------------------------------
/examples/public/jsoneditor.min.css:
--------------------------------------------------------------------------------
1 | .jsoneditor .field,.jsoneditor .readonly,.jsoneditor .value{border:1px solid transparent;min-height:16px;min-width:32px;padding:2px;margin:1px;word-wrap:break-word;float:left}.jsoneditor .field p,.jsoneditor .value p{margin:0}.jsoneditor .value{word-break:break-word}.jsoneditor .readonly{min-width:16px;color:gray}.jsoneditor .empty{border-color:#d3d3d3;border-style:dashed;border-radius:2px}.jsoneditor .field.empty{background-image:url(img/jsoneditor-icons.png);background-position:0 -144px}.jsoneditor .value.empty{background-image:url(img/jsoneditor-icons.png);background-position:-48px -144px}.jsoneditor .value.url{color:green;text-decoration:underline}.jsoneditor a.value.url:focus,.jsoneditor a.value.url:hover{color:red}.jsoneditor .separator{padding:3px 0;vertical-align:top;color:gray}.jsoneditor .field.highlight,.jsoneditor .field[contenteditable=true]:focus,.jsoneditor .field[contenteditable=true]:hover,.jsoneditor .value.highlight,.jsoneditor .value[contenteditable=true]:focus,.jsoneditor .value[contenteditable=true]:hover{background-color:#FFFFAB;border:1px solid #ff0;border-radius:2px}.jsoneditor .field.highlight-active,.jsoneditor .field.highlight-active:focus,.jsoneditor .field.highlight-active:hover,.jsoneditor .value.highlight-active,.jsoneditor .value.highlight-active:focus,.jsoneditor .value.highlight-active:hover{background-color:#fe0;border:1px solid #ffc700;border-radius:2px}.jsoneditor div.tree button{width:24px;height:24px;padding:0;margin:0;border:none;cursor:pointer;background:transparent url(img/jsoneditor-icons.png)}.jsoneditor div.tree button.collapsed{background-position:0 -48px}.jsoneditor div.tree button.expanded{background-position:0 -72px}.jsoneditor div.tree button.contextmenu{background-position:-48px -72px}.jsoneditor div.tree button.contextmenu.selected,.jsoneditor div.tree button.contextmenu:focus,.jsoneditor div.tree button.contextmenu:hover{background-position:-48px -48px}.jsoneditor div.tree :focus{outline:0}.jsoneditor div.tree button:focus{background-color:#f5f5f5;outline:#e5e5e5 solid 1px}.jsoneditor div.tree button.invisible{visibility:hidden;background:0 0}.jsoneditor{color:#1A1A1A;border:1px solid #97B0F8;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;width:100%;height:100%;overflow:auto;position:relative;padding:0;line-height:100%}.jsoneditor div.tree table.tree{border-collapse:collapse;border-spacing:0;width:100%;margin:0}.jsoneditor div.outer{width:100%;height:100%;margin:-35px 0 0 0;padding:35px 0 0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;overflow:hidden}.jsoneditor div.tree{width:100%;height:100%;position:relative;overflow:auto}.jsoneditor textarea.text{width:100%;height:100%;margin:0;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;border:none;background-color:#fff;resize:none}.jsoneditor tr.highlight{background-color:#FFFFAB}.jsoneditor div.tree button.dragarea{background:url(img/jsoneditor-icons.png) -72px -72px;cursor:move}.jsoneditor div.tree button.dragarea:focus,.jsoneditor div.tree button.dragarea:hover{background-position:-72px -48px}.jsoneditor td,.jsoneditor th,.jsoneditor tr{padding:0;margin:0}.jsoneditor td,.jsoneditor td.tree{vertical-align:top}.jsoneditor .field,.jsoneditor .value,.jsoneditor td,.jsoneditor textarea,.jsoneditor th{font-family:droid sans mono,monospace,courier new,courier,sans-serif;font-size:10pt;color:#1A1A1A}.jsoneditor-contextmenu{position:absolute;z-index:99999}.jsoneditor-contextmenu ul{position:relative;left:0;top:0;width:124px;background:#fff;border:1px solid #d3d3d3;box-shadow:2px 2px 12px rgba(128,128,128,.3);list-style:none;margin:0;padding:0}.jsoneditor-contextmenu ul li button{padding:0;margin:0;width:124px;height:24px;border:none;cursor:pointer;color:#4d4d4d;background:0 0;line-height:26px;text-align:left}.jsoneditor-contextmenu ul li button::-moz-focus-inner{padding:0;border:0}.jsoneditor-contextmenu ul li button:focus,.jsoneditor-contextmenu ul li button:hover{color:#1a1a1a;background-color:#f5f5f5;outline:0}.jsoneditor-contextmenu ul li button.default{width:92px}.jsoneditor-contextmenu ul li button.expand{float:right;width:32px;height:24px;border-left:1px solid #e5e5e5}.jsoneditor-contextmenu div.icon{float:left;width:24px;height:24px;border:none;padding:0;margin:0;background-image:url(img/jsoneditor-icons.png)}.jsoneditor-contextmenu ul li button div.expand{float:right;width:24px;height:24px;padding:0;margin:0 4px 0 0;background:url(img/jsoneditor-icons.png) 0 -72px;opacity:.4}.jsoneditor-contextmenu ul li button.expand:focus div.expand,.jsoneditor-contextmenu ul li button.expand:hover div.expand,.jsoneditor-contextmenu ul li button:focus div.expand,.jsoneditor-contextmenu ul li button:hover div.expand,.jsoneditor-contextmenu ul li.selected div.expand{opacity:1}.jsoneditor-contextmenu .separator{height:0;border-top:1px solid #e5e5e5;padding-top:5px;margin-top:5px}.jsoneditor-contextmenu button.remove>.icon{background-position:-24px -24px}.jsoneditor-contextmenu button.remove:focus>.icon,.jsoneditor-contextmenu button.remove:hover>.icon{background-position:-24px 0}.jsoneditor-contextmenu button.append>.icon{background-position:0 -24px}.jsoneditor-contextmenu button.append:focus>.icon,.jsoneditor-contextmenu button.append:hover>.icon{background-position:0 0}.jsoneditor-contextmenu button.insert>.icon{background-position:0 -24px}.jsoneditor-contextmenu button.insert:focus>.icon,.jsoneditor-contextmenu button.insert:hover>.icon{background-position:0 0}.jsoneditor-contextmenu button.duplicate>.icon{background-position:-48px -24px}.jsoneditor-contextmenu button.duplicate:focus>.icon,.jsoneditor-contextmenu button.duplicate:hover>.icon{background-position:-48px 0}.jsoneditor-contextmenu button.sort-asc>.icon{background-position:-168px -24px}.jsoneditor-contextmenu button.sort-asc:focus>.icon,.jsoneditor-contextmenu button.sort-asc:hover>.icon{background-position:-168px 0}.jsoneditor-contextmenu button.sort-desc>.icon{background-position:-192px -24px}.jsoneditor-contextmenu button.sort-desc:focus>.icon,.jsoneditor-contextmenu button.sort-desc:hover>.icon{background-position:-192px 0}.jsoneditor-contextmenu ul li .selected{background-color:#D5DDF6}.jsoneditor-contextmenu ul li{overflow:hidden}.jsoneditor-contextmenu ul li ul{display:none;position:relative;left:-10px;top:0;border:none;box-shadow:inset 0 0 10px rgba(128,128,128,.5);padding:0 10px;-webkit-transition:all .3s ease-out;-moz-transition:all .3s ease-out;-o-transition:all .3s ease-out;transition:all .3s ease-out}.jsoneditor-contextmenu ul li ul li button{padding-left:24px}.jsoneditor-contextmenu ul li ul li button:focus,.jsoneditor-contextmenu ul li ul li button:hover{background-color:#f5f5f5}.jsoneditor-contextmenu button.type-string>.icon{background-position:-144px -24px}.jsoneditor-contextmenu button.type-string.selected>.icon,.jsoneditor-contextmenu button.type-string:focus>.icon,.jsoneditor-contextmenu button.type-string:hover>.icon{background-position:-144px 0}.jsoneditor-contextmenu button.type-auto>.icon{background-position:-120px -24px}.jsoneditor-contextmenu button.type-auto.selected>.icon,.jsoneditor-contextmenu button.type-auto:focus>.icon,.jsoneditor-contextmenu button.type-auto:hover>.icon{background-position:-120px 0}.jsoneditor-contextmenu button.type-object>.icon{background-position:-72px -24px}.jsoneditor-contextmenu button.type-object.selected>.icon,.jsoneditor-contextmenu button.type-object:focus>.icon,.jsoneditor-contextmenu button.type-object:hover>.icon{background-position:-72px 0}.jsoneditor-contextmenu button.type-array>.icon{background-position:-96px -24px}.jsoneditor-contextmenu button.type-array.selected>.icon,.jsoneditor-contextmenu button.type-array:focus>.icon,.jsoneditor-contextmenu button.type-array:hover>.icon{background-position:-96px 0}.jsoneditor-contextmenu button.type-modes>.icon{background-image:none;width:6px}.jsoneditor .menu{width:100%;height:35px;padding:2px;margin:0;overflow:hidden;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;color:#1A1A1A;background-color:#D5DDF6;border-bottom:1px solid #97B0F8}.jsoneditor .menu button{width:26px;height:26px;margin:2px;padding:0;border-radius:2px;border:1px solid #aec0f8;background:#e3eaf6 url(img/jsoneditor-icons.png);color:#4D4D4D;opacity:.8;font-family:arial,sans-serif;font-size:10pt;float:left}.jsoneditor .menu button:hover{background-color:#f0f2f5}.jsoneditor .menu button:active{background-color:#fff}.jsoneditor .menu button:disabled{background-color:#e3eaf6}.jsoneditor .menu button.collapse-all{background-position:0 -96px}.jsoneditor .menu button.expand-all{background-position:0 -120px}.jsoneditor .menu button.undo{background-position:-24px -96px}.jsoneditor .menu button.undo:disabled{background-position:-24px -120px}.jsoneditor .menu button.redo{background-position:-48px -96px}.jsoneditor .menu button.redo:disabled{background-position:-48px -120px}.jsoneditor .menu button.compact{background-position:-72px -96px}.jsoneditor .menu button.format{background-position:-72px -120px}.jsoneditor .menu button.modes{background-image:none;width:auto;padding-left:6px;padding-right:6px}.jsoneditor .menu button.separator{margin-left:10px}.jsoneditor .menu a{font-family:arial,sans-serif;font-size:10pt;color:#97B0F8;vertical-align:middle}.jsoneditor .menu a:hover{color:red}.jsoneditor .menu a.poweredBy{font-size:8pt;position:absolute;right:0;top:0;padding:10px}.jsoneditor .search .results,.jsoneditor .search input{font-family:arial,sans-serif;font-size:10pt;color:#1A1A1A}.jsoneditor .search{position:absolute;right:2px;top:2px}.jsoneditor .search .frame{border:1px solid #97B0F8;background-color:#fff;padding:0 2px;margin:0}.jsoneditor .search .frame table{border-collapse:collapse}.jsoneditor .search input{width:120px;border:none;outline:0;margin:1px}.jsoneditor .search .results{color:#4d4d4d;padding-right:5px;line-height:24px}.jsoneditor .search button{width:16px;height:24px;padding:0;margin:0;border:none;background:url(img/jsoneditor-icons.png);vertical-align:top}.jsoneditor .search button:hover{background-color:transparent}.jsoneditor .search button.refresh{width:18px;background-position:-99px -73px}.jsoneditor .search button.next{cursor:pointer;background-position:-124px -73px}.jsoneditor .search button.next:hover{background-position:-124px -49px}.jsoneditor .search button.previous{cursor:pointer;background-position:-148px -73px;margin-right:2px}.jsoneditor .search button.previous:hover{background-position:-148px -49px}
--------------------------------------------------------------------------------
/examples/public/list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
109 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
297 |
--------------------------------------------------------------------------------
/examples/public/multi.html:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
21 |
40 |
--------------------------------------------------------------------------------
/examples/public/react.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hello React
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
130 |
131 |
132 |
--------------------------------------------------------------------------------
/examples/public/ws.html:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
33 |
--------------------------------------------------------------------------------
/examples/server.coffee:
--------------------------------------------------------------------------------
1 | # This is a little prototype browserchannel wrapper for the session code.
2 | {Duplex} = require 'stream'
3 | browserChannel = require('browserchannel').server
4 | connect = require 'connect'
5 | serveStatic = require 'serve-static'
6 | argv = require('optimist').argv
7 | livedb = require 'livedb'
8 |
9 | try
10 | require 'heapdump'
11 |
12 | sharejs = require '../lib'
13 |
14 | webserver = connect()
15 |
16 | webserver.use serveStatic "#{__dirname}/public"
17 | webserver.use serveStatic sharejs.scriptsDir
18 |
19 | backend = livedb.client livedb.memory()
20 |
21 | backend.addProjection '_users', 'users', 'json0', {x:true}
22 |
23 | share = sharejs.server.createClient {backend}
24 |
25 |
26 | ###
27 | share.use 'validate', (req, callback) ->
28 | err = 'noooo' if req.snapshot.data?.match /x/
29 | callback err
30 |
31 | share.use 'connect', (req, callback) ->
32 | console.log req.agent
33 | callback()
34 | ###
35 |
36 | numClients = 0
37 |
38 | webserver.use browserChannel {webserver, sessionTimeoutInterval:5000}, (client) ->
39 | numClients++
40 | stream = new Duplex objectMode:yes
41 | stream._write = (chunk, encoding, callback) ->
42 | console.log 's->c ', JSON.stringify(chunk)
43 | if client.state isnt 'closed' # silently drop messages after the session is closed
44 | client.send chunk
45 | callback()
46 |
47 | stream._read = -> # Ignore. You can't control the information, man!
48 |
49 | stream.headers = client.headers
50 | stream.remoteAddress = stream.address
51 |
52 | client.on 'message', (data) ->
53 | console.log 'c->s ', JSON.stringify(data)
54 | stream.push data
55 |
56 | stream.on 'error', (msg) ->
57 | client.stop()
58 |
59 | client.on 'close', (reason) ->
60 | stream.push null
61 | stream.emit 'close'
62 |
63 | numClients--
64 | console.log 'client went away', numClients
65 |
66 | stream.on 'end', ->
67 | client.close()
68 |
69 | # ... and give the stream to ShareJS.
70 | share.listen stream
71 |
72 | webserver.use '/doc', share.rest()
73 |
74 | port = argv.p or 7007
75 | webserver.listen port
76 | console.log "Listening on http://localhost:#{port}/"
77 |
--------------------------------------------------------------------------------
/examples/ws.coffee:
--------------------------------------------------------------------------------
1 | # This is a little prototype browserchannel wrapper for the session code.
2 | {Duplex} = require 'stream'
3 | connect = require 'connect'
4 | argv = require('optimist').argv
5 | livedb = require 'livedb'
6 | http = require 'http'
7 |
8 | sharejs = require '../lib'
9 |
10 | app = connect(
11 | # connect.logger()
12 | connect.static "#{__dirname}/public"
13 | connect.static sharejs.scriptsDir
14 | )
15 |
16 |
17 | # app.use '/doc', share.rest()
18 |
19 | backend = livedb.client livedb.memory()
20 | #backend = livedb.client livedbMongo('localhost:27017/test?auto_reconnect', safe:false)
21 |
22 | share = sharejs.server.createClient {backend}
23 |
24 |
25 | server = http.createServer app
26 |
27 | WebSocketServer = require('ws').Server
28 | wss = new WebSocketServer {server}
29 | wss.on 'connection', (client) ->
30 | stream = new Duplex objectMode:yes
31 | stream._write = (chunk, encoding, callback) ->
32 | console.log 's->c ', chunk
33 | client.send JSON.stringify chunk
34 | callback()
35 |
36 | stream._read = -> # Ignore. You can't control the information, man!
37 |
38 | stream.headers = client.upgradeReq.headers
39 | stream.remoteAddress = client.upgradeReq.connection.remoteAddress
40 |
41 | client.on 'message', (data) ->
42 | console.log 'c->s ', data
43 | stream.push JSON.parse data
44 |
45 | stream.on 'error', (msg) ->
46 | client.close msg
47 |
48 | client.on 'close', (reason) ->
49 | stream.push null
50 | stream.emit 'close'
51 |
52 | console.log 'client went away'
53 | client.close reason
54 |
55 | stream.on 'end', ->
56 | client.close()
57 |
58 | # ... and give the stream to ShareJS.
59 | share.listen stream
60 |
61 |
62 |
63 | port = argv.p or 7007
64 | server.listen port
65 | console.log "Listening on http://localhost:#{port}/"
66 |
--------------------------------------------------------------------------------
/lib/client/emitter.js:
--------------------------------------------------------------------------------
1 | var EventEmitter = require('events').EventEmitter;
2 |
3 | exports.EventEmitter = EventEmitter;
4 | exports.mixin = mixin;
5 |
6 | function mixin(Constructor) {
7 | for (var key in EventEmitter.prototype) {
8 | Constructor.prototype[key] = EventEmitter.prototype[key];
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/lib/client/index.js:
--------------------------------------------------------------------------------
1 | // Entry point for the client
2 | //
3 | // Usage:
4 | //
5 | //
6 |
7 | exports.Connection = require('./connection').Connection;
8 | exports.Doc = require('./doc').Doc;
9 | require('./textarea');
10 |
11 | var types = require('../types');
12 | exports.ottypes = types.ottypes;
13 | exports.registerType = types.registerType;
14 |
--------------------------------------------------------------------------------
/lib/client/query.js:
--------------------------------------------------------------------------------
1 | var emitter = require('./emitter');
2 |
3 | // Queries are live requests to the database for particular sets of fields.
4 | //
5 | // The server actively tells the client when there's new data that matches
6 | // a set of conditions.
7 | var Query = exports.Query = function(type, connection, id, collection, query, options, callback) {
8 | emitter.EventEmitter.call(this);
9 |
10 | // 'fetch' or 'sub'
11 | this.type = type;
12 |
13 | this.connection = connection;
14 | this.id = id;
15 | this.collection = collection;
16 |
17 | // The query itself. For mongo, this should look something like {"data.x":5}
18 | this.query = query;
19 |
20 | // Resultant document action for the server. Fetch mode will automatically
21 | // fetch all results. Subscribe mode will automatically subscribe all
22 | // results. Results are never unsubscribed.
23 | this.docMode = options.docMode; // undefined, 'fetch' or 'sub'.
24 | if (this.docMode === 'subscribe') this.docMode = 'sub';
25 |
26 | // Do we repoll the entire query whenever anything changes? (As opposed to
27 | // just polling the changed item). This needs to be enabled to be able to use
28 | // ordered queries (sortby:) and paginated queries. Set to undefined, it will
29 | // be enabled / disabled automatically based on the query's properties.
30 | this.poll = options.poll;
31 |
32 | // The backend we actually hit. If this isn't defined, it hits the snapshot
33 | // database. Otherwise this can be used to hit another configured query
34 | // index.
35 | this.backend = options.backend || options.source;
36 |
37 | // A list of resulting documents. These are actual documents, complete with
38 | // data and all the rest. If fetch is false, these documents will not
39 | // have any data. You should manually call fetch() or subscribe() on them.
40 | //
41 | // Calling subscribe() might be a good idea anyway, as you won't be
42 | // subscribed to the documents by default.
43 | this.knownDocs = options.knownDocs || [];
44 | this.results = [];
45 |
46 | // Do we have some initial data?
47 | this.ready = false;
48 |
49 | this.callback = callback;
50 | };
51 | emitter.mixin(Query);
52 |
53 | Query.prototype.action = 'qsub';
54 |
55 | // Helper for subscribe & fetch, since they share the same message format.
56 | //
57 | // This function actually issues the query.
58 | Query.prototype._execute = function() {
59 | if (!this.connection.canSend) return;
60 |
61 | if (this.docMode) {
62 | var collectionVersions = {};
63 | // Collect the version of all the documents in the current result set so we
64 | // don't need to be sent their snapshots again.
65 | for (var i = 0; i < this.knownDocs.length; i++) {
66 | var doc = this.knownDocs[i];
67 | if (doc.version == null) continue;
68 | var c = collectionVersions[doc.collection] =
69 | (collectionVersions[doc.collection] || {});
70 | c[doc.name] = doc.version;
71 | }
72 | }
73 |
74 | var msg = {
75 | a: 'q' + this.type,
76 | id: this.id,
77 | c: this.collection,
78 | o: {},
79 | q: this.query,
80 | };
81 |
82 | if (this.docMode) {
83 | msg.o.m = this.docMode;
84 | // This should be omitted if empty, but whatever.
85 | msg.o.vs = collectionVersions;
86 | }
87 | if (this.backend != null) msg.o.b = this.backend;
88 | if (this.poll !== undefined) msg.o.p = this.poll;
89 |
90 | this.connection.send(msg);
91 | };
92 |
93 | // Make a list of documents from the list of server-returned data objects
94 | Query.prototype._dataToDocs = function(data) {
95 | var results = [];
96 | var lastType;
97 | for (var i = 0; i < data.length; i++) {
98 | var docData = data[i];
99 |
100 | // Types are only put in for the first result in the set and every time the type changes in the list.
101 | if (docData.type) {
102 | lastType = docData.type;
103 | } else {
104 | docData.type = lastType;
105 | }
106 |
107 | // This will ultimately call doc.ingestData(), which is what populates
108 | // the doc snapshot and version with the data returned by the query
109 | var doc = this.connection.get(docData.c || this.collection, docData.d, docData);
110 | results.push(doc);
111 | }
112 | return results;
113 | };
114 |
115 | // Destroy the query object. Any subsequent messages for the query will be
116 | // ignored by the connection. You should unsubscribe from the query before
117 | // destroying it.
118 | Query.prototype.destroy = function() {
119 | if (this.connection.canSend && this.type === 'sub') {
120 | this.connection.send({a:'qunsub', id:this.id});
121 | }
122 |
123 | this.connection._destroyQuery(this);
124 | };
125 |
126 | Query.prototype._onConnectionStateChanged = function(state, reason) {
127 | if (this.connection.state === 'connecting') {
128 | this._execute();
129 | }
130 | };
131 |
132 | // Internal method called from connection to pass server messages to the query.
133 | Query.prototype._onMessage = function(msg) {
134 | if ((msg.a === 'qfetch') !== (this.type === 'fetch')) {
135 | console.warn('Invalid message sent to query', msg, this);
136 | return;
137 | }
138 |
139 | if (msg.error) this.emit('error', msg.error);
140 |
141 | switch (msg.a) {
142 | case 'qfetch':
143 | var results = msg.data ? this._dataToDocs(msg.data) : undefined;
144 | if (this.callback) this.callback(msg.error, results, msg.extra);
145 | // Once a fetch query gets its data, it is destroyed.
146 | this.connection._destroyQuery(this);
147 | break;
148 |
149 | case 'q':
150 | // Query diff data (inserts and removes)
151 | if (msg.diff) {
152 | // We need to go through the list twice. First, we'll ingest all the
153 | // new documents and set them as subscribed. After that we'll emit
154 | // events and actually update our list. This avoids race conditions
155 | // around setting documents to be subscribed & unsubscribing documents
156 | // in event callbacks.
157 | for (var i = 0; i < msg.diff.length; i++) {
158 | var d = msg.diff[i];
159 | if (d.type === 'insert') d.values = this._dataToDocs(d.values);
160 | }
161 |
162 | for (var i = 0; i < msg.diff.length; i++) {
163 | var d = msg.diff[i];
164 | switch (d.type) {
165 | case 'insert':
166 | var newDocs = d.values;
167 | Array.prototype.splice.apply(this.results, [d.index, 0].concat(newDocs));
168 | this.emit('insert', newDocs, d.index);
169 | break;
170 | case 'remove':
171 | var howMany = d.howMany || 1;
172 | var removed = this.results.splice(d.index, howMany);
173 | this.emit('remove', removed, d.index);
174 | break;
175 | case 'move':
176 | var howMany = d.howMany || 1;
177 | var docs = this.results.splice(d.from, howMany);
178 | Array.prototype.splice.apply(this.results, [d.to, 0].concat(docs));
179 | this.emit('move', docs, d.from, d.to);
180 | break;
181 | }
182 | }
183 | }
184 |
185 | if (msg.extra !== void 0) {
186 | this.emit('extra', msg.extra);
187 | }
188 | break;
189 | case 'qsub':
190 | // This message replaces the entire result set with the set passed.
191 | if (!msg.error) {
192 | var previous = this.results;
193 |
194 | // Then add everything in the new result set.
195 | this.results = this.knownDocs = this._dataToDocs(msg.data);
196 | this.extra = msg.extra;
197 |
198 | this.ready = true;
199 | this.emit('change', this.results, previous);
200 | }
201 | if (this.callback) {
202 | this.callback(msg.error, this.results, this.extra);
203 | delete this.callback;
204 | }
205 | break;
206 | }
207 | };
208 |
209 | // Change the thing we're searching for. This isn't fully supported on the
210 | // backend (it destroys the old query and makes a new one) - but its
211 | // programatically useful and I might add backend support at some point.
212 | Query.prototype.setQuery = function(q) {
213 | if (this.type !== 'sub') throw new Error('cannot change a fetch query');
214 |
215 | this.query = q;
216 | if (this.connection.canSend) {
217 | // There's no 'change' message to send to the server. Just resubscribe.
218 | this.connection.send({a:'qunsub', id:this.id});
219 | this._execute();
220 | }
221 | };
222 |
--------------------------------------------------------------------------------
/lib/client/textarea.js:
--------------------------------------------------------------------------------
1 | /* This contains the textarea binding for ShareJS. This binding is really
2 | * simple, and a bit slow on big documents (Its O(N). However, it requires no
3 | * changes to the DOM and no heavy libraries like ace. It works for any kind of
4 | * text input field.
5 | *
6 | * You probably want to use this binding for small fields on forms and such.
7 | * For code editors or rich text editors or whatever, I recommend something
8 | * heavier.
9 | */
10 |
11 | var Doc = require('./doc').Doc;
12 |
13 | /* applyChange creates the edits to convert oldval -> newval.
14 | *
15 | * This function should be called every time the text element is changed.
16 | * Because changes are always localised, the diffing is quite easy. We simply
17 | * scan in from the start and scan in from the end to isolate the edited range,
18 | * then delete everything that was removed & add everything that was added.
19 | * This wouldn't work for complex changes, but this function should be called
20 | * on keystroke - so the edits will mostly just be single character changes.
21 | * Sometimes they'll paste text over other text, but even then the diff
22 | * generated by this algorithm is correct.
23 | *
24 | * This algorithm is O(N). I suspect you could speed it up somehow using regular expressions.
25 | */
26 | var applyChange = function(ctx, oldval, newval) {
27 | // Strings are immutable and have reference equality. I think this test is O(1), so its worth doing.
28 | if (oldval === newval) return;
29 |
30 | var commonStart = 0;
31 | while (oldval.charAt(commonStart) === newval.charAt(commonStart)) {
32 | commonStart++;
33 | }
34 |
35 | var commonEnd = 0;
36 | while (oldval.charAt(oldval.length - 1 - commonEnd) === newval.charAt(newval.length - 1 - commonEnd) &&
37 | commonEnd + commonStart < oldval.length && commonEnd + commonStart < newval.length) {
38 | commonEnd++;
39 | }
40 |
41 | if (oldval.length !== commonStart + commonEnd) {
42 | ctx.remove(commonStart, oldval.length - commonStart - commonEnd);
43 | }
44 | if (newval.length !== commonStart + commonEnd) {
45 | ctx.insert(commonStart, newval.slice(commonStart, newval.length - commonEnd));
46 | }
47 | };
48 |
49 | // Attach a textarea to a document's editing context.
50 | //
51 | // The context is optional, and will be created from the document if its not
52 | // specified.
53 | Doc.prototype.attachTextarea = function(elem, ctx) {
54 | if (!ctx) ctx = this.createContext();
55 |
56 | if (!ctx.provides.text) throw new Error('Cannot attach to non-text document');
57 |
58 | elem.value = ctx.get();
59 |
60 | // The current value of the element's text is stored so we can quickly check
61 | // if its been changed in the event handlers. This is mostly for browsers on
62 | // windows, where the content contains \r\n newlines. applyChange() is only
63 | // called after the \r\n newlines are converted, and that check is quite
64 | // slow. So we also cache the string before conversion so we can do a quick
65 | // check incase the conversion isn't needed.
66 | var prevvalue;
67 |
68 | // Replace the content of the text area with newText, and transform the
69 | // current cursor by the specified function.
70 | var replaceText = function(newText, transformCursor) {
71 | if (transformCursor) {
72 | var newSelection = [transformCursor(elem.selectionStart), transformCursor(elem.selectionEnd)];
73 | }
74 |
75 | // Fixate the window's scroll while we set the element's value. Otherwise
76 | // the browser scrolls to the element.
77 | var scrollTop = elem.scrollTop;
78 | elem.value = newText;
79 | prevvalue = elem.value; // Not done on one line so the browser can do newline conversion.
80 | if (elem.scrollTop !== scrollTop) elem.scrollTop = scrollTop;
81 |
82 | // Setting the selection moves the cursor. We'll just have to let your
83 | // cursor drift if the element isn't active, though usually users don't
84 | // care.
85 | if (newSelection && window.document.activeElement === elem) {
86 | elem.selectionStart = newSelection[0];
87 | elem.selectionEnd = newSelection[1];
88 | }
89 | };
90 |
91 | replaceText(ctx.get());
92 |
93 |
94 | // *** remote -> local changes
95 |
96 | ctx.onInsert = function(pos, text) {
97 | var transformCursor = function(cursor) {
98 | return pos < cursor ? cursor + text.length : cursor;
99 | };
100 |
101 | // Remove any window-style newline characters. Windows inserts these, and
102 | // they mess up the generated diff.
103 | var prev = elem.value.replace(/\r\n/g, '\n');
104 | replaceText(prev.slice(0, pos) + text + prev.slice(pos), transformCursor);
105 | };
106 |
107 | ctx.onRemove = function(pos, length) {
108 | var transformCursor = function(cursor) {
109 | // If the cursor is inside the deleted region, we only want to move back to the start
110 | // of the region. Hence the Math.min.
111 | return pos < cursor ? cursor - Math.min(length, cursor - pos) : cursor;
112 | };
113 |
114 | var prev = elem.value.replace(/\r\n/g, '\n');
115 | replaceText(prev.slice(0, pos) + prev.slice(pos + length), transformCursor);
116 | };
117 |
118 |
119 | // *** local -> remote changes
120 |
121 | // This function generates operations from the changed content in the textarea.
122 | var genOp = function(event) {
123 | // In a timeout so the browser has time to propogate the event's changes to the DOM.
124 | setTimeout(function() {
125 | if (elem.value !== prevvalue) {
126 | prevvalue = elem.value;
127 | applyChange(ctx, ctx.get(), elem.value.replace(/\r\n/g, '\n'));
128 | }
129 | }, 0);
130 | };
131 |
132 | var eventNames = ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'];
133 | for (var i = 0; i < eventNames.length; i++) {
134 | var e = eventNames[i];
135 | if (elem.addEventListener) {
136 | elem.addEventListener(e, genOp, false);
137 | } else {
138 | elem.attachEvent('on' + e, genOp);
139 | }
140 | }
141 |
142 | ctx.detach = function() {
143 | for (var i = 0; i < eventNames.length; i++) {
144 | var e = eventNames[i];
145 | if (elem.removeEventListener) {
146 | elem.removeEventListener(e, genOp, false);
147 | } else {
148 | elem.detachEvent('on' + e, genOp);
149 | }
150 | }
151 | };
152 |
153 | return ctx;
154 | };
155 |
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | exports.server = require('./server');
2 | exports.client = require('./client');
3 |
4 | // Type wrappers
5 | exports.types = require('./types');
6 |
7 | // Export the scripts directory to make it easy to host the scripts with connect. Do something like this:
8 | // app.use('sharejs', connect.static(sharejs.scriptsDir));
9 | exports.scriptsDir = __dirname + '/../dist';
10 |
11 | // Expose db.mongo, db.etc - whatever else is in livedb.
12 | exports.db = require('livedb');
13 |
--------------------------------------------------------------------------------
/lib/server/index.js:
--------------------------------------------------------------------------------
1 | var Session = require('./session');
2 | var UserAgent = require('./useragent');
3 | var livedb = require('livedb');
4 |
5 | /** This encapsulates the sharejs server state & exposes a few useful methods.
6 | *
7 | * @constructor
8 | */
9 | var ShareInstance = function(options) {
10 | this.options = options;
11 |
12 | this.preValidate = options.preValidate;
13 | this.validate = options.validate;
14 |
15 | if (options.backend) {
16 | this.backend = options.backend;
17 | } else if (options.db) {
18 | this.backend = livedb.client(options.db);
19 | } else {
20 | throw Error("Both options.backend and options.db are missing. Can't function without a database!");
21 | }
22 |
23 | // Map from event name (or '') to a list of middleware.
24 | this.extensions = {'':[]};
25 | this.docFilters = [];
26 | this.opFilters = [];
27 | };
28 |
29 | /** A client has connected through the specified stream. Listen for messages.
30 | * Returns the useragent associated with the connected session.
31 | *
32 | * The optional second argument (req) is an initial request which is passed
33 | * through to any connect() middleware. This is useful for inspecting cookies
34 | * or an express session or whatever on the request object in your middleware.
35 | *
36 | * (The useragent is available through all middleware)
37 | */
38 | ShareInstance.prototype.listen = function(stream, req) {
39 | var session = this.createSession(stream);
40 | session.agent.trigger('connect', null, null, {stream: stream, req: req}, function(err) {
41 | if (err) return session.close(err);
42 | session.pump();
43 | });
44 | return session.agent;
45 | };
46 |
47 | // Create and return REST middleware to access the documents
48 | ShareInstance.prototype.rest = function() {
49 | var rest = require('./rest');
50 | return rest(this);
51 | };
52 |
53 |
54 | /** Add middleware to an action. The action is optional (if not specified, the
55 | * middleware fires on every action).
56 | */
57 | ShareInstance.prototype.use = function(action, middleware) {
58 | if (typeof action !== 'string') {
59 | middleware = action;
60 | action = '';
61 | }
62 |
63 | if (action === 'getOps') {
64 | throw new Error("The 'getOps' middleware action has been renamed to 'get ops'. Update your code.");
65 | }
66 |
67 | var extensions = this.extensions[action];
68 | if (!extensions) extensions = this.extensions[action] = [];
69 |
70 | extensions.push(middleware);
71 | };
72 |
73 |
74 | /** Add a function to filter all data going to the current client */
75 | ShareInstance.prototype.filter = function(fn) {
76 | this.docFilters.push(fn);
77 | };
78 |
79 | ShareInstance.prototype.filterOps = function(fn) {
80 | this.opFilters.push(fn);
81 | };
82 |
83 | ShareInstance.prototype.createAgent = function(stream) {
84 | return new UserAgent(this, stream);
85 | };
86 |
87 | ShareInstance.prototype.createSession = function(stream) {
88 | return new Session(this, stream);
89 | };
90 |
91 | // Return truthy if the instance has registered middleware. Used for bulkSubscribe.
92 | ShareInstance.prototype._hasMiddleware = function(action) {
93 | return this.extensions[action];
94 | };
95 |
96 |
97 | /**
98 | * Passes request through the extensions stack
99 | *
100 | * Extensions may modify the request object. After all middlewares have been
101 | * invoked we call `callback` with `null` and the modified request.
102 | * If one of the extensions resturns an error the callback is called with that
103 | * error.
104 | */
105 | ShareInstance.prototype._trigger = function(request, callback) {
106 | // Copying the triggers we'll fire so they don't get edited while we iterate.
107 | var middlewares = (this.extensions[request.action] || []).concat(this.extensions['']);
108 |
109 | var next = function() {
110 | if (!middlewares.length)
111 | return callback ? callback(null, request) : undefined;
112 |
113 | var middleware = middlewares.shift();
114 | middleware(request, function(err) {
115 | if (err) return callback ? callback(err) : undefined;
116 |
117 | next();
118 | });
119 | };
120 |
121 | next();
122 | };
123 |
124 | exports.createClient = function(options) {
125 | return new ShareInstance(options);
126 | };
127 |
128 |
--------------------------------------------------------------------------------
/lib/server/rest.js:
--------------------------------------------------------------------------------
1 | // This implements ShareJS's REST API.
2 |
3 | var Router = require('express').Router;
4 | var url = require('url');
5 |
6 |
7 | // **** Utility functions
8 |
9 |
10 | var send403 = function(res, message) {
11 | if (message == null) message = 'Forbidden\n';
12 |
13 | res.writeHead(403, {'Content-Type': 'text/plain'});
14 | res.end(message);
15 | };
16 |
17 | var send404 = function(res, message) {
18 | if (message == null) message = '404: Your document could not be found.\n';
19 |
20 | res.writeHead(404, {'Content-Type': 'text/plain'});
21 | res.end(message);
22 | };
23 |
24 | var send409 = function(res, message) {
25 | if (message == null) message = '409: Your operation could not be applied.\n';
26 |
27 | res.writeHead(409, {'Content-Type': 'text/plain'});
28 | res.end(message);
29 | };
30 |
31 | var sendError = function(res, message, head) {
32 | if (message === 'forbidden') {
33 | if (head) {
34 | send403(res, "");
35 | } else {
36 | send403(res);
37 | }
38 | } else if (message === 'Document created remotely') {
39 | if (head) {
40 | send409(res, "");
41 | } else {
42 | send409(res, message + '\n');
43 | }
44 | } else {
45 | //console.warn("REST server does not know how to send error:", message);
46 | if (head) {
47 | res.writeHead(500, {});
48 | res.end("");
49 | } else {
50 | res.writeHead(500, {'Content-Type': 'text/plain'});
51 | res.end("Error: " + message + "\n");
52 | }
53 | }
54 | };
55 |
56 | var send400 = function(res, message) {
57 | res.writeHead(400, {'Content-Type': 'text/plain'});
58 | res.end(message);
59 | };
60 |
61 | var send200 = function(res, message) {
62 | if (message == null) message = "OK\n";
63 |
64 | res.writeHead(200, {'Content-Type': 'text/plain'});
65 | res.end(message);
66 | };
67 |
68 | var sendJSON = function(res, obj) {
69 | res.writeHead(200, {'Content-Type': 'application/json'});
70 | res.end(JSON.stringify(obj) + '\n');
71 | };
72 |
73 | // Expect the request to contain JSON data. Read all the data and try to JSON
74 | // parse it.
75 | var expectJSONObject = function(req, res, callback) {
76 | pump(req, function(data) {
77 | var obj;
78 | try {
79 | obj = JSON.parse(data);
80 | } catch (err) {
81 | send400(res, 'Supplied JSON invalid');
82 | return;
83 | }
84 |
85 | return callback(obj);
86 | });
87 | };
88 |
89 | var pump = function(req, callback) {
90 | // Currently using the old streams API..
91 | var data = '';
92 | req.on('data', function(chunk) {
93 | return data += chunk;
94 | });
95 | return req.on('end', function() {
96 | return callback(data);
97 | });
98 | };
99 |
100 |
101 |
102 | // ***** Actual logic
103 |
104 | module.exports = function(share) {
105 | var router = new Router();
106 |
107 | var auth = function(req, res, next) {
108 | if (req.session && req.session.shareAgent) {
109 | req._shareAgent = req.session.shareAgent;
110 | } else {
111 | var userAgent = req._shareAgent = share.createAgent(req);
112 | if (req.session) req.session.shareAgent = userAgent;
113 | }
114 |
115 | next();
116 | };
117 |
118 |
119 | // GET returns the document snapshot. The version and type are sent as headers.
120 | // I'm not sure what to do with document metadata - it is inaccessable for now.
121 | router.get('/:cName/:docName', auth, function(req, res, next) {
122 | req._shareAgent.fetch(req.params.cName, req.params.docName, function(err, doc) {
123 | if (err) {
124 | if (req.method === "HEAD") {
125 | sendError(res, err, true);
126 | } else {
127 | sendError(res, err);
128 | }
129 | return;
130 | }
131 |
132 | res.setHeader('X-OT-Version', doc.v);
133 |
134 | if (!doc.type) {
135 | send404(res, 'Document does not exist\n');
136 | return;
137 | }
138 |
139 | res.setHeader('X-OT-Type', doc.type);
140 | res.setHeader('ETag', doc.v);
141 |
142 | // If not GET request, presume HEAD request
143 | if (req.method !== 'GET') {
144 | send200(res, '');
145 | return;
146 | }
147 |
148 | var content;
149 | var query = url.parse(req.url,true).query;
150 | if (query.envelope == 'true')
151 | {
152 | content = doc;
153 | } else {
154 | content = doc.data;
155 | }
156 |
157 | if (typeof doc.data === 'string') {
158 | send200(res, content);
159 | } else {
160 | sendJSON(res, content);
161 | }
162 | });
163 | });
164 |
165 | // Get operations. You can use from:X and to:X to specify the range of ops you want.
166 | router.get('/:cName/:docName/ops', auth, function(req, res, next) {
167 | var from = 0, to = null;
168 |
169 | var query = url.parse(req.url, true).query;
170 |
171 | if (query && query.from) from = parseInt(query.from)|0;
172 | if (query && query.to) to = parseInt(query.to)|0;
173 |
174 | req._shareAgent.getOps(req.params.cName, req.params.docName, from, to, function(err, ops) {
175 | if (err)
176 | sendError(res, err);
177 | else
178 | sendJSON(res, ops);
179 | });
180 | });
181 |
182 | var submit = function(req, res, opData, sendOps) {
183 | // The backend allows the version to be unspecified - it assumes the most
184 | // recent version in that case. This is useful behaviour when you want to
185 | // create a document.
186 | req._shareAgent.submit(req.params.cName, req.params.docName, opData, {}, function(err, v, ops) {
187 | if (err) return sendError(res, err);
188 |
189 | res.setHeader('X-OT-Version', v);
190 | if (sendOps)
191 | sendJSON(res, ops);
192 | else
193 | send200(res);
194 | });
195 | };
196 |
197 | // POST submits op data to the document. POST {op:[...], v:100}
198 | router.post('/:cName/:docName', auth, function(req, res, next) {
199 | expectJSONObject(req, res, function(opData) {
200 | submit(req, res, opData, true);
201 | });
202 | });
203 |
204 |
205 | // PUT is used to create a document. The contents are a JSON object with
206 | // {type:TYPENAME, data:{initial data} meta:{...}}
207 | // PUT {...} is equivalent to POST {create:{...}}
208 | router.put('/:cName/:docName', auth, function(req, res, next) {
209 | expectJSONObject(req, res, function(create) {
210 | submit(req, res, {create:create});
211 | });
212 | });
213 |
214 | // DELETE deletes a document. It is equivalent to POST {del:true}
215 | router.delete('/:cName/:docName', auth, function(req, res, next) {
216 | submit(req, res, {del:true});
217 | });
218 |
219 | return router.middleware;
220 | };
221 |
222 |
--------------------------------------------------------------------------------
/lib/server/useragent.js:
--------------------------------------------------------------------------------
1 | var hat = require('hat');
2 | var TransformStream = require('stream').Transform;
3 | var async = require('async');
4 |
5 |
6 | /**
7 | * Provides access to the backend of `instance`.
8 | *
9 | * Create a user agent accessing a share instance
10 | *
11 | * userAgent = new UserAgent(instance)
12 | *
13 | * The user agent exposes the following API to communicate asynchronously with
14 | * the share instances backend.
15 | * - submit (submit)
16 | * - fetch (fetch)
17 | * - subscribe (subscribe)
18 | * - getOps (get ops)
19 | * - query (query)
20 | * - queryFetch (query)
21 | *
22 | *
23 | * Middleware
24 | * ----------
25 | * Each of the API methods also triggers an action (given in brackets) on the
26 | * share instance. This enables middleware to modifiy the requests and results
27 | * By default the request passed to the middleware contains the properties
28 | * - action
29 | * - agent
30 | * - backend
31 | * - collection
32 | * - docName
33 | * The `collection` and `docName` properties are only set if applicable. In
34 | * addition each API method extends the request object with custom properties.
35 | * These are documented with the methods.
36 | *
37 | *
38 | * Filters
39 | * -------
40 | * The documents provided by the `fetch`, `query` and `queryFetch` methods are
41 | * filtered with the share instance's `docFilters`.
42 | *
43 | * instance.filter(function(collection, docName, docData, next) {
44 | * if (docName == "mario") {
45 | * docData.greeting = "It'se me: Mario";
46 | * next();
47 | * } else {
48 | * next("Document not found!");
49 | * }
50 | * });
51 | * userAgent.fetch('people', 'mario', function(error, data) {
52 | * data.greeting; // It'se me
53 | * });
54 | * userAgent.fetch('people', 'peaches', function(error, data) {
55 | * error == "Document not found!";
56 | * });
57 | *
58 | * In a filter `this` is the user agent.
59 | *
60 | * Similarily we can filter the operations that a client can see
61 | *
62 | * instance.filterOps(function(collection, docName, opData, next) {
63 | * if (opData.op == 'cheat')
64 | * next("Not on my watch!");
65 | * else
66 | * next();
67 | * }
68 | * });
69 | *
70 | */
71 | var UserAgent = function(instance, stream) {
72 | this.instance = instance;
73 | this.backend = instance.backend;
74 |
75 | this.stream = stream;
76 | this.sessionId = hat();
77 |
78 | this.connectTime = new Date();
79 | };
80 |
81 | module.exports = UserAgent;
82 |
83 | /**
84 | * Helper to run the filters over some data. Returns an error string on error,
85 | * or nothing on success. Data is modified in place.
86 | */
87 | UserAgent.prototype._runFilters = function(filters, collection, docName, data, callback) {
88 | var self = this;
89 | async.eachSeries(filters, function(filter, next) {
90 | filter.call(self, collection, docName, data, next);
91 | }, function(error) {
92 | callback(error, error ? null : data);
93 | });
94 | };
95 |
96 | UserAgent.prototype.filterDoc = function(collection, docName, data, callback) {
97 | return this._runFilters(this.instance.docFilters, collection, docName, data, callback);
98 | };
99 | UserAgent.prototype.filterOp = function(collection, docName, data, callback) {
100 | return this._runFilters(this.instance.opFilters, collection, docName, data, callback);
101 | };
102 |
103 | // This is only used by bulkFetch, but its enough logic that I prefer to
104 | // separate it out.
105 | //
106 | // data is a map from collection name -> doc name -> data.
107 | UserAgent.prototype.filterDocs = function(data, callback) {
108 | var work = 1;
109 | var done = function() {
110 | work--;
111 | if (work === 0 && callback) callback(null, data);
112 | }
113 |
114 | for (var cName in data) {
115 | for (var docName in data[cName]) {
116 | work++;
117 | this.filterDoc(cName, docName, data[cName][docName], function(err) {
118 | if (err && callback) {
119 | callback(err);
120 | callback = null;
121 | }
122 | // Clean up call stack
123 | process.nextTick(done);
124 | });
125 | }
126 | }
127 | done();
128 | };
129 |
130 | UserAgent.prototype.filterOps = function(collection, docName, ops, callback) {
131 | if (!ops) return callback();
132 | var agent = this;
133 | var i = 0;
134 | (function next(err) {
135 | if (err) return callback(err);
136 | var op = ops[i++];
137 | if (op) {
138 | // Clean up call stack. Would it be better to modulus the iterator and
139 | // only introduce next tick every nth iteration?
140 | process.nextTick(function() {
141 | agent.filterOp(collection, docName, op, next);
142 | });
143 | } else {
144 | callback(null, ops);
145 | }
146 | })();
147 | };
148 |
149 | /**
150 | * Builds a request, passes it through the instance's extension stack for the
151 | * action and calls callback with the request.
152 | */
153 | UserAgent.prototype.trigger = function(action, collection, docName, request, callback) {
154 | if (typeof request === 'function') {
155 | callback = request;
156 | request = {};
157 | }
158 |
159 | request.agent = this;
160 | request.action = action;
161 | if (collection) request.collection = collection;
162 | if (docName) request.docName = docName;
163 | request.backend = this.backend;
164 |
165 | this.instance._trigger(request, callback);
166 | };
167 |
168 |
169 | /**
170 | * Fetch current snapshot of a document
171 | *
172 | * Triggers the `fetch` action. The actual fetch is performed with collection
173 | * and docName from the middleware request.
174 | */
175 | UserAgent.prototype.fetch = function(collection, docName, callback) {
176 | var agent = this;
177 |
178 | agent.trigger('fetch', collection, docName, function(err, action) {
179 | if (err) return callback(err);
180 | collection = action.collection;
181 | docName = action.docName;
182 |
183 | agent.backend.fetch(collection, docName, function(err, data) {
184 | if (err) return callback(err);
185 | if (data) {
186 | agent.filterDoc(collection, docName, data, callback);
187 | } else {
188 | callback(null, data);
189 | }
190 | });
191 | });
192 | };
193 |
194 | var bulkFetchRequestsEmpty = function(requests) {
195 | for (var cName in requests) {
196 | if (requests[cName].length) return false;
197 | }
198 | return true;
199 | };
200 |
201 | // requests is a map from collection -> [docName]
202 | UserAgent.prototype.bulkFetch = function(requests, callback) {
203 | var agent = this;
204 |
205 | if (bulkFetchRequestsEmpty(requests)) return callback(null, {});
206 |
207 | if (this.instance._hasMiddleware('bulk fetch') || !this.instance._hasMiddleware('fetch')) {
208 | agent.trigger('bulk fetch', null, null, {requests:requests}, function(err, action) {
209 | if (err) return callback(err);
210 | requests = action.requests;
211 |
212 | agent.backend.bulkFetch(requests, function(err, data) {
213 | if (err) return callback(err);
214 |
215 | agent.filterDocs(data, callback);
216 | });
217 | });
218 | } else {
219 | // Could implement this using async...
220 | throw Error('If you have fetch middleware you need to also make bulk fetch middleware');
221 | }
222 | };
223 |
224 |
225 | /**
226 | * Get all operations on this document with version in [start, end).
227 | *
228 | * Tiggers `get ops` action with requst
229 | * { start: start, end: end }
230 | */
231 | UserAgent.prototype.getOps = function(collection, docName, start, end, callback) {
232 | var agent = this;
233 |
234 | agent.trigger('get ops', collection, docName, {start:start, end:end}, function(err, action) {
235 | if (err) return callback(err);
236 |
237 | agent.backend.getOps(action.collection, action.docName, start, end, function(err, results) {
238 | if (err) return callback(err);
239 |
240 | agent.filterOps(collection, docName, results, callback);
241 | });
242 | });
243 | };
244 |
245 | function OpTransformStream(agent, collection, docName, stream) {
246 | TransformStream.call(this, {objectMode: true});
247 | this.agent = agent;
248 | this.collection = collection;
249 | this.docName = docName;
250 | this.stream = stream;
251 | }
252 |
253 | OpTransformStream.prototype = Object.create(TransformStream.prototype);
254 |
255 | OpTransformStream.prototype.destroy = function() {
256 | this.stream.destroy();
257 | };
258 |
259 | OpTransformStream.prototype._transform = function(data, encoding, callback) {
260 | var filterOpCallback = getFilterOpCallback(this, callback);
261 | this.agent.filterOp(this.collection, this.docName, data, filterOpCallback);
262 | };
263 |
264 | function getFilterOpCallback(opTransformStream, callback) {
265 | return function filterOpCallback(err, data) {
266 | opTransformStream.push(err ? {error: err} : data);
267 | callback();
268 | };
269 | }
270 |
271 | /**
272 | * Filter the data passed through the stream with `filterOp()`
273 | *
274 | * Returns a new stream that let's us only read these messages from stream wich
275 | * where not filtered by `this.filterOp(collection, docName, message)`. If the
276 | * filter chain calls an error we read a `{error: 'description'}` message from the
277 | * stream.
278 | */
279 | UserAgent.prototype.wrapOpStream = function(collection, docName, stream) {
280 | var opTransformStream = new OpTransformStream(this, collection, docName, stream);
281 | stream.pipe(opTransformStream);
282 | return opTransformStream;
283 | };
284 |
285 |
286 | /**
287 | * Apply `wrapOpStream()` to each stream
288 | *
289 | * `streams` is a map `collection -> docName -> stream`. It returns the same map
290 | * with the streams wrapped.
291 | */
292 | UserAgent.prototype.wrapOpStreams = function(streams) {
293 | for (var cName in streams) {
294 | for (var docName in streams[cName]) {
295 | streams[cName][docName] = this.wrapOpStream(cName, docName, streams[cName][docName]);
296 | }
297 | }
298 | return streams;
299 | };
300 |
301 |
302 | /**
303 | * Get stream of operations for a document.
304 | *
305 | * On success it resturns a readable stream of operations for this document.
306 | *
307 | * Triggers the `subscribe` action with request
308 | * { version: version }
309 | */
310 | UserAgent.prototype.subscribe = function(collection, docName, version, callback) {
311 | var agent = this;
312 | agent.trigger('subscribe', collection, docName, {version:version}, function(err, action) {
313 | if (err) return callback(err);
314 | collection = action.collection;
315 | docName = action.docName;
316 | version = action.version;
317 | agent.backend.subscribe(collection, docName, version, function(err, stream) {
318 | callback(err, err ? null : agent.wrapOpStream(collection, docName, stream));
319 | });
320 | });
321 | };
322 |
323 | // requests is a map from cName -> docName -> version.
324 | UserAgent.prototype.bulkSubscribe = function(requests, callback) {
325 | var agent = this;
326 | // Use a bulk subscribe to check everything in one go.
327 | agent.trigger('bulk subscribe', null, null, {requests: requests}, function(err, action) {
328 | if (err) return callback(err);
329 | agent.backend.bulkSubscribe(action.requests, function(err, streams) {
330 | callback(err, err ? null : agent.wrapOpStreams(streams));
331 | });
332 | });
333 | };
334 |
335 |
336 | // DEPRECATED - just call fetch() then subscribe() yourself.
337 | UserAgent.prototype.fetchAndSubscribe = function(collection, docName, callback) {
338 | var agent = this;
339 | agent.trigger('fetch', collection, docName, function(err, action) {
340 | if (err) return callback(err);
341 | agent.trigger('subscribe', action.collection, action.docName, function(err, action) {
342 | if (err) return callback(err);
343 |
344 | collection = action.collection;
345 | docName = action.docName;
346 | agent.backend.fetchAndSubscribe(action.collection, action.docName, function(err, data, stream) {
347 | if (err) return callback(err);
348 | agent.filterDoc(collection, docName, data, function (err, data) {
349 | if (err) return callback(err);
350 | var wrappedStream = agent.wrapOpStream(collection, docName, stream);
351 | callback(null, data, wrappedStream);
352 | });
353 | });
354 | });
355 | });
356 | };
357 |
358 |
359 | /**
360 | * Submits an operation.
361 | *
362 | * On success it returns the version and the operation.
363 | *
364 | * Triggers the `submit` action with request
365 | * { opData: opData, channelPrefix: null }
366 | * and the `after submit` action with the request
367 | * { opData: opData, snapshot: modifiedSnapshot }
368 | */
369 | UserAgent.prototype.submit = function(collection, docName, opData, options, callback) {
370 | if (typeof options === 'function') {
371 | callback = options;
372 | options = {};
373 | }
374 |
375 | var agent = this;
376 | agent.trigger('submit', collection, docName, {opData: opData, channelPrefix:null}, function(err, action) {
377 | if (err) return callback(err);
378 |
379 | collection = action.collection;
380 | docName = action.docName;
381 | opData = action.opData;
382 | options.channelPrefix = action.channelPrefix;
383 |
384 | if (!opData.preValidate) opData.preValidate = agent.instance.preValidate;
385 | if (!opData.validate) opData.validate = agent.instance.validate;
386 |
387 | agent.backend.submit(collection, docName, opData, options, function(err, v, ops, snapshot) {
388 | if (err) return callback(err);
389 | agent.trigger('after submit', collection, docName, {opData: opData, snapshot: snapshot}, function(err) {
390 | if (err) return callback(err);
391 | agent.filterOps(collection, docName, ops, function(err, filteredOps) {
392 | if (err) return callback(err);
393 | callback(null, v, filteredOps);
394 | });
395 | });
396 | });
397 | });
398 | };
399 |
400 | /** Helper to filter query result sets */
401 | UserAgent.prototype._filterQueryResults = function(collection, results, callback) {
402 | // The filter function is asyncronous. We can run all of the query results in parallel.
403 | var agent = this;
404 | async.each(results, function(data, innerCb) {
405 | agent.filterDoc(collection, data.docName, data, innerCb);
406 | }, function(error) {
407 | callback(error, results);
408 | });
409 | };
410 |
411 |
412 | /**
413 | * Execute a query and fetch matching documents.
414 | *
415 | * The result is an array of the matching documents. Each document has in
416 | * addtion the `docName` property set to its name.
417 | *
418 | * Triggers the `query` action with the request
419 | * { query: query, fetch: true, options: options }
420 | */
421 | UserAgent.prototype.queryFetch = function(collection, query, options, callback) {
422 | var agent = this;
423 | // Should we emit 'query' or 'query fetch' here?
424 | agent.trigger('query', collection, null, {query:query, fetch:true, options: options}, function(err, action) {
425 | if (err) return callback(err);
426 |
427 | collection = action.collection;
428 | query = action.query;
429 |
430 | agent.backend.queryFetch(collection, query, options, function(err, results, extra) {
431 | if (err) return callback(err);
432 | agent._filterQueryResults(collection, results, function(err, results) {
433 | if (err) return callback(err);
434 | callback(null, results, extra);
435 | });
436 | });
437 | });
438 | };
439 |
440 |
441 | /**
442 | * Get an QueryEmitter for the query
443 | *
444 | * The returned emitter fires 'diff' event every time the result of the query
445 | * changes.
446 | *
447 | * Triggers the `query` action with the request
448 | * { query: query, options: options }
449 | */
450 | UserAgent.prototype.query = function(collection, query, options, callback) {
451 | var agent = this;
452 | agent.trigger('query', collection, null, {query: query, options: options}, function(err, action) {
453 | if (err) return callback(err);
454 |
455 | collection = action.collection;
456 | query = action.query;
457 |
458 | agent.backend.query(collection, query, options, function(err, emitter, results, extra) {
459 | if (err) return callback(err);
460 |
461 | // Override emitDiff to filter all inserted items
462 | emitter.emitDiff = function(diff) {
463 | async.each(diff, function(item, cb) {
464 | if (item.type === 'insert') {
465 | agent._filterQueryResults(collection, item.values, cb);
466 | return;
467 | }
468 | cb();
469 | }, function(err) {
470 | if (err) return emitter.emitError(err);
471 | emitter.onDiff(diff);
472 | });
473 | };
474 |
475 | // TODO: This is buggy. If the emitter emits a diff during a slow piece of
476 | // middleware, it'll be lost.
477 | agent._filterQueryResults(collection, results, function(err, results) {
478 | // Also if there's an error here, the emitter is never removed.
479 | if (err) return callback(err);
480 | callback(null, emitter, results, extra);
481 | });
482 | });
483 | });
484 | };
485 |
--------------------------------------------------------------------------------
/lib/types/README.md:
--------------------------------------------------------------------------------
1 | This directory contains wrappers for the OT types. The types themselves are in [josephg/ot-types](https://github.com/josephg/ot-types).
2 |
3 | The wrapper methods are mixed into the client's Doc object when a document is created.
4 | They are designed to give users better, more consistant APIs for constructing operations. All of the text bindings use
5 | the nice APIs so if you want to invent your own wacky type, you can still use all the editor bindings.
6 |
7 | For example, the three text types defined here (text, text-composable and text-tp2) all provide the text API, supplying
8 | `.insert()`, `.del()`, `.getLength` and `.getText` methods.
9 |
10 | See text-api.js for an example.
11 |
12 |
--------------------------------------------------------------------------------
/lib/types/index.js:
--------------------------------------------------------------------------------
1 |
2 | exports.ottypes = {};
3 | exports.registerType = function(type) {
4 | if (type.name) exports.ottypes[type.name] = type;
5 | if (type.uri) exports.ottypes[type.uri] = type;
6 | };
7 |
8 | exports.registerType(require('ot-json0').type);
9 | exports.registerType(require('ot-text').type);
10 | exports.registerType(require('ot-text-tp2').type);
11 |
12 | // The types register themselves on their respective types.
13 | require('./text-api');
14 | require('./text-tp2-api');
15 |
16 | // The JSON API is buggy!! Please submit a pull request fixing it if you want to use it.
17 | //require('./json-api');
18 |
--------------------------------------------------------------------------------
/lib/types/json-api.js:
--------------------------------------------------------------------------------
1 | // JSON document API for the 'json0' type.
2 |
3 | var type = require('ot-json0').type;
4 |
5 | // Helpers
6 |
7 | function depath(path) {
8 | if (path.length === 1 && path[0].constructor === Array) {
9 | return path[0];
10 | } else {
11 | return path;
12 | }
13 | }
14 |
15 | function traverse(snapshot, path) {
16 | var key = 'data';
17 | var elem = { data: snapshot };
18 |
19 | for (var i = 0; i < path.length; i++) {
20 | elem = elem[key];
21 | key = path[i];
22 | if (typeof elem === 'undefined') {
23 | throw new Error('bad path');
24 | }
25 | }
26 |
27 | return {
28 | elem: elem,
29 | key: key
30 | };
31 | }
32 |
33 | function pathEquals(p1, p2) {
34 | if (p1.length !== p2.length) {
35 | return false;
36 | }
37 | for (var i = 0; i < p1.length; ++i) {
38 | if (p1[i] !== p2[i]) {
39 | return false;
40 | }
41 | }
42 | return true;
43 | }
44 |
45 | function containsPath(p1, p2) {
46 | if (p1.length < p2.length) return false;
47 | return pathEquals( p1.slice(0,p2.length), p2);
48 | }
49 |
50 | // does nothing, used as a default callback
51 | function nullFunction() {}
52 |
53 | // given a path represented as an array or a number, normalize to an array
54 | // whole numbers are converted to integers.
55 | function normalizePath(path) {
56 | if (path instanceof Array) {
57 | return path;
58 | }
59 | if (typeof(path) == "number") {
60 | return [path];
61 | }
62 | // if (typeof(path) == "string") {
63 | // path = path.split(".");
64 | // var out = [];
65 | // for (var i=0; i 1 && typeof args[args.length-1] !== 'function') {
84 | args.push(nullFunction);
85 | }
86 |
87 | if (args.length < (requiredArgsCount || func.length)) {
88 | args.unshift(path_prefix);
89 | } else {
90 | args[0] = path_prefix.concat(normalizePath(args[0]));
91 | }
92 |
93 | return func.apply(obj,args);
94 | }
95 |
96 |
97 | // SubDoc
98 | // this object is returned from context.createContextAt()
99 |
100 | var SubDoc = function(context, path) {
101 | this.context = context;
102 | this.path = path || [];
103 | };
104 |
105 | SubDoc.prototype._updatePath = function(op){
106 | for (var i = 0; i < op.length; i++) {
107 | var c = op[i];
108 | if(c.lm !== undefined && containsPath(this.path,c.p)){
109 | var new_path_prefix = c.p.slice(0,c.p.length-1);
110 | new_path_prefix.push(c.lm);
111 | this.path = new_path_prefix.concat(this.path.slice(new_path_prefix.length));
112 | }
113 | }
114 | };
115 |
116 | SubDoc.prototype.createContextAt = function() {
117 | var path = 1 <= arguments.length ? Array.prototype.slice.call(arguments, 0) : [];
118 | return this.context.createContextAt(this.path.concat(depath(path)));
119 | };
120 |
121 | SubDoc.prototype.get = function(path) {
122 | return normalizeArgs(this, arguments, function(path){
123 | return this.context.get(path);
124 | });
125 | };
126 |
127 | SubDoc.prototype.set = function(path, value, cb) {
128 | return normalizeArgs(this, arguments, function(path, value, cb){
129 | return this.context.set(path, value, cb);
130 | });
131 | };
132 |
133 | SubDoc.prototype.insert = function(path, value, cb) {
134 | return normalizeArgs(this, arguments, function(path, value, cb){
135 | return this.context.insert(path, value, cb);
136 | });
137 | };
138 |
139 | SubDoc.prototype.remove = function(path, len, cb) {
140 | return normalizeArgs(this, arguments, function(path, len, cb) {
141 | return this.context.remove(path, len, cb);
142 | }, 2);
143 | };
144 |
145 | SubDoc.prototype.push = function(path, value, cb) {
146 | return normalizeArgs(this, arguments, function(path, value, cb) {
147 | var _ref = traverse(this.context.getSnapshot(), path);
148 | var len = _ref.elem[_ref.key].length;
149 | path.push(len);
150 | return this.context.insert(path, value, cb);
151 | });
152 | };
153 |
154 | SubDoc.prototype.move = function(path, from, to, cb) {
155 | return normalizeArgs(this, arguments, function(path, from, to, cb) {
156 | return this.context.move(path, from, to, cb);
157 | });
158 | };
159 |
160 | SubDoc.prototype.add = function(path, amount, cb) {
161 | return normalizeArgs(this, arguments, function(path, amount, cb) {
162 | return this.context.add(path, amount, cb);
163 | });
164 | };
165 |
166 | SubDoc.prototype.on = function(event, cb) {
167 | return this.context.addListener(this.path, event, cb);
168 | };
169 |
170 | SubDoc.prototype.removeListener = function(l) {
171 | return this.context.removeListener(l);
172 | };
173 |
174 | SubDoc.prototype.getLength = function(path) {
175 | return normalizeArgs(this, arguments, function(path) {
176 | return this.context.getLength(path);
177 | });
178 | };
179 |
180 | // DEPRECATED
181 | SubDoc.prototype.getText = function(path) {
182 | return normalizeArgs(this, arguments, function(path) {
183 | return this.context.getText(path);
184 | });
185 | };
186 |
187 | // DEPRECATED
188 | SubDoc.prototype.deleteText = function(path, pos, length, cb) {
189 | return normalizeArgs(this, arguments, function(path, pos, length, cb) {
190 | return this.context.deleteText(path, length, pos, cb);
191 | });
192 | };
193 |
194 | SubDoc.prototype.destroy = function() {
195 | this.context._removeSubDoc(this);
196 | };
197 |
198 |
199 | // JSON API methods
200 | // these methods are mixed in to the context return from doc.createContext()
201 |
202 | type.api = {
203 |
204 | provides: {
205 | json: true
206 | },
207 |
208 | _fixComponentPaths: function(c) {
209 | if (!this._listeners) {
210 | return;
211 | }
212 | if (c.na !== undefined || c.si !== undefined || c.sd !== undefined) {
213 | return;
214 | }
215 |
216 | var to_remove = [];
217 | var _ref = this._listeners;
218 |
219 | for (var i = 0; i < _ref.length; i++) {
220 | var l = _ref[i];
221 | var dummy = {
222 | p: l.path,
223 | na: 0
224 | };
225 | var xformed = type.transformComponent([], dummy, c, 'left');
226 | if (xformed.length === 0) {
227 | to_remove.push(i);
228 | } else if (xformed.length === 1) {
229 | l.path = xformed[0].p;
230 | } else {
231 | throw new Error("Bad assumption in json-api: xforming an 'na' op will always result in 0 or 1 components.");
232 | }
233 | }
234 |
235 | to_remove.sort(function(a, b) {
236 | return b - a;
237 | });
238 |
239 | var _results = [];
240 | for (var j = 0; j < to_remove.length; j++) {
241 | i = to_remove[j];
242 | _results.push(this._listeners.splice(i, 1));
243 | }
244 |
245 | return _results;
246 | },
247 |
248 | _fixPaths: function(op) {
249 | var _results = [];
250 | for (var i = 0; i < op.length; i++) {
251 | var c = op[i];
252 | _results.push(this._fixComponentPaths(c));
253 | }
254 | return _results;
255 | },
256 |
257 | _submit: function(op, callback) {
258 | this._fixPaths(op);
259 | return this.submitOp(op, callback);
260 | },
261 |
262 | _addSubDoc: function(subdoc){
263 | this._subdocs || (this._subdocs = []);
264 | this._subdocs.push(subdoc);
265 | },
266 |
267 | _removeSubDoc: function(subdoc){
268 | this._subdocs || (this._subdocs = []);
269 | for(var i = 0; i < this._subdocs.length; i++){
270 | if(this._subdocs[i] === subdoc) this._subdocs.splice(i,1);
271 | return;
272 | }
273 | },
274 |
275 | _updateSubdocPaths: function(op){
276 | this._subdocs || (this._subdocs = []);
277 | for(var i = 0; i < this._subdocs.length; i++){
278 | this._subdocs[i]._updatePath(op);
279 | }
280 | },
281 |
282 | createContextAt: function() {
283 | var path = 1 <= arguments.length ? Array.prototype.slice.call(arguments, 0) : [];
284 | var subdoc = new SubDoc(this, depath(path));
285 | this._addSubDoc(subdoc);
286 | return subdoc;
287 | },
288 |
289 | get: function(path) {
290 | if (!path) return this.getSnapshot();
291 | return normalizeArgs(this,arguments,function(path){
292 | var _ref = traverse(this.getSnapshot(), path);
293 | return _ref.elem[_ref.key];
294 | });
295 | },
296 |
297 | set: function(path, value, cb) {
298 | return normalizeArgs(this, arguments, function(path, value, cb) {
299 | var _ref = traverse(this.getSnapshot(), path);
300 | var elem = _ref.elem;
301 | var key = _ref.key;
302 | var op = {
303 | p: path
304 | };
305 |
306 | if (elem.constructor === Array) {
307 | op.li = value;
308 | if (typeof elem[key] !== 'undefined') {
309 | op.ld = elem[key];
310 | }
311 | } else if (typeof elem === 'object') {
312 | op.oi = value;
313 | if (typeof elem[key] !== 'undefined') {
314 | op.od = elem[key];
315 | }
316 | } else {
317 | throw new Error('bad path');
318 | }
319 |
320 | return this._submit([op], cb);
321 | });
322 | },
323 |
324 | remove: function(path, len, cb) {
325 | return normalizeArgs(this, arguments, function(path, len, cb) {
326 | if (!cb && len instanceof Function) {
327 | cb = len;
328 | len = null;
329 | }
330 | // if there is no len argument, then we are removing a single item from either a list or a hash
331 | var _ref, elem, op, key;
332 | if (len === null || len === undefined) {
333 | _ref = traverse(this.getSnapshot(), path);
334 | elem = _ref.elem;
335 | key = _ref.key;
336 | op = {
337 | p: path
338 | };
339 |
340 | if (typeof elem[key] === 'undefined') {
341 | throw new Error('no element at that path');
342 | }
343 |
344 | if (elem.constructor === Array) {
345 | op.ld = elem[key];
346 | } else if (typeof elem === 'object') {
347 | op.od = elem[key];
348 | } else {
349 | throw new Error('bad path');
350 | }
351 | return this._submit([op], cb);
352 | } else {
353 | var pos;
354 | pos = path.pop();
355 | _ref = traverse(this.getSnapshot(), path);
356 | elem = _ref.elem;
357 | key = _ref.key;
358 | if (typeof elem[key] === 'string') {
359 | op = {
360 | p: path.concat(pos),
361 | sd: _ref.elem[_ref.key].slice(pos, pos + len)
362 | };
363 | return this._submit([op], cb);
364 | } else if (elem[key].constructor === Array) {
365 | var ops = [];
366 | for (var i=pos; i 0) && pos.index < doc.data.length) {
9 | var part = takeDoc(doc, pos, maxlength, true);
10 | if (maxlength != null && typeof part === 'string') {
11 | maxlength -= part.length;
12 | }
13 | append(op, part.length || part);
14 | }
15 | };
16 |
17 | type.api = {
18 | provides: {text: true},
19 |
20 | // Number of characters in the string
21 | getLength: function() { return this.getSnapshot().charLength; },
22 |
23 | // Flatten the document into a string
24 | get: function() {
25 | var snapshot = this.getSnapshot();
26 | var strings = [];
27 |
28 | for (var i = 0; i < snapshot.data.length; i++) {
29 | var elem = snapshot.data[i];
30 | if (typeof elem == 'string') {
31 | strings.push(elem);
32 | }
33 | }
34 |
35 | return strings.join('');
36 | },
37 |
38 | getText: function() {
39 | console.warn("`getText()` is deprecated; use `get()` instead.");
40 | return this.get();
41 | },
42 |
43 | // Insert text at pos
44 | insert: function(pos, text, callback) {
45 | if (pos == null) pos = 0;
46 |
47 | var op = [];
48 | var docPos = {index: 0, offset: 0};
49 | var snapshot = this.getSnapshot();
50 |
51 | // Skip to the specified position
52 | appendSkipChars(op, snapshot, docPos, pos);
53 |
54 | // Append the text
55 | append(op, {i: text});
56 | appendSkipChars(op, snapshot, docPos);
57 | this.submitOp(op, callback);
58 | return op;
59 | },
60 |
61 | // Remove length of text at pos
62 | remove: function(pos, len, callback) {
63 | var op = [];
64 | var docPos = {index: 0, offset: 0};
65 | var snapshot = this.getSnapshot();
66 |
67 | // Skip to the position
68 | appendSkipChars(op, snapshot, docPos, pos);
69 |
70 | while (len > 0) {
71 | var part = takeDoc(snapshot, docPos, len, true);
72 |
73 | // We only need to delete actual characters. This should also be valid if
74 | // we deleted all the tombstones in the document here.
75 | if (typeof part === 'string') {
76 | append(op, {d: part.length});
77 | len -= part.length;
78 | } else {
79 | append(op, part);
80 | }
81 | }
82 |
83 | appendSkipChars(op, snapshot, docPos);
84 | this.submitOp(op, callback);
85 | return op;
86 | },
87 |
88 | _beforeOp: function() {
89 | // Its a shame we need this. This also currently relies on snapshots being
90 | // cloned during apply(). This is used in _onOp below to figure out what
91 | // text was _actually_ inserted and removed.
92 | //
93 | // Maybe instead we should do all the _onOp logic here and store the result
94 | // then play the events when _onOp is actually called or something.
95 | this.__prevSnapshot = this.getSnapshot();
96 | },
97 |
98 | _onOp: function(op) {
99 | var textPos = 0;
100 | var docPos = {index:0, offset:0};
101 | // The snapshot we get here is the document state _AFTER_ the specified op
102 | // has been applied. That means any deleted characters are now tombstones.
103 | var prevSnapshot = this.__prevSnapshot;
104 |
105 | for (var i = 0; i < op.length; i++) {
106 | var component = op[i];
107 | var part, remainder;
108 |
109 | if (typeof component == 'number') {
110 | // Skip
111 | for (remainder = component;
112 | remainder > 0;
113 | remainder -= part.length || part) {
114 |
115 | part = takeDoc(prevSnapshot, docPos, remainder);
116 | if (typeof part === 'string')
117 | textPos += part.length;
118 | }
119 | } else if (component.i != null) {
120 | // Insert
121 | if (typeof component.i == 'string') {
122 | // ... and its an insert of text, not insert of tombstones
123 | if (this.onInsert) this.onInsert(textPos, component.i);
124 | textPos += component.i.length;
125 | }
126 | } else {
127 | // Delete
128 | for (remainder = component.d;
129 | remainder > 0;
130 | remainder -= part.length || part) {
131 |
132 | part = takeDoc(prevSnapshot, docPos, remainder);
133 | if (typeof part == 'string' && this.onRemove)
134 | this.onRemove(textPos, part.length);
135 | }
136 | }
137 | }
138 | }
139 | };
140 |
--------------------------------------------------------------------------------
/metadata.md:
--------------------------------------------------------------------------------
1 | # Document Metadata Design Proposal
2 |
3 | > This feature isn't fully implemented yet
4 |
5 | I'm planning on adding metadata support to documents. The idea is to add a sidechannel for data like creation time, cursor positions, connected users, etc.
6 |
7 | ## New document interface
8 |
9 | A document will consist of:
10 |
11 | - **Version**: A version number counting from 0
12 | - **Snapshot**: The current document contents
13 | - **Meta**: *(NEW, not yet implemented)* An object containing misc data about the document:
14 | - Arbitrary user data, set when an object is created. Editable by the server only.
15 | - **Creation time**
16 | - **Last modified time**: This is updated automatically on each client each time it sees a document operation
17 | - **Session data**: An object with an entry for every client that is currently connected. Map clientIds to:
18 | - **Username** (optional)
19 | - **Cursor position** (type dependant)
20 | - **Connection time** maybe?
21 | - Any other user data. This can be filled in by the auth function when a client connects. (And maybe clients should be able to edit this as well?)
22 |
23 | Unlike the document data, metadata changes will not be persisted. Metadata changes will not bump the document's version number. (-wm: I assume that some things like the time stamps will be persisted? And what about when client IDs are stored with ops, shouldn't the user names be persisted?)
24 |
25 | Initial document metadata can be set at document creation time.
26 |
27 | Some metadata fields like last modified time and cursor positions will be updated automatically on all clients whenever an operation is submitted. (-wm: this will probably mean storing the time delta between the server and client)
28 |
29 | ## Metadata operations
30 |
31 | > This is implemented, but the only path handled now is 'shout'
32 |
33 | We also add a new kind of operation, a **meta operation**. I've thought about using the JSON OT type for this, but it means that if someone wants to implement the sharejs wire protocol, they have to implement JSON OT (which is really complicated). So I'm going to keep it simple.
34 |
35 | Metadata operations express changes in the metadata object. They look like this:
36 |
37 | - NOT **Version**: Metadata changes should be independent of current document version number
38 | - **Path**: List of object keys to reach the target of the change. All path elements except the last must already exist.
39 | - **New value**: *(optional)* JSON object which replaces whatever was at the metadata object before. If this is missing, the object is removed.
40 |
41 | Some paths are special:
42 |
43 | - ``['shout', ...]``: Broadcasts the value to all clients, doesn't keep it in memory. Full path is ignored.
44 | - ``['ctime']``, ``['mtime']``, ``['sessions']``: Read only, see above
45 |
46 | Metadata gets consistency guarantees by restricting who is allowed to submit metadata changes.
47 |
48 | The server can send metadata operations to clients:
49 |
50 | - [``shout``]: ``value`` is the value being broadcast, ``by`` is the clientid that shouted (not implemented). This results in an event emitted by Doc: ``('shout', value)``.
51 | > (Right now the full path used is given to the client, perhaps this could be used to send specific Doc events instead of just "shout", like ``('shout_foo', value)`` for ``path: ['shout', 'foo']``?)
52 |
53 | The model emits ``('applyMetaOp', path, value)`` for all successful meta operations
54 |
55 | ## Transforming cursor positions
56 |
57 | > not implemented
58 |
59 | Types can also specify cursor transforms. This is important to make cursors move as you edit content surrounding them.
60 |
61 | ```coffeescript
62 | TYPE.transformCursor = (position, operation) -> newPosition
63 | ```
64 |
65 | Clients are responsible for updating cursor positions in two scenarios:
66 |
67 | - When they generate operations locally they transform everyone's cursor position by the operation
68 | - When they receive updated cursor positions from the server against an old version
69 |
70 | The server will pre-transform cursor positions before rebroadcasting them.
71 |
72 |
73 | ## Expected usage
74 |
75 | - **A new client connects** - the server will add an entry corresponding to the client in the session data
76 | - **A user moves their cursor** - they send a metadata op which is broadcast via the server to indicate their new cursor position. (Note that cursor positions may be more complicated than just a number. Imagine a user exploring a spreadsheet...)
77 | - **The client sees a new document operation** It updates the last modified time of the session data using its local clock. It doesn't tell anyone else - they'll each have each made the same change locally as well.
78 |
79 | - - -
80 |
81 | #### Still to figure out
82 |
83 | - How does the client learn its own ID?
84 | - It could get another special metadata field when it opens a document
85 | - It could be told its ID when it gets its first message from the server, or when it opens a document
86 | - Are clients allowed to make arbitrary changes to the document's metadata?
87 | - What is the client API for querying cursor positions and getting notified of metadata changes?
88 |
89 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "share",
3 | "version": "0.7.40",
4 | "description": "A database for concurrent document editing",
5 | "keywords": [
6 | "operational transformation",
7 | "ot",
8 | "concurrent",
9 | "collaborative",
10 | "database",
11 | "server"
12 | ],
13 | "author": {
14 | "name": "Joseph Gentle",
15 | "email": "josephg@gmail.com"
16 | },
17 | "dependencies": {
18 | "async": "^0.9.0",
19 | "hat": "^0.0.3",
20 | "livedb": "^0.5.12",
21 | "ot-json0": "^1.0.0",
22 | "ot-text": "^1.0.0",
23 | "ot-text-tp2": "^1.0.0"
24 | },
25 | "devDependencies": {
26 | "browserchannel": "*",
27 | "browserify": "^10.0.0",
28 | "chai": "*",
29 | "coffee-script": "~1.7.x",
30 | "connect": "^3.3.0",
31 | "istanbul": "^0.3.13",
32 | "mocha": "^2.2.4",
33 | "optimist": ">= 0.2.4",
34 | "ot-fuzzer": "^1.0.0",
35 | "redis": "^0.12.1",
36 | "serve-static": "^1.9.2",
37 | "sinon": "^1.14.1",
38 | "uglify-js": "^2.4.20"
39 | },
40 | "optionalDependencies": {
41 | "express": "~3"
42 | },
43 | "engine": "node >= 0.10",
44 | "main": "lib/index.js",
45 | "scripts": {
46 | "build": "make",
47 | "test": "node_modules/mocha/bin/mocha test/server test/browser",
48 | "prepublish": "make",
49 | "coverage": "node node_modules/istanbul/lib/cli cover node_modules/mocha/bin/_mocha test/server test/browser"
50 | },
51 | "licenses": [
52 | {
53 | "type": "BSD",
54 | "url": "http://www.freebsd.org/copyright/freebsd-license.html"
55 | }
56 | ],
57 | "repository": {
58 | "type": "git",
59 | "url": "http://github.com/josephg/sharejs.git"
60 | },
61 | "bugs": {
62 | "url": "https://github.com/josephg/sharejs/issues"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/test/browser/connection.coffee:
--------------------------------------------------------------------------------
1 | assert = require 'assert'
2 | createServer = require '../helpers/server.coffee'
3 | createSocket = require '../helpers/socket.coffee'
4 |
5 |
6 | describe 'Connection', ->
7 | share = require('../../lib/client')
8 | Connection = share.Connection
9 |
10 | before ->
11 | @server = createServer()
12 | after (done) ->
13 | @server.close done
14 |
15 | describe 'connecting', ->
16 | it 'connects socket', (done)->
17 | socket = createSocket()
18 | socket.close()
19 | connection = new Connection(socket)
20 | connection.on 'connecting', ->
21 | socket.close()
22 | done()
23 | socket.open()
24 |
25 | it 'connects to sharejs', (done)->
26 | socket = createSocket()
27 | connection = new Connection(socket)
28 | connection.on 'connected', ->
29 | socket.close()
30 | done()
31 |
32 |
33 | describe '#get', ->
34 |
35 | before ->
36 | socket = createSocket()
37 | @connection = new Connection(socket)
38 |
39 | after ->
40 | @connection.socket.close()
41 |
42 | it 'returns a document', ->
43 | Doc = share.Doc
44 | doc = @connection.get('cars', 'porsche')
45 | assert.equal doc.constructor, Doc
46 |
47 | it 'always returns the same document', ->
48 | doc1 = @connection.get('cars', 'porsche')
49 | doc2 = @connection.get('cars', 'porsche')
50 | assert.equal doc1, doc2
51 |
--------------------------------------------------------------------------------
/test/browser/doc.coffee:
--------------------------------------------------------------------------------
1 | assert = require 'assert'
2 | sinon = require 'sinon'
3 | createSocket = require '../helpers/socket.coffee'
4 | createServer = require '../helpers/server.coffee'
5 | createFixtures = require '../helpers/fixtures.coffee'
6 |
7 | describe 'Doc', ->
8 | {Connection} = require('../../lib/client')
9 |
10 | before ->
11 | @connection = @alice = new Connection(createSocket())
12 | @bob = new Connection(createSocket())
13 |
14 | @alice.on 'error', (e) -> throw e
15 | @bob.on 'error', (e) -> throw e
16 | @server = createServer()
17 | @fixtures = createFixtures()
18 |
19 | after (done) ->
20 | @alice.socket.close()
21 | @bob.socket.close()
22 | @fixtures.close()
23 | @server.close done
24 |
25 | # Reset documents
26 | beforeEach ->
27 | @alice.collections = {}
28 | @bob.collections = {}
29 |
30 | describe '#create', ->
31 | afterEach (done) ->
32 | @fixtures.reset done
33 |
34 | it 'creates a document', (done) ->
35 | doc = @connection.get('garage', 'porsche')
36 | doc.create 'json0', {color: 'black'}, done
37 |
38 | it 'creates a document remotely data', (done) ->
39 | doc = @alice.get('garage', 'porsche')
40 | doc.create 'json0', {color: 'red'}, =>
41 | doc2 = @bob.get('garage', 'porsche')
42 | doc2.fetch (error) ->
43 | assert.deepEqual doc2.snapshot, color: 'red'
44 | done(error)
45 |
46 | it 'triggers created', (done) ->
47 | doc = @alice.get('garage', 'jaguar')
48 | oncreate = sinon.spy()
49 | doc.on 'create', oncreate
50 | doc.create 'json0', {color: 'british racing green'}, ->
51 | sinon.assert.calledOnce oncreate
52 | done()
53 |
54 | it 'sets state floating', (done) ->
55 | doc = @alice.get('garage', 'porsche')
56 | assert.equal doc.state, null
57 | doc.create 'json0', {color: 'white'}, done
58 | assert.equal doc.state, 'floating'
59 |
60 | it 'sets state ready on success', (done) ->
61 | doc = @alice.get('garage', 'porsche')
62 | assert.equal doc.state, null
63 | doc.create 'json0', {color: 'rose'}, (error) ->
64 | assert.equal doc.state, 'ready'
65 | done(error)
66 |
67 |
68 | describe '#del', ->
69 | afterEach (done) ->
70 | @fixtures.reset done
71 |
72 | it 'deletes doc remotely', (done) ->
73 | doc = @alice.get('garage', 'porsche')
74 | doc.create 'json0', {color: 'beige'}, false, =>
75 | doc.del false, =>
76 | doc2 = @bob.get('garage', 'porsche')
77 | doc2.fetch (error) ->
78 | assert.equal doc2.type, undefined
79 | assert.equal doc2.snapshot, undefined
80 | done(error)
81 |
82 |
83 | describe '#destroy', ->
84 | afterEach (done) ->
85 | @fixtures.reset done
86 |
87 | it 'removes doc from cache', ->
88 | doc = @alice.get('garage', 'porsche')
89 | assert.equal @alice.get('garage', 'porsche'), doc
90 | doc.destroy()
91 | assert.notEqual @alice.get('garage', 'porsche')
92 |
93 | describe '#submitOp', ->
94 | afterEach (done) ->
95 | @fixtures.reset done
96 |
97 | beforeEach (done) ->
98 | @doc = @alice.get('songs', 'dedododo')
99 | @doc.create 'text', '', false, done
100 |
101 | it 'applies operation locally', (done) ->
102 | @doc.submitOp ['dedadada'], false, =>
103 | assert.equal @doc.snapshot, 'dedadada'
104 | done()
105 |
106 | it 'applies operation remotely', (done) ->
107 | @doc.submitOp ['dont think'], false, =>
108 | doc2 = @bob.get('songs', 'dedododo')
109 | doc2.fetch (error) ->
110 | assert.equal doc2.snapshot, 'dont think'
111 | done(error)
112 |
--------------------------------------------------------------------------------
/test/browser/queries.coffee:
--------------------------------------------------------------------------------
1 | assert = require 'assert'
2 | {Connection} = require '../../lib/client'
3 | createSocket = require '../helpers/socket.coffee'
4 | createServer = require '../helpers/server.coffee'
5 | createFixtures = require '../helpers/fixtures.coffee'
6 |
7 | describe 'Queries', ->
8 |
9 | before ->
10 | @server = createServer()
11 | @fixtures = createFixtures()
12 |
13 | after (done) ->
14 | @fixtures.close()
15 | @server.close done
16 |
17 | beforeEach (done) ->
18 | @connection = new Connection(createSocket())
19 | @connection.get('cars', 'porsche').create 'text', 'red', =>
20 | @connection.get('cars', 'jaguar').create 'text', 'green', =>
21 | @connection.socket.close()
22 | @connection = new Connection(createSocket())
23 | done()
24 |
25 | afterEach (done) ->
26 | @connection.socket.close()
27 | @fixtures.reset done
28 |
29 | describe 'fetch', ->
30 | it 'returns documents', (done)->
31 | @connection.createFetchQuery 'cars', {}, {}, (error, documents)->
32 | assert.equal documents[0].name, 'porsche'
33 | assert.equal documents[1].name, 'jaguar'
34 | done()
35 |
36 | describe 'docMode: fetch', ->
37 |
38 | it 'returns documents with snapshots', (done)->
39 | @connection.createFetchQuery 'cars', {}, {docMode: 'fetch'}, (error, documents)->
40 | assert.equal documents[0].snapshot, 'red'
41 | assert.equal documents[1].snapshot, 'green'
42 | done()
43 |
44 | it 'populates connection documents', (done)->
45 | porsche = @connection.get('cars', 'porsche')
46 | assert.equal porsche.type, undefined
47 | assert.equal porsche.snapshot, undefined
48 | @connection.createFetchQuery 'cars', {}, {docMode: 'fetch'}, (error, documents)->
49 | assert.equal porsche.type.name, 'text'
50 | assert.equal porsche.snapshot, 'red'
51 | done()
52 |
53 |
54 | describe 'subscribe', ->
55 |
56 | it 'returns documents', (done)->
57 | @connection.createSubscribeQuery 'cars', {}, {}, (error, documents)->
58 | assert.equal documents[0].name, 'porsche'
59 | assert.equal documents[1].name, 'jaguar'
60 | done()
61 |
62 | it 'emits insert when creating document', (done)->
63 | query = @connection.createSubscribeQuery 'cars', {}, {}, ->
64 | query.on 'insert', ([document])->
65 | assert.equal document.name, 'panther'
66 | assert.equal document.snapshot, 'black'
67 | done()
68 | @connection.get('cars', 'panther').create 'text', 'black'
69 |
70 | it 'emits remove when deleting document', (done)->
71 | query = @connection.createSubscribeQuery 'cars', {}, {docMode: 'fetch'}, =>
72 | query.on 'remove', ([document])->
73 | assert.equal document.name, 'porsche'
74 | assert.equal document.snapshot, undefined
75 | done()
76 | @connection.get('cars', 'porsche').del()
77 |
78 |
79 | describe 'docMode: sub', ->
80 | beforeEach ->
81 | @anotherConnection = new Connection(createSocket())
82 | afterEach ->
83 | @anotherConnection.socket.close()
84 |
85 | it 'subscribes all result documents', (done)->
86 | @connection.createSubscribeQuery 'cars', {}, {docMode: 'sub'}, (error, [document]) =>
87 | document.on 'op', (operation)->
88 | assert.deepEqual operation, [3, 'y']
89 | done()
90 | porsche = @anotherConnection.get('cars', 'porsche')
91 | porsche.fetch -> porsche.submitOp [3, 'y']
92 |
--------------------------------------------------------------------------------
/test/browser/subscribed.coffee:
--------------------------------------------------------------------------------
1 | createSocket = require '../helpers/socket.coffee'
2 | assert = require 'assert'
3 | {Connection} = require '../../lib/client'
4 | createServer = require '../helpers/server.coffee'
5 | createFixtures = require '../helpers/fixtures.coffee'
6 |
7 | describe 'Subscribed Document', ->
8 |
9 | before ->
10 | @alice = new Connection(createSocket())
11 | @bob = new Connection(createSocket())
12 |
13 | @alice.on 'error', (e) -> throw e
14 | @bob.on 'error', (e) -> throw e
15 | @server = createServer()
16 | @fixtures = createFixtures()
17 |
18 | after (done) ->
19 | @alice.socket.close()
20 | @bob.socket.close()
21 | @fixtures.close()
22 | @server.close done
23 |
24 | # Reset documents
25 | beforeEach (done) ->
26 | @alice.collections = {}
27 | @bob.collections = {}
28 |
29 | @docs = {}
30 | @docs.alice = @alice.get 'poems', 'lorelay'
31 | @docs.bob = @bob.get 'poems', 'lorelay'
32 | @docs.alice.subscribe => @docs.bob.subscribe done
33 |
34 | afterEach (done) ->
35 | @docs.alice.unsubscribe => @docs.bob.unsubscribe done
36 |
37 | describe 'shared create', ->
38 | afterEach (done) -> @fixtures.reset done
39 |
40 | it 'triggers create', (done) ->
41 | @docs.bob.on 'create', -> done()
42 | @docs.alice.create 'text', 'ich'
43 |
44 | it 'sets type', (done) ->
45 | @docs.alice.on 'create', =>
46 | assert.equal @docs.alice.type, require('ot-text').type
47 | done()
48 | @docs.bob.create 'text', 'ich'
49 |
50 | it 'sets initial snapshot', (done)->
51 | @docs.bob.on 'create', =>
52 | assert.equal @docs.bob.snapshot, 'ich'
53 | done()
54 | @docs.alice.create 'text', 'ich'
55 |
56 | describe 'when created', ->
57 |
58 | beforeEach (done) ->
59 | @docs.bob.on 'create', -> done()
60 | @docs.alice.create 'text', 'ich'
61 |
62 | it 'shares del', (done) ->
63 | @docs.bob.on 'del', =>
64 | @fixtures.reset done
65 | @docs.alice.del()
66 |
67 | describe 'editing context', ->
68 |
69 | beforeEach ->
70 | @aliceCtx = @docs.alice.createContext()
71 | @bobCtx = @docs.bob.createContext()
72 |
73 | it 'shares insert', (done) ->
74 | @bobCtx.onInsert = (pos, text) =>
75 | assert.equal pos, 3
76 | assert.equal text, ' weiss'
77 | assert.equal @bobCtx.getSnapshot(), 'ich weiss'
78 | @fixtures.reset done
79 | @aliceCtx.insert(3, ' weiss')
80 |
81 | it 'shares remove', (done) ->
82 | @aliceCtx.onRemove = (pos, length) =>
83 | assert.equal pos, 1
84 | assert.equal length, 1
85 | assert.equal @aliceCtx.getSnapshot(), 'ih'
86 | @fixtures.reset done
87 | @bobCtx.remove(1, 1)
88 |
--------------------------------------------------------------------------------
/test/helpers/fixtures.coffee:
--------------------------------------------------------------------------------
1 | {BCSocket} = require 'browserchannel'
2 |
3 | # Control documents on the server
4 | #
5 | # fix = require('fixtures')()
6 | # fix.reset -> 'fixtures reseted'
7 | #
8 | module.exports = ->
9 | socket: (new BCSocket 'http://localhost:3000/fixtures')
10 | reset: (done)->
11 | @socket.onmessage = =>
12 | @socket.onmessage = undefined
13 | done()
14 | @socket.send('reset')
15 | close: -> @socket.close()
16 |
--------------------------------------------------------------------------------
/test/helpers/index.coffee:
--------------------------------------------------------------------------------
1 | # This is a set of test helpers. All the test helpers are included
2 | # directly in this file except for the randomizer, which should be
3 | # required directly.
4 |
5 | # Cross-transform function. Transform server by client and client by server. Returns
6 | # [server, client].
7 | exports.transformX = transformX = (type, left, right) ->
8 | [type.transform(left, right, 'left'), type.transform(right, left, 'right')]
9 |
10 | # new seed every 6 hours
11 | exports.seed = Math.floor(Date.now() / (1000*60*60*6))
12 | if exports.seed?
13 | {rand_real, seed} = require('./mersenne')
14 | seed exports.seed
15 | exports.randomReal = rand_real
16 | else
17 | exports.randomReal = Math.random
18 |
19 | # Generate a random int 0 <= k < n
20 | exports.randomInt = (n) -> Math.floor exports.randomReal() * n
21 |
22 | # Transform a list of server ops by a list of client ops.
23 | # Returns [serverOps', clientOps'].
24 | # This is O(serverOps.length * clientOps.length)
25 | exports.transformLists = (type, serverOps, clientOps) ->
26 | #p "Transforming #{i serverOps} with #{i clientOps}"
27 | serverOps = for s in serverOps
28 | clientOps = for c in clientOps
29 | #p "X #{i s} by #{i c}"
30 | [s, c_] = transformX type, s, c
31 | c_
32 | s
33 |
34 | [serverOps, clientOps]
35 |
36 | # Compose a list of ops together
37 | exports.composeList = (type, ops) -> ops.reduce type.compose
38 |
39 | # Wait for the returned function to be called a given number of times, then call the
40 | # callback.
41 | exports.makePassPart = (n, callback) ->
42 | remaining = n
43 | ->
44 | remaining--
45 | if remaining == 0
46 | callback()
47 | else if remaining < 0
48 | throw new Error "expectCalls called more than #{n} times"
49 |
50 | # Callback will be called after all the ops have been applied, with the
51 | # resultant snapshot. Callback format is callback(error, snapshot)
52 | #
53 | # It might be worth moving this to model so others can use this method.
54 | exports.applyOps = applyOps = (model, docName, startVersion, ops, callback) =>
55 | op = ops.shift()
56 | model.applyOp docName, {v:startVersion, op:op}, (error, appliedVersion) =>
57 | if error
58 | callback error
59 | else
60 | if ops.length == 0
61 | model.getSnapshot docName, callback
62 | else
63 | applyOps model, docName, startVersion + 1, ops, callback
64 |
65 | # Generate a new, locally unique document name.
66 | exports.newDocName = do ->
67 | index = 1
68 | -> 'testing_doc_' + index++
69 |
70 |
71 |
--------------------------------------------------------------------------------
/test/helpers/mersenne.js:
--------------------------------------------------------------------------------
1 | // copied from the npm module 'mersenne', may 2011
2 | //
3 | // this program is a JavaScript version of Mersenne Twister, with concealment and encapsulation in class,
4 | // an almost straight conversion from the original program, mt19937ar.c,
5 | // translated by y. okada on July 17, 2006.
6 | // and modified a little at july 20, 2006, but there are not any substantial differences.
7 | // in this program, procedure descriptions and comments of original source code were not removed.
8 | // lines commented with //c// were originally descriptions of c procedure. and a few following lines are appropriate JavaScript descriptions.
9 | // lines commented with /* and */ are original comments.
10 | // lines commented with // are additional comments in this JavaScript version.
11 | // before using this version, create at least one instance of MersenneTwister19937 class, and initialize the each state, given below in c comments, of all the instances.
12 | /*
13 | A C-program for MT19937, with initialization improved 2002/1/26.
14 | Coded by Takuji Nishimura and Makoto Matsumoto.
15 |
16 | Before using, initialize the state by using init_genrand(seed)
17 | or init_by_array(init_key, key_length).
18 |
19 | Copyright (C) 1997 - 2002, Makoto Matsumoto and Takuji Nishimura,
20 | All rights reserved.
21 |
22 | Redistribution and use in source and binary forms, with or without
23 | modification, are permitted provided that the following conditions
24 | are met:
25 |
26 | 1. Redistributions of source code must retain the above copyright
27 | notice, this list of conditions and the following disclaimer.
28 |
29 | 2. Redistributions in binary form must reproduce the above copyright
30 | notice, this list of conditions and the following disclaimer in the
31 | documentation and/or other materials provided with the distribution.
32 |
33 | 3. The names of its contributors may not be used to endorse or promote
34 | products derived from this software without specific prior written
35 | permission.
36 |
37 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
38 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
39 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
40 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
41 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
42 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
43 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
44 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
45 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
46 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
47 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
48 |
49 |
50 | Any feedback is very welcome.
51 | http://www.math.sci.hiroshima-u.ac.jp/~m-mat/MT/emt.html
52 | email: m-mat @ math.sci.hiroshima-u.ac.jp (remove space)
53 | */
54 |
55 | function MersenneTwister19937()
56 | {
57 | /* Period parameters */
58 | //c//#define N 624
59 | //c//#define M 397
60 | //c//#define MATRIX_A 0x9908b0dfUL /* constant vector a */
61 | //c//#define UPPER_MASK 0x80000000UL /* most significant w-r bits */
62 | //c//#define LOWER_MASK 0x7fffffffUL /* least significant r bits */
63 | N = 624;
64 | M = 397;
65 | MATRIX_A = 0x9908b0df; /* constant vector a */
66 | UPPER_MASK = 0x80000000; /* most significant w-r bits */
67 | LOWER_MASK = 0x7fffffff; /* least significant r bits */
68 | //c//static unsigned long mt[N]; /* the array for the state vector */
69 | //c//static int mti=N+1; /* mti==N+1 means mt[N] is not initialized */
70 | var mt = new Array(N); /* the array for the state vector */
71 | var mti = N+1; /* mti==N+1 means mt[N] is not initialized */
72 |
73 | function unsigned32 (n1) // returns a 32-bits unsiged integer from an operand to which applied a bit operator.
74 | {
75 | return n1 < 0 ? (n1 ^ UPPER_MASK) + UPPER_MASK : n1;
76 | }
77 |
78 | function subtraction32 (n1, n2) // emulates lowerflow of a c 32-bits unsiged integer variable, instead of the operator -. these both arguments must be non-negative integers expressible using unsigned 32 bits.
79 | {
80 | return n1 < n2 ? unsigned32((0x100000000 - (n2 - n1)) & 0xffffffff) : n1 - n2;
81 | }
82 |
83 | function addition32 (n1, n2) // emulates overflow of a c 32-bits unsiged integer variable, instead of the operator +. these both arguments must be non-negative integers expressible using unsigned 32 bits.
84 | {
85 | return unsigned32((n1 + n2) & 0xffffffff)
86 | }
87 |
88 | function multiplication32 (n1, n2) // emulates overflow of a c 32-bits unsiged integer variable, instead of the operator *. these both arguments must be non-negative integers expressible using unsigned 32 bits.
89 | {
90 | var sum = 0;
91 | for (var i = 0; i < 32; ++i){
92 | if ((n1 >>> i) & 0x1){
93 | sum = addition32(sum, unsigned32(n2 << i));
94 | }
95 | }
96 | return sum;
97 | }
98 |
99 | /* initializes mt[N] with a seed */
100 | //c//void init_genrand(unsigned long s)
101 | this.init_genrand = function (s)
102 | {
103 | //c//mt[0]= s & 0xffffffff;
104 | mt[0]= unsigned32(s & 0xffffffff);
105 | for (mti=1; mti> 30)) + mti);
108 | addition32(multiplication32(1812433253, unsigned32(mt[mti-1] ^ (mt[mti-1] >>> 30))), mti);
109 | /* See Knuth TAOCP Vol2. 3rd Ed. P.106 for multiplier. */
110 | /* In the previous versions, MSBs of the seed affect */
111 | /* only MSBs of the array mt[]. */
112 | /* 2002/01/09 modified by Makoto Matsumoto */
113 | //c//mt[mti] &= 0xffffffff;
114 | mt[mti] = unsigned32(mt[mti] & 0xffffffff);
115 | /* for >32 bit machines */
116 | }
117 | }
118 |
119 | /* initialize by an array with array-length */
120 | /* init_key is the array for initializing keys */
121 | /* key_length is its length */
122 | /* slight change for C++, 2004/2/26 */
123 | //c//void init_by_array(unsigned long init_key[], int key_length)
124 | this.init_by_array = function (init_key, key_length)
125 | {
126 | //c//int i, j, k;
127 | var i, j, k;
128 | //c//init_genrand(19650218);
129 | this.init_genrand(19650218);
130 | i=1; j=0;
131 | k = (N>key_length ? N : key_length);
132 | for (; k; k--) {
133 | //c//mt[i] = (mt[i] ^ ((mt[i-1] ^ (mt[i-1] >> 30)) * 1664525))
134 | //c// + init_key[j] + j; /* non linear */
135 | mt[i] = addition32(addition32(unsigned32(mt[i] ^ multiplication32(unsigned32(mt[i-1] ^ (mt[i-1] >>> 30)), 1664525)), init_key[j]), j);
136 | mt[i] =
137 | //c//mt[i] &= 0xffffffff; /* for WORDSIZE > 32 machines */
138 | unsigned32(mt[i] & 0xffffffff);
139 | i++; j++;
140 | if (i>=N) { mt[0] = mt[N-1]; i=1; }
141 | if (j>=key_length) j=0;
142 | }
143 | for (k=N-1; k; k--) {
144 | //c//mt[i] = (mt[i] ^ ((mt[i-1] ^ (mt[i-1] >> 30)) * 1566083941))
145 | //c//- i; /* non linear */
146 | mt[i] = subtraction32(unsigned32((dbg=mt[i]) ^ multiplication32(unsigned32(mt[i-1] ^ (mt[i-1] >>> 30)), 1566083941)), i);
147 | //c//mt[i] &= 0xffffffff; /* for WORDSIZE > 32 machines */
148 | mt[i] = unsigned32(mt[i] & 0xffffffff);
149 | i++;
150 | if (i>=N) { mt[0] = mt[N-1]; i=1; }
151 | }
152 | mt[0] = 0x80000000; /* MSB is 1; assuring non-zero initial array */
153 | }
154 |
155 | /* moved outside of genrand_int32() by jwatte 2010-11-17; generate less garbage */
156 | var mag01 = [0x0, MATRIX_A];
157 |
158 | /* generates a random number on [0,0xffffffff]-interval */
159 | //c//unsigned long genrand_int32(void)
160 | this.genrand_int32 = function ()
161 | {
162 | //c//unsigned long y;
163 | //c//static unsigned long mag01[2]={0x0UL, MATRIX_A};
164 | var y;
165 | /* mag01[x] = x * MATRIX_A for x=0,1 */
166 |
167 | if (mti >= N) { /* generate N words at one time */
168 | //c//int kk;
169 | var kk;
170 |
171 | if (mti == N+1) /* if init_genrand() has not been called, */
172 | //c//init_genrand(5489); /* a default initial seed is used */
173 | this.init_genrand(5489); /* a default initial seed is used */
174 |
175 | for (kk=0;kk> 1) ^ mag01[y & 0x1];
178 | y = unsigned32((mt[kk]&UPPER_MASK)|(mt[kk+1]&LOWER_MASK));
179 | mt[kk] = unsigned32(mt[kk+M] ^ (y >>> 1) ^ mag01[y & 0x1]);
180 | }
181 | for (;kk> 1) ^ mag01[y & 0x1];
184 | y = unsigned32((mt[kk]&UPPER_MASK)|(mt[kk+1]&LOWER_MASK));
185 | mt[kk] = unsigned32(mt[kk+(M-N)] ^ (y >>> 1) ^ mag01[y & 0x1]);
186 | }
187 | //c//y = (mt[N-1]&UPPER_MASK)|(mt[0]&LOWER_MASK);
188 | //c//mt[N-1] = mt[M-1] ^ (y >> 1) ^ mag01[y & 0x1];
189 | y = unsigned32((mt[N-1]&UPPER_MASK)|(mt[0]&LOWER_MASK));
190 | mt[N-1] = unsigned32(mt[M-1] ^ (y >>> 1) ^ mag01[y & 0x1]);
191 | mti = 0;
192 | }
193 |
194 | y = mt[mti++];
195 |
196 | /* Tempering */
197 | //c//y ^= (y >> 11);
198 | //c//y ^= (y << 7) & 0x9d2c5680;
199 | //c//y ^= (y << 15) & 0xefc60000;
200 | //c//y ^= (y >> 18);
201 | y = unsigned32(y ^ (y >>> 11));
202 | y = unsigned32(y ^ ((y << 7) & 0x9d2c5680));
203 | y = unsigned32(y ^ ((y << 15) & 0xefc60000));
204 | y = unsigned32(y ^ (y >>> 18));
205 |
206 | return y;
207 | }
208 |
209 | /* generates a random number on [0,0x7fffffff]-interval */
210 | //c//long genrand_int31(void)
211 | this.genrand_int31 = function ()
212 | {
213 | //c//return (genrand_int32()>>1);
214 | return (this.genrand_int32()>>>1);
215 | }
216 |
217 | /* generates a random number on [0,1]-real-interval */
218 | //c//double genrand_real1(void)
219 | this.genrand_real1 = function ()
220 | {
221 | //c//return genrand_int32()*(1.0/4294967295.0);
222 | return this.genrand_int32()*(1.0/4294967295.0);
223 | /* divided by 2^32-1 */
224 | }
225 |
226 | /* generates a random number on [0,1)-real-interval */
227 | //c//double genrand_real2(void)
228 | this.genrand_real2 = function ()
229 | {
230 | //c//return genrand_int32()*(1.0/4294967296.0);
231 | return this.genrand_int32()*(1.0/4294967296.0);
232 | /* divided by 2^32 */
233 | }
234 |
235 | /* generates a random number on (0,1)-real-interval */
236 | //c//double genrand_real3(void)
237 | this.genrand_real3 = function ()
238 | {
239 | //c//return ((genrand_int32()) + 0.5)*(1.0/4294967296.0);
240 | return ((this.genrand_int32()) + 0.5)*(1.0/4294967296.0);
241 | /* divided by 2^32 */
242 | }
243 |
244 | /* generates a random number on [0,1) with 53-bit resolution*/
245 | //c//double genrand_res53(void)
246 | this.genrand_res53 = function ()
247 | {
248 | //c//unsigned long a=genrand_int32()>>5, b=genrand_int32()>>6;
249 | var a=this.genrand_int32()>>>5, b=this.genrand_int32()>>>6;
250 | return(a*67108864.0+b)*(1.0/9007199254740992.0);
251 | }
252 | /* These real versions are due to Isaku Wada, 2002/01/09 added */
253 | }
254 |
255 | // Exports: Public API
256 |
257 | // Export the twister class
258 | exports.MersenneTwister19937 = MersenneTwister19937;
259 |
260 | // Export a simplified function to generate random numbers
261 | var gen = new MersenneTwister19937;
262 | gen.init_genrand((new Date).getTime() % 1000000000);
263 | exports.rand = function(N) {
264 | if (!N)
265 | {
266 | N = 32768;
267 | }
268 | return Math.floor(gen.genrand_real2() * N);
269 | }
270 | exports.rand_real = function () {
271 | return gen.genrand_real2()
272 | }
273 | exports.seed = function(S) {
274 | if (typeof(S) != 'number')
275 | {
276 | throw new Error("seed(S) must take numeric argument; is " + typeof(S));
277 | }
278 | gen.init_genrand(S);
279 | }
280 | exports.seed_array = function(A) {
281 | if (typeof(A) != 'object')
282 | {
283 | throw new Error("seed_array(A) must take array of numbers; is " + typeof(A));
284 | }
285 | gen.init_by_array(A);
286 | }
287 |
288 |
289 |
--------------------------------------------------------------------------------
/test/helpers/ot_number.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Simple OT representing a number.
3 | *
4 | * Snapshots and operations are just integers. Applying an operation to an
5 | * integer corresponds to adding the number. It supports inversion.
6 | *
7 | * It is used mainly for testing
8 | */
9 | module.exports = {
10 | name: 'simple-number',
11 | uri: 'http://sharejs.org/types/simple-number',
12 |
13 | /**
14 | * Creates snapshot with initial data
15 | *
16 | * @param {Number} [initial=0]
17 | * @return {Number}
18 | */
19 | create: function(initial) {
20 | if (initial == null)
21 | initial = 0;
22 | return initial;
23 | },
24 |
25 | /**
26 | * Apply an operation to a snapshot and return new snapshot.
27 | */
28 | apply: function(snapshot, op) {
29 | return snapshot + op;
30 | },
31 |
32 | /**
33 | * Compose operations
34 | */
35 | transform: function(op1, op2) {
36 | return op1 + op2;
37 | },
38 |
39 | invert: function(operation) {
40 | return -operation;
41 | },
42 |
43 | /**
44 | * Mixin to manipulate snapshots
45 | *
46 | * The receiver of this mixin must implement getSnapshot() and
47 | * submitOp(operation, [callback]).
48 | */
49 | api: {
50 | get: function() { return this.getSnapshot() },
51 | add: function(value, callback) {
52 | return this.submitOp(value, callback);
53 | }
54 | }
55 | };
56 |
57 |
--------------------------------------------------------------------------------
/test/helpers/phantom.coffee:
--------------------------------------------------------------------------------
1 | # Communicate with phantom if available
2 | if window? && window.callPhantom
3 |
4 | Function.prototype.bind = (object)->
5 | => this.apply(object, arguments)
6 |
7 |
8 | phantom = (type, args...)->
9 | if args.length > 0
10 | window.callPhantom [type].concat(args)
11 | else
12 | (args...)-> window.callPhantom [type].concat(args)
13 |
14 | module.exports = phantom
15 | module.exports.available = true
16 | else
17 | module.exports = ->
18 | module.exports.available = false
19 |
--------------------------------------------------------------------------------
/test/helpers/server.coffee:
--------------------------------------------------------------------------------
1 | express = require 'express'
2 | {Duplex} = require 'stream'
3 | connect = require 'connect'
4 |
5 | # Creates a sharejs instance with a livedb backend
6 | createInstance = ->
7 | redis = require('redis')
8 | #redis.flushdb()
9 |
10 | redisClient1 = redis.createClient(6379, 'localhost');
11 | redisClient2 = redis.createClient(6379, 'localhost');
12 |
13 | livedbLib = require 'livedb'
14 | memorydb = livedbLib.memory()
15 | driver = livedbLib.redisDriver(memorydb, redisClient1, redisClient2);
16 | livedb = livedbLib.client(db: memorydb, driver:driver)
17 | livedb.redis = redisClient1
18 | livedb.db = memorydb
19 |
20 | shareServer = require '../../lib/server'
21 | shareServer.createClient(backend: livedb)
22 |
23 |
24 | # Converts a socket to a Duplex stream
25 | socketToStream = (socket, log)->
26 | stream = new Duplex objectMode: yes
27 | socket.on 'message', (data)->
28 | if log
29 | console.log "<<< client receive"
30 | console.log data
31 | stream.push(data)
32 | socket.on 'close', ->
33 | stream.end()
34 | stream.emit('close')
35 | stream.emit('end')
36 |
37 | stream._read = ->
38 | stream._write = (data, enc, callback)->
39 | if log
40 | console.log ">>> server send"
41 | console.log data
42 | socket.send(data)
43 | callback()
44 | stream
45 |
46 |
47 |
48 | # Exports an express app that handles sharejs and tests
49 | #
50 | # @param options.log enables logging of wire protocol and server requests,
51 | # defaults to true
52 | module.exports = (options = {})->
53 |
54 | log = options.log if options.log?
55 |
56 | share = createInstance()
57 |
58 | # BrowserChannel middleware that creates sharejs sessions
59 | shareChannel = require('browserchannel')
60 | .server cors: '*', (socket)->
61 | share.listen socketToStream(socket, log)
62 |
63 | # Enables client to reset the database
64 | fixturesChannel = require('browserchannel')
65 | .server base: '/fixtures', cors: '*', (socket)->
66 | socket.on 'message', (data)->
67 | share.backend.redis.flushdb()
68 | share.backend.db.collections = {}
69 | share.backend.db.ops = {}
70 | console.log '*** reset' if log
71 | socket.send 'ok'
72 |
73 |
74 | app = express()
75 | .use(shareChannel)
76 | .use(fixturesChannel)
77 |
78 | #app.use(connect.logger('dev')) if log
79 |
80 | app.listen 3000
81 |
--------------------------------------------------------------------------------
/test/helpers/socket.coffee:
--------------------------------------------------------------------------------
1 | {BCSocket} = require 'browserchannel'
2 |
3 | module.exports = (url = 'http://localhost:3000/channel')->
4 | new BCSocket(url)
5 |
--------------------------------------------------------------------------------
/test/helpers/webclient.coffee:
--------------------------------------------------------------------------------
1 | # This file wraps the closure compiled webclient script for testing.
2 | #
3 | # Run `cake webclient` to build the web client.
4 |
5 | # From time to time, its worth making sure the uncompressed code also works.
6 | # I can't be bothered making it run all the tests on uncompressed code every time though -
7 | # its too slow.
8 | TEST_UNCOMPRESSED = false
9 |
10 | fs = require 'fs'
11 |
12 | window = {}
13 | window.io = require 'socket.io-client'
14 | window.BCSocket = require('browserchannel').BCSocket
15 |
16 | for script in ['share', 'json']
17 | script = "#{script}.uncompressed" if TEST_UNCOMPRESSED
18 | code = fs.readFileSync("#{__dirname}/../../webclient/#{script}.js", 'utf8')
19 |
20 | # We also need to make sure the uncompressed version of the script knows its in a browser.
21 | # This is handled by window.WEB=true in web-prelude, but that obviously doesn't work here.
22 | code = "var WEB=true; #{code}" if TEST_UNCOMPRESSED
23 |
24 | console.log "Evaling #{script}"
25 | eval code
26 |
27 | module.exports = window.sharejs
28 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | --compilers coffee:coffee-script/register
2 | --reporter spec
3 | --check-leaks
4 | --recursive
5 |
--------------------------------------------------------------------------------
/test/server/connection.coffee:
--------------------------------------------------------------------------------
1 | sinon = require 'sinon'
2 | assert = require 'assert'
3 | {Connection} = require '../../lib/client'
4 | {Doc} = require '../../lib/client/doc'
5 |
6 |
7 | describe 'Connection', ->
8 |
9 | socket = {
10 | send: ->
11 | connect: ->
12 | @readyState = 1
13 | @onopen?()
14 | close: ->
15 | @readyState = 3
16 | @onclose?()
17 | canSendWhileConnecting: true
18 | }
19 |
20 |
21 | beforeEach ->
22 | socket.readyState = 0
23 | @connection = new Connection socket
24 |
25 |
26 | describe 'state and socket', ->
27 |
28 | it 'is set to disconnected', ->
29 | socket.readyState = 3
30 | connection = new Connection socket
31 | assert.equal connection.state, 'disconnected'
32 |
33 | it 'is set to connecting', ->
34 | socket.readyState = 1
35 | connection = new Connection socket
36 | assert.equal connection.state, 'connecting'
37 |
38 |
39 | describe 'socket onopen', ->
40 |
41 | beforeEach ->
42 | socket.readyState = 3
43 | @connection = new Connection socket
44 |
45 | it 'sets connecting state', ->
46 | assert.equal @connection.state, 'disconnected'
47 | socket.onopen()
48 | assert.equal @connection.state, 'connecting'
49 |
50 | it 'sets canSend', ->
51 | assert !@connection.canSend
52 | socket.onopen()
53 | assert @connection.canSend
54 |
55 |
56 | describe 'socket onclose', ->
57 |
58 | it 'sets disconnected state', ->
59 | assert.equal @connection.state, 'connecting'
60 | socket.close()
61 | assert.equal @connection.state, 'disconnected'
62 |
63 | it 'sets canSend', ->
64 | assert @connection.canSend
65 | socket.close()
66 | assert !@connection.canSend
67 |
68 |
69 | describe 'socket onmessage', ->
70 |
71 | it 'calls handle message', ->
72 | handleMessage = sinon.spy @connection, 'handleMessage'
73 | socket.onmessage({key: 'value'})
74 | sinon.assert.calledWith handleMessage, {key: 'value'}
75 |
76 | it 'pushes message buffer', ->
77 | assert @connection.messageBuffer.length == 0
78 | socket.onmessage('"a message"')
79 | assert @connection.messageBuffer.length == 1
80 |
81 |
82 | describe '#disconnect', ->
83 |
84 | it 'calls socket.close()', ->
85 | close = sinon.spy socket, 'close'
86 | @connection.disconnect()
87 | sinon.assert.calledOnce close
88 | close.reset()
89 |
90 | it 'emits disconnected', ->
91 | emit = sinon.spy @connection, 'emit'
92 | @connection.disconnect()
93 | sinon.assert.calledWith emit, 'disconnected'
94 | emit.reset()
95 |
96 |
97 | describe '#get', ->
98 |
99 | it 'returns a Doc', ->
100 | doc = @connection.get('food', 'steak')
101 | assert.equal doc.constructor, Doc
102 |
103 | it 'returns the same object the second time', ->
104 | first = @connection.get('food', 'steak')
105 | second = @connection.get('food', 'steak')
106 | assert.equal first, second
107 |
108 | it 'injests data on creation', ->
109 | # There is a very sublte bug, possibly in test itself, where if 'this' is console.logged
110 | # in Doc.prototype.ingestData, the snapshot is actually equal to expected value.
111 | doc = @connection.get('food', 'steak', {data: 'content', v: 0, type: 'text'})
112 | assert.equal doc.snapshot, 'content'
113 | doc = @connection.get('food', 'steak', {data: 'other content', v: 0, type: 'text'})
114 | # TODO
115 | assert.equal doc.snapshot, 'content'
116 | #assert.equal doc.snapshot, 'other content'
117 |
--------------------------------------------------------------------------------
/test/server/doc.coffee:
--------------------------------------------------------------------------------
1 | {expect} = require 'chai'
2 | {Connection} = require '../../lib/client'
3 | {Doc} = require '../../lib/client/doc'
4 | textType = require('ot-text').type
5 |
6 | describe 'Doc', ->
7 |
8 | numberType = require('../helpers/ot_number')
9 |
10 | createConnection = ->
11 | socket = {}
12 | connection = new Connection(socket)
13 | connection.id = '42'
14 | connection.state = 'connected'
15 | connection.canSend = true
16 | connection.sent = []
17 | connection.sendBackTo = null
18 | connection.send = (data) ->
19 | if @sendBackTo
20 | @sendBackTo._onMessage data
21 | else
22 | @sent.push data
23 | return connection
24 |
25 | beforeEach ->
26 | @connection = createConnection()
27 | @doc = new Doc(@connection, 'notes', 'music')
28 |
29 | # Call this in a group to work with a created document
30 | beforeEachCreateDocument = ->
31 | beforeEach ->
32 | # Mirror message right back to doc
33 | @connection.sendBackTo = @doc
34 | @doc.create numberType, 1
35 | # Flush forces the doc to send immediately instead of async
36 | @doc.flush()
37 | @connection.sendBackTo = null
38 |
39 | describe '#subscribe', ->
40 |
41 | it 'sets subscribed', (done)->
42 | expect(@doc.subscribed).to.be.false
43 | @doc.subscribe =>
44 | expect(@doc.subscribed).to.be.true
45 | done()
46 | @doc._onMessage {c: 'notes', d: 'music', a: 'sub'}
47 |
48 | it 'emits subscribe', (done)->
49 | @doc.subscribe()
50 | @doc.on('subscribe', done)
51 | @doc._onMessage {c: 'notes', d: 'music', a: 'sub'}
52 |
53 | it 'sends subscription message', ->
54 | @doc.subscribe()
55 | expect(@connection.sent[0].c).to.equal 'notes'
56 | expect(@connection.sent[0].d).to.equal 'music'
57 | expect(@connection.sent[0].a).to.equal 'sub'
58 |
59 | it 'retrives snapshot', ->
60 | @doc.subscribe()
61 | expect(@doc.snapshot).to.be.undefined
62 | @doc._onMessage
63 | c: 'notes'
64 | d: 'music'
65 | a: 'sub'
66 | data:
67 | {data: 'snapshot', v: 0, type: 'text'}
68 | expect(@doc.snapshot).to.equal 'snapshot'
69 |
70 |
71 | describe '#unsubscribe', ->
72 |
73 | beforeEach -> @doc.subscribed = true
74 |
75 | it 'sets unsubscribed', (done)->
76 | expect(@doc.subscribed).to.be.true
77 | @doc.unsubscribe =>
78 | expect(@doc.subscribed).to.be.false
79 | done()
80 | @doc._onMessage {c: 'notes', d: 'music', a: 'unsub'}
81 |
82 | it 'emits unsubscribe', (done)->
83 | @doc.on('unsubscribe', done)
84 | @doc._onMessage {c: 'notes', d: 'music', a: 'unsub'}
85 |
86 | it 'sends unsubscription message', ->
87 | @doc.unsubscribe()
88 | expect(@connection.sent[0]).to.deep.equal {c: 'notes', d: 'music', a: 'unsub'}
89 |
90 | it 'calls subscribe callbacks', (done)->
91 | @doc.subscribe(done)
92 | @doc.unsubscribe()
93 | @doc._onMessage {c: 'notes', d: 'music', a: 'unsub'}
94 |
95 |
96 | describe '#fetch', ->
97 |
98 | it 'sends fetch message', ->
99 | @doc.fetch()
100 | expect(@connection.sent[0]).to.deep.equal {c: 'notes', d: 'music', a: 'fetch'}
101 |
102 | it 'sets snapshot', ->
103 | @doc.fetch()
104 | @doc._onMessage {c: 'notes', d: 'music', a: 'fetch', data: { data: 'cool' , v: 0, type: 'text'}}
105 | expect(@doc.snapshot).to.equal 'cool'
106 |
107 | it 'gets ready', (done)->
108 | @doc.fetch()
109 | @doc.on 'ready', =>
110 | expect(@doc.state).to.equal 'ready'
111 | done()
112 | @doc._onMessage {c: 'notes', d: 'music', a: 'fetch', data: { data: 'cool' , v: 0}}
113 |
114 | it 'fetches ops when we have data'
115 |
116 | it 'just calls the callback when we are subscribed'
117 |
118 | it 'calls all callbacks when fetch is called multiple times'
119 |
120 | describe '#create', ->
121 |
122 | it 'calls callback', (done)->
123 | @connection.sendBackTo = @doc
124 | @doc.create textType, done
125 |
126 | it 'sends create message', ->
127 | @doc.create(textType)
128 | @doc.flush()
129 | expect(@connection.sent[0]).to.have.property('create')
130 |
131 | it 'immediately injests data locally', ->
132 | @doc.create(textType, 'a note on music')
133 | expect(@doc.snapshot).to.equal 'a note on music'
134 |
135 | it 'injests data on success', ->
136 | @connection.sendBackTo = @doc
137 | @doc.create(textType, 'a note on music')
138 | @doc.flush()
139 | expect(@doc.snapshot).to.equal 'a note on music'
140 |
141 | it 'gets ready', (done)->
142 | @connection.sendBackTo = @doc
143 | expect(@doc.state).to.be.null
144 | @doc.create(textType, 'a note on music')
145 | @doc.flush()
146 | @doc.on 'ready', =>
147 | expect(@doc.state).to.equal 'ready'
148 | done()
149 |
150 | it 'emits create after snapshot created', (done)->
151 | @doc.once 'create', =>
152 | expect(@doc.snapshot).to.equal 'Love The Police'
153 | done()
154 | @doc.create(textType, 'Love The Police')
155 |
156 |
157 | describe '#del', ->
158 |
159 | beforeEachCreateDocument()
160 |
161 | it 'sends del message', ->
162 | @doc.del()
163 | @doc.flush()
164 | expect(@connection.sent[0]).to.have.property('del')
165 |
166 | it 'unsets type', ->
167 | @doc.del()
168 | expect(@doc.type).to.be.null
169 |
170 | it 'emits del after unsetting type', (done)->
171 | @doc.once 'del', =>
172 | expect(@doc.type).to.be.null
173 | done()
174 | @doc.del()
175 |
176 |
177 | describe 'subscribe unsubscribe and fetch', ->
178 | it 'subscribes once and calls all callbacks when subscribe is called multiple times'
179 | it 'unsubscribes once and calls all callbacks when unsubscribe is called multiple times'
180 | it 'hydrates the document if you call getOrCreate() with no data followed by getOrCreate() with data'
181 |
182 |
183 | describe 'editing contexts', ->
184 |
185 | beforeEach ->
186 | @connection.sendBackTo = @doc
187 | @doc.create(textType, 'note')
188 | @doc.flush()
189 | @context = @doc.createContext()
190 |
191 | it '#get exposes data', ->
192 | expect(@context.get()).to.equal 'note'
193 |
194 | it 'changes snapshot locally', ->
195 | @context.insert(0, 'delicious ')
196 | expect(@doc.snapshot).to.equal 'delicious note'
197 |
198 | @doc.flush()
199 | expect(@doc.snapshot).to.equal 'delicious note'
200 |
201 | it 'changes context data', ->
202 | @context.insert(0, 'delicious ')
203 | @doc.flush()
204 | expect(@context.get()).to.equal 'delicious note'
205 |
206 | it 'can call removeContexts', ->
207 | @doc.removeContexts()
208 |
209 |
210 | describe 'rollback', ->
211 |
212 | describe 'create', ->
213 |
214 | it 'resets floating state', ->
215 | @doc.create textType, 'a note on music'
216 | @doc.flush()
217 | expect(@doc.state).to.equal 'floating'
218 | @doc._onMessage {c: 'notes', d: 'music', a: 'ack', error: 'rejected'}
219 | expect(@doc.state).to.be.null
220 |
221 | it "when we're ready and the server rejects the op"
222 |
223 | it "when the doc is floating and the document already exists on the server"
224 |
225 | describe 'operation', ->
226 |
227 | beforeEachCreateDocument()
228 |
229 | beforeEach ->
230 | @context = @doc.createContext()
231 | afterEach ->
232 | @doc.removeContexts()
233 |
234 | it 'applies inverse', ->
235 | @context.add(2)
236 | @doc.flush()
237 | expect(@context.get()).to.equal 3
238 |
239 | msg = @connection.sent.pop()
240 | msg.error = 'rejected'
241 | msg.a = 'ack'
242 | @doc._onMessage msg
243 | expect(@context.get()).to.equal 1
244 |
245 |
246 | it 'ends up in the right state if we create() then subscribe() synchronously'
247 | it "abandons the document state if we can't recover from the rejected op"
248 |
249 | describe 'after op event', ->
250 |
251 | beforeEachCreateDocument()
252 |
253 | it 'is triggered when submitting operation', (done)->
254 | @doc.on('after op', -> done())
255 | @doc.submitOp(-2)
256 |
257 | it 'is triggered after applying operation', (done)->
258 | expect(@doc.snapshot).to.equal 1
259 | @doc.on 'after op', =>
260 | expect(@doc.snapshot).to.equal 0
261 | done()
262 | @doc.submitOp(-1)
263 |
264 | it 'sends operations in correct order', (done)->
265 | @doc.once 'after op', =>
266 | @doc.submitOp(1)
267 | @doc.flush()
268 | expect(@connection.sent[0].op).to.equal -1
269 | done()
270 | @doc.submitOp(-1)
271 |
272 | describe 'after op event', ->
273 |
274 | beforeEachCreateDocument()
275 |
276 | it 'is triggered when submitting operation', (done)->
277 | @doc.on('before op', -> done())
278 | @doc.submitOp(-2)
279 |
280 | it 'is triggered before applying operation', (done)->
281 | expect(@doc.snapshot).to.equal 1
282 | @doc.on 'before op', =>
283 | expect(@doc.snapshot).to.equal 1
284 | done()
285 | @doc.submitOp(-1)
286 |
287 | it 'is triggered when document is locked', (done)->
288 | @doc.once 'before op', =>
289 | expect(@doc.locked).to.be.true
290 | done()
291 | @doc.submitOp(-1)
292 |
293 |
294 | describe 'ops', ->
295 | it 'sends an op to the server'
296 | it 'deletes a document'
297 | it 'only sends one op to the server if ops are sent synchronously'
298 | it 'reorders sent (but not acknowledged) operations on reconnect'
299 |
--------------------------------------------------------------------------------
/test/server/integration.coffee:
--------------------------------------------------------------------------------
1 | # These integration tests test the client through to the useragent code. The
2 | # useragent code is not tested here.
3 |
4 | assert = require 'assert'
5 | {Duplex, Readable} = require 'stream'
6 | {EventEmitter} = require 'events'
7 | textType = require('ot-text').type
8 |
9 | Session = require '../../lib/server/session'
10 | {Connection} = require '../../lib/client'
11 |
12 | describe.skip 'integration', ->
13 | beforeEach ->
14 | @serverStream = new Duplex objectMode:yes
15 |
16 | @userAgent =
17 | sessionId: 'session id' # The unique client ID
18 | fetchAndSubscribe: (collection, doc, callback) =>
19 | @subscribedCollection = collection
20 | @subscribedDoc = doc
21 |
22 | return callback @subscribeError if @subscribeError
23 |
24 | @opStream = new Readable objectMode:yes
25 | @opStream._read = ->
26 | callback null, {v:100, type:textType, data:'hi there'}, @opStream
27 | trigger: (a, b, c, d, callback) -> callback()
28 |
29 | @instance =
30 | createAgent: (stream) =>
31 | assert.strictEqual stream, @serverStream
32 | @userAgent
33 |
34 | @clientStream =
35 | send: (data) =>
36 | #console.log 'C->S', JSON.stringify data
37 | @serverStream.push data
38 | readyState: 0 # Connecting
39 | close: =>
40 | @serverStream.emit 'close'
41 | @serverStream.emit 'end'
42 | @serverStream.end()
43 |
44 | @serverStream._write = (chunk, encoding, callback) =>
45 | #console.log 'S->C', JSON.stringify chunk
46 | @clientStream.onmessage? chunk
47 | callback()
48 | @serverStream._read = ->
49 |
50 | @connection = new Connection @clientStream
51 |
52 | @clientStream.readyState = 1 # Connected.
53 | @clientStream.onopen?()
54 |
55 | @session = new Session @instance, @serverStream
56 |
57 | describe 'connection maintenance', ->
58 | it 'connects', (done) ->
59 | checkStuff = =>
60 | assert.equal @connection.state, 'connected'
61 | assert.equal @connection.id, 'session id'
62 | done()
63 |
64 | if @connection.state is 'connected'
65 | checkStuff()
66 | else
67 | @connection.on 'connected', checkStuff
68 |
69 | describe 'document', ->
70 | describe 'subscribe', ->
71 | beforeEach ->
72 |
73 | it 'subscribes to a document', (done) ->
74 | doc = @connection.get 'users', 'seph'
75 | assert.strictEqual doc.collection, 'users'
76 | assert.strictEqual doc.name, 'seph'
77 | assert.strictEqual doc.subscribed, false
78 |
79 | doc.subscribe (err) =>
80 | assert.equal err, null
81 |
82 | assert.equal doc.state, 'ready'
83 | assert doc.subscribed
84 |
85 | assert.deepEqual doc.snapshot, 'hi there'
86 | assert.strictEqual doc.version, 100
87 | assert.strictEqual doc.type, textType
88 |
89 | assert.strictEqual doc.name, @subscribedDoc
90 | assert.strictEqual doc.collection, @subscribedCollection
91 |
92 | done()
93 |
94 | it 'passes subscribe errors back to the client', (done) ->
95 | @subscribeError = 'You require more vespine gas'
96 |
97 | doc = @connection.get 'users', 'seph'
98 | doc.subscribe (err) =>
99 | assert.equal err, @subscribeError
100 |
101 | assert.strictEqual doc.name, @subscribedDoc
102 | assert.strictEqual doc.collection, @subscribedCollection
103 |
104 | assert !doc.subscribed
105 | assert.equal doc.version, null
106 | assert.equal doc.type, null
107 |
108 | done()
109 |
110 | describe 'null document', ->
111 | it.skip 'lets you create', (done) ->
112 | doc = @connection.get 'users', 'seph'
113 | doc.submitOp
114 |
115 | describe 'query', ->
116 | beforeEach ->
117 |
118 | it 'issues a query to the backend', (done) ->
119 | @userAgent.query = (index, query, opts, callback) ->
120 | assert.strictEqual index, 'index'
121 | assert.deepEqual query, {a:5, b:6}
122 | assert.deepEqual opts, {docMode:'fetch', poll:true, backend:'abc123', versions:{}}
123 | emitter = new EventEmitter()
124 | emitter.data = [{data:{x:10}, type:textType.uri, v:100, docName:'docname', c:'collection'}]
125 | emitter.extra = 'oh hi'
126 | callback null, emitter
127 |
128 | @connection.createSubscribeQuery 'index', {a:5, b:6}, {docMode:'fetch', poll:true, source:'abc123'}, (err, results, extra) ->
129 | assert.ifError err
130 | # Results should contain the single document that the query returned.
131 | assert.strictEqual results.length, 1
132 | assert.strictEqual results[0].name, 'docname'
133 | assert.strictEqual results[0].collection, 'collection'
134 | assert.deepEqual results[0].snapshot, {x:10}
135 | assert.strictEqual extra, 'oh hi'
136 | done()
137 |
138 |
139 | describe 'queryfetch', ->
140 | it 'does not subscribe to the query result set'
141 |
142 | it 'does not fetch query results when docMode is null', (done) ->
143 | @userAgent.queryFetch = (index, query, opts, callback) ->
144 | callback null, [{data:{x:10}, type:textType.uri, v:100, docName:'docname', c:'collection'}], 'oh hi'
145 |
146 | @connection.createFetchQuery 'index', {a:5, b:6}, {}, (err, results, extra) ->
147 | assert.ifError err
148 | assert.strictEqual results.length, 1
149 | assert.equal results[0].state, null
150 | assert.equal results[0].version, null
151 | assert.equal results[0].snapshot, null
152 | done()
153 |
154 | it 'fetches query results if docMode is fetch', (done) ->
155 | @userAgent.queryFetch = (index, query, opts, callback) ->
156 | callback null, [{data:{x:10}, type:textType.uri, v:100, docName:'docname', c:'collection'}], 'oh hi'
157 |
158 | @connection.createFetchQuery 'index', {a:5, b:6}, {docMode:'fetch'}, (err, results, extra) ->
159 | assert.ifError err
160 | assert.strictEqual results.length, 1
161 | assert.deepEqual results[0].snapshot, {x:10}
162 | done()
163 |
164 | it 'subscribes to documents if docMode is subscribe', (done) ->
165 | @userAgent.queryFetch = (index, query, opts, callback) ->
166 | callback null, [{data:'internet', type:textType.uri, v:100, docName:'docname', c:'collection'}], 'oh hi'
167 |
168 | @userAgent.subscribe = (collection, doc, version, callback) =>
169 | assert.strictEqual collection, 'collection'
170 | assert.strictEqual doc, 'docname'
171 | assert.strictEqual version, 100
172 | @opStream = new Readable objectMode:yes
173 | @opStream._read = ->
174 | callback null, @opStream
175 |
176 | @connection.createFetchQuery 'index', {a:5, b:6}, {docMode:'sub'}, (err, results, extra) =>
177 | assert.ifError err
178 | assert.strictEqual results.length, 1
179 | doc = results[0]
180 | assert.deepEqual doc.snapshot, 'internet'
181 | assert.strictEqual doc.subscribed, true
182 | assert.strictEqual doc.wantSubscribe, true # Probably shouldn't depend on this actually.
183 |
184 | doc.on 'op', (op) ->
185 | assert.equal doc.snapshot, 'internet are go!'
186 | done()
187 |
188 | # The document should get operations sent to the opstream.
189 | @opStream.push {v:100, op:[8, ' are go!']}
190 |
191 |
192 | # regression
193 | it 'subscribes from the version specified if the client has a document snapshot already', (done) ->
194 | @userAgent.fetch = (collection, docName, callback) ->
195 | assert.strictEqual collection, 'collection'
196 | assert.strictEqual docName, 'docname'
197 | callback null, {v:98, type:textType.uri, data:'old data'}
198 |
199 | doc = @connection.get 'collection', 'docname'
200 |
201 | doc.fetch (err) =>
202 | throw new Error err if err
203 |
204 | @userAgent.queryFetch = (index, query, opts, callback) ->
205 | callback null, [{data:'internet', type:textType.uri, v:100, docName:'docname', c:'collection'}], 'oh hi'
206 |
207 | @userAgent.subscribe = (collection, doc, version, callback) =>
208 | assert.strictEqual collection, 'collection'
209 | assert.strictEqual doc, 'docname'
210 | assert.strictEqual version, 98
211 | @opStream = new Readable objectMode:yes
212 | @opStream._read = ->
213 | callback null, @opStream
214 | process.nextTick =>
215 | @opStream.push {v:98, op:[]}
216 | @opStream.push {v:99, op:[]}
217 |
218 | doc.on 'op', (op) ->
219 | assert doc.version <= 100
220 | done() if doc.version is 100
221 |
222 | @connection.createFetchQuery 'index', {a:5, b:6}, {docMode:'sub', knownDocs:[doc]}, (err, results, extra) =>
223 |
224 |
225 | it 'does not resend document snapshots when you reconnect' # ?? how do we test this at this level of abstraction?
226 |
227 | it 'fetches operations if the client already has a document snapshot at an old version', (done) ->
228 | @userAgent.fetch = (collection, docName, callback) ->
229 | assert.strictEqual collection, 'collection'
230 | assert.strictEqual docName, 'docname'
231 | callback null, {v:98, type:textType.uri, data:'old data'}
232 |
233 | doc = @connection.get 'collection', 'docname'
234 |
235 | doc.fetch (err) =>
236 | throw new Error err if err
237 | @userAgent.getOps = (collection, docName, from, to, callback) ->
238 | assert.strictEqual collection, 'collection'
239 | assert.strictEqual docName, 'docname'
240 | assert.equal from, 98
241 | assert to is -1 or to is 100
242 | callback null, [{v:98, op:[]},{v:99, op:[]}] # ops from 98 to 100
243 |
244 | @userAgent.queryFetch = (index, query, opts, callback) ->
245 | callback null, [{data:'internet', type:textType.uri, v:100, docName:'docname', c:'collection'}]
246 |
247 | @connection.createFetchQuery 'index', {a:5, b:6}, {docMode:'fetch', knownDocs:[doc]}, (err, results, extra) =>
248 | throw new Error err if err
249 |
250 | doc.on 'op', (op) ->
251 | assert doc.version <= 100
252 | done() if doc.version is 100
253 |
254 |
255 |
256 |
257 | it 'does not fetch results which are subscribed by the client'
258 | it 'subscribes to documents if autosubscribe is true'
259 | it 'does not double subscribe to documents or anything wierd'
260 | it 'passes the right error message back if subscribe fails'
261 |
262 |
263 | it 'correctly handles concurrent subscribes & queries with subscribe set'
264 | it 'handles diffs properly when in subscribe mode'
265 | it 'sets all new documents to be subscribed before calling any callbacks in query diff handler'
266 |
267 | # regression
268 | it 'does not pass known documents to the change event handler'
269 |
270 |
271 | # regression
272 | it 'emits an error on the connection or document if an exception is thrown in an event handler'
273 |
274 |
275 |
--------------------------------------------------------------------------------
/test/server/json-api.coffee:
--------------------------------------------------------------------------------
1 | assert = require("assert")
2 | json = require('ot-json0').type
3 | require("../../lib/types/json-api")
4 | emitter = require('../../lib/client/emitter');
5 |
6 | # in the future, it would be less brittle to use the real Doc object instead of this fake one
7 | Doc = (data) ->
8 | @_snapshot = (if data? then data else json.create())
9 | @type = json
10 | @editingContexts = []
11 |
12 | @getSnapshot = ->
13 | @_snapshot
14 |
15 | @submitOp = (op, context, cb) ->
16 | @_snapshot = json.apply(@_snapshot, op)
17 | @emit "op", op
18 | cb?()
19 |
20 | #createContext is copy-pasted from lib/client/doc
21 | @createContext = ->
22 | type = @type
23 | throw new Error("Missing type") unless type
24 |
25 | doc = this
26 | context =
27 | getSnapshot: ->
28 | doc.getSnapshot()
29 |
30 | submitOp: (op, callback) ->
31 | doc.submitOp op, context, callback
32 |
33 | destroy: ->
34 | if @detach
35 | @detach()
36 |
37 | delete @detach
38 |
39 | delete @_onOp
40 |
41 | @remove = true
42 |
43 | _doc: this
44 |
45 | if type.api
46 | for k of type.api
47 | context[k] = type.api[k]
48 | else
49 | context.provides = {}
50 | @editingContexts.push context
51 | context
52 |
53 | this
54 |
55 | emitter.mixin Doc
56 |
57 | apply = (cxt,op) ->
58 | cxt._beforeOp? op
59 | cxt.submitOp op
60 | cxt._onOp op
61 |
62 | waitBriefly = (done) ->
63 | setTimeout ( ->
64 | assert.ok true
65 | done()
66 | ), 10
67 |
68 | describe "JSON Client API", ->
69 | it "sanity check", ->
70 | doc = new Doc("hi")
71 | cxt = doc.createContext()
72 | assert.equal cxt.get(), "hi"
73 | doc = new Doc(hello: "world")
74 | cxt = doc.createContext()
75 | assert.equal cxt.get(["hello"]), "world"
76 |
77 | it "get", ->
78 | doc = new Doc(hi: [1, 2, 3])
79 | cxt = doc.createContext()
80 | assert.equal cxt.get(["hi", 2]), 3
81 |
82 | it "sub-cxt get", ->
83 | doc = new Doc(hi: [1, 2, 3])
84 | cxt = doc.createContext()
85 | hi = cxt.createContextAt("hi")
86 | assert.deepEqual hi.get(), [1, 2, 3]
87 | assert.equal hi.createContextAt(2).get(), 3
88 | assert.equal cxt.get(["hi", 2]), 3
89 |
90 | it "object set", ->
91 | doc = new Doc
92 | cxt = doc.createContext()
93 | cxt.set hello: "world"
94 | assert.deepEqual cxt.get(),
95 | hello: "world"
96 |
97 | cxt.createContextAt("hello").set "blah"
98 | assert.deepEqual cxt.get(),
99 | hello: "blah"
100 |
101 | cxt.set ["hello"], "bleh"
102 | assert.deepEqual cxt.get(),
103 | hello: "bleh"
104 |
105 | it "list set", ->
106 | doc = new Doc([1, 2, 3])
107 | cxt = doc.createContext()
108 | cxt.createContextAt(1).set 5
109 | assert.deepEqual cxt.get(), [1, 5, 3]
110 |
111 | doc = new Doc([1, 2, 3])
112 | cxt = doc.createContext()
113 | cxt.set [1], 5
114 | assert.deepEqual cxt.get(), [1, 5, 3]
115 |
116 | it "remove", ->
117 | doc = new Doc(hi: [1, 2, 3])
118 | cxt = doc.createContext()
119 | hi = cxt.createContextAt("hi")
120 | hi.createContextAt(0).remove()
121 | assert.deepEqual cxt.get(),
122 | hi: [2, 3]
123 |
124 | hi.remove()
125 | assert.deepEqual cxt.get(), {}
126 |
127 | doc = new Doc(hi: [1, 2, 3])
128 | cxt = doc.createContext()
129 | cxt.remove(["hi", 0])
130 | assert.deepEqual cxt.get(),
131 | hi: [2, 3]
132 |
133 |
134 | it "remove multiple items", ->
135 | doc = new Doc(hi: [1, 2, 3])
136 | cxt = doc.createContext()
137 | hi = cxt.createContextAt("hi")
138 | hi.remove(0, 2)
139 | assert.deepEqual cxt.get(),
140 | hi: [3]
141 |
142 | hi.remove()
143 | assert.deepEqual cxt.get(), {}
144 |
145 | it "insert text", ->
146 | doc = new Doc(text: "Hello there!")
147 | cxt = doc.createContext()
148 | cxt.createContextAt("text").insert 11, ", ShareJS"
149 | assert.deepEqual cxt.get(),
150 | text: "Hello there, ShareJS!"
151 |
152 |
153 | it "delete text", ->
154 | doc = new Doc(text: "Sup, share?")
155 | cxt = doc.createContext()
156 | cxt.createContextAt("text").remove 3, 7
157 | assert.deepEqual cxt.get(),
158 | text: "Sup?"
159 |
160 | doc = new Doc(text: "Sup, share?")
161 | cxt = doc.createContext()
162 | cxt.remove ["text", 3], 7
163 | assert.deepEqual cxt.get(),
164 | text: "Sup?"
165 |
166 |
167 | it "list insert", ->
168 | doc = new Doc(nums: [1, 2])
169 | cxt = doc.createContext()
170 | cxt.createContextAt("nums").insert 0, 4
171 | assert.deepEqual cxt.get(),
172 | nums: [4, 1, 2]
173 |
174 | doc = new Doc(nums: [1, 2])
175 | cxt = doc.createContext()
176 | cxt.insert ["nums", 0], 4
177 | assert.deepEqual cxt.get(),
178 | nums: [4, 1, 2]
179 |
180 |
181 | it "list push", ->
182 | doc = new Doc(nums: [1, 2])
183 | cxt = doc.createContext()
184 | cxt.createContextAt("nums").push 3
185 | assert.deepEqual cxt.get(),
186 | nums: [1, 2, 3]
187 |
188 | doc = new Doc(nums: [1, 2])
189 | cxt = doc.createContext()
190 | cxt.push ["nums"], 3
191 | assert.deepEqual cxt.get(),
192 | nums: [1, 2, 3]
193 |
194 |
195 | it "list move", (done) ->
196 | doc = new Doc(list: [1, 2, 3, 4])
197 | cxt = doc.createContext()
198 | list = cxt.createContextAt("list")
199 | list.move 0, 3
200 | assert.deepEqual cxt.get(),
201 | list: [2, 3, 4, 1]
202 |
203 | doc = new Doc(list: [1, 2, 3, 4])
204 | cxt = doc.createContext()
205 | cxt.move ["list"], 0, 3
206 | assert.deepEqual cxt.get(),
207 | list: [2, 3, 4, 1]
208 | done()
209 |
210 | it "number add", ->
211 | doc = new Doc([1])
212 | cxt = doc.createContext()
213 | cxt.createContextAt(0).add 4
214 | assert.deepEqual cxt.get(), [5]
215 |
216 | doc = new Doc([1])
217 | cxt = doc.createContext()
218 | cxt.add [0], 4
219 | assert.deepEqual cxt.get(), [5]
220 |
221 | it "basic listeners", (done) ->
222 | doc = new Doc(list: [1])
223 | cxt = doc.createContext()
224 | cxt.createContextAt("list").on "insert", (pos, num) ->
225 | assert.equal num, 4
226 | assert.equal pos, 0
227 | done()
228 |
229 | apply cxt, [
230 | p: ["list", 0]
231 | li: 4
232 | ]
233 |
234 | it "object replace listener", (done) ->
235 | doc = new Doc(foo: "bar")
236 | cxt = doc.createContext()
237 | cxt.createContextAt().on "replace", (pos, before, after) ->
238 | assert.equal before, "bar"
239 | assert.equal after, "baz"
240 | assert.equal pos, "foo"
241 | done()
242 |
243 | apply cxt, [
244 | p: ["foo"]
245 | od: "bar"
246 | oi: "baz"
247 | ]
248 |
249 | it "list replace listener", (done) ->
250 | doc = new Doc(["bar"])
251 | cxt = doc.createContext()
252 | cxt.createContextAt().on "replace", (pos, before, after) ->
253 | assert.equal before, "bar"
254 | assert.equal after, "baz"
255 | assert.equal pos, 0
256 | done()
257 |
258 | apply cxt, [
259 | p: [0]
260 | ld: "bar"
261 | li: "baz"
262 | ]
263 |
264 | it "listener moves on li", (done) ->
265 | doc = new Doc(["bar"])
266 | cxt = doc.createContext()
267 | cxt.createContextAt(0).on "insert", (i, s) ->
268 | assert.equal s, "foo"
269 | assert.equal i, 0
270 | done()
271 |
272 | cxt.createContextAt().insert 0, "asdf"
273 |
274 | apply cxt, [
275 | p: [1, 0]
276 | si: "foo"
277 | ]
278 |
279 | it "listener moves on ld", (done) ->
280 | doc = new Doc(["asdf", "bar"])
281 | cxt = doc.createContext()
282 | cxt.createContextAt(1).on "insert", (i, s) ->
283 | assert.equal s, "foo"
284 | assert.equal i, 0
285 | done()
286 |
287 | cxt.createContextAt(0).remove()
288 | apply cxt, [
289 | p: [0, 0]
290 | si: "foo"
291 | ]
292 |
293 | it "listener moves on array lm", (done) ->
294 | doc = new Doc(["asdf", "bar"])
295 | cxt = doc.createContext()
296 | cxt.createContextAt(1).on "insert", (i, s) ->
297 | assert.equal s, "foo"
298 | assert.equal i, 0
299 | done()
300 |
301 | cxt.createContextAt().move 0, 1
302 | apply cxt, [
303 | p: [0, 0]
304 | si: "foo"
305 | ]
306 |
307 | it "listener drops on ld", (done) ->
308 | doc = new Doc([1])
309 | cxt = doc.createContext()
310 | cxt.createContextAt(0).on "add", (x) ->
311 | assert.ok false
312 | done()
313 |
314 | cxt.createContextAt(0).set 3
315 | apply cxt, [
316 | p: [0]
317 | na: 1
318 | ]
319 | waitBriefly(done)
320 |
321 |
322 | it "listener drops on od", (done) ->
323 | doc = new Doc(foo: "bar")
324 | cxt = doc.createContext()
325 | cxt.createContextAt("foo").on "text-insert", (text, pos) ->
326 | assert.ok false
327 | done()
328 |
329 | cxt.createContextAt("foo").set "baz"
330 | apply cxt, [
331 | p: ["foo", 0]
332 | si: "asdf"
333 | ]
334 | waitBriefly(done)
335 |
336 | it "child op one level", (done) ->
337 | doc = new Doc(foo: "bar")
338 | cxt = doc.createContext()
339 | cxt.createContextAt().on "child op", (p, op) ->
340 | assert.deepEqual p, ["foo", 0]
341 | assert.equal op.si, "baz"
342 | done()
343 |
344 | apply cxt, [
345 | p: ["foo", 0]
346 | si: "baz"
347 | ]
348 |
349 | it "child op two levels", (done) ->
350 | doc = new Doc(foo: ["bar"])
351 | cxt = doc.createContext()
352 | cxt.createContextAt().on "child op", (p, op) ->
353 | assert.deepEqual p, ["foo", 0, 3]
354 | assert.deepEqual op.si, "baz"
355 | done()
356 |
357 | apply cxt, [
358 | p: ["foo", 0, 3]
359 | si: "baz"
360 | ]
361 |
362 | it "child op path snipping", (done) ->
363 | doc = new Doc(foo: ["bar"])
364 | cxt = doc.createContext()
365 | cxt.createContextAt("foo").on "child op", (p, op) ->
366 | assert.deepEqual p, [0, 3]
367 | assert.deepEqual op.si, "baz"
368 | done()
369 |
370 | apply cxt, [
371 | p: ["foo", 0, 3]
372 | si: "baz"
373 | ]
374 |
375 | it "common operation paths intersection", (done) ->
376 | doc = new Doc(
377 | name: "name"
378 | components: []
379 | )
380 | cxt = doc.createContext()
381 | cxt.createContextAt("name").on "insert", (p, op) ->
382 |
383 | cxt.createContextAt("components").on "child op", (p, op) ->
384 | done()
385 |
386 | apply cxt, [
387 | p: ["name", 4]
388 | si: "X"
389 | ]
390 |
391 | it "child op not sent when op outside node", (done) ->
392 | doc = new Doc(foo: ["bar"])
393 | cxt = doc.createContext()
394 | cxt.createContextAt("foo").on "child op", ->
395 | assert.ok false
396 | done()
397 |
398 | cxt.createContextAt("baz").set "hi"
399 | waitBriefly(done)
400 |
401 |
402 | it "continues to work after list move operation", (done) ->
403 | doc = new Doc([
404 | {top:{foo:'bar'}},
405 | {bottom:'other'}
406 | ])
407 | cxt = doc.createContext()
408 | sub = cxt.createContextAt [0,'top']
409 |
410 | assert.deepEqual(sub.get(),{foo:'bar'})
411 |
412 | cxt.createContextAt().move 0, 1, ->
413 | assert.deepEqual sub.get(),{foo:'bar'}
414 | done()
415 |
416 | it "removes itself from the context on destroy", (done) ->
417 | doc = new Doc({foo:'bar'})
418 | cxt = doc.createContext()
419 | sub = cxt.createContextAt 'foo'
420 |
421 | assert.equal(cxt._subdocs.length,1)
422 | sub.destroy()
423 | assert.equal(cxt._subdocs.length,0)
424 | done()
425 |
--------------------------------------------------------------------------------
/test/server/middleware.coffee:
--------------------------------------------------------------------------------
1 | shareServer = require '../../lib/server'
2 | assert = require 'assert'
3 | sinon = require 'sinon'
4 |
5 | describe 'ShareInstance middleware _trigger', ->
6 |
7 | backend =
8 | bulkSubscribe: ->
9 |
10 | beforeEach ->
11 | @instance = shareServer.createClient(backend: backend)
12 |
13 | it 'runs all middleware', (done)->
14 | middleware1 = sinon.spy (request, next)-> next()
15 | middleware2 = sinon.spy (request, next)-> next()
16 | @instance.use 'q', middleware1
17 | @instance.use 'q', middleware2
18 | @instance._trigger {action: 'q', msg: 'say what'}, ->
19 | sinon.assert.calledWith middleware1, {action: 'q', msg: 'say what'}
20 | sinon.assert.calledWith middleware2, {action: 'q', msg: 'say what'}
21 | done()
22 |
23 | it 'runs middleware in samed order as used', ->
24 | middleware1 = sinon.spy (request, next)-> next()
25 | middleware2 = sinon.spy (request, next)-> next()
26 | @instance.use 'q', middleware1
27 | @instance.use 'q', middleware2
28 | @instance._trigger {action: 'q', msg: 'say what'}
29 | sinon.assert.callOrder(middleware1, middleware2)
30 |
31 | it 'without callback runs all middleware', (done)->
32 | middleware1 = sinon.spy (request, next)-> next()
33 | middleware2 = sinon.spy (request, next)-> next()
34 | @instance.use 'q', middleware1
35 | @instance.use 'q', middleware2
36 | @instance._trigger {action: 'q', msg: 'say what'}
37 | sinon.assert.calledWith middleware1, {action: 'q', msg: 'say what'}
38 | sinon.assert.calledWith middleware2, {action: 'q', msg: 'say what'}
39 | done()
40 |
41 | it 'modifies request', ->
42 | change = (request, next)->
43 | change.msg = request.msg
44 | request.msg = 'gruezi'
45 | next()
46 | before = (request, next)->
47 | before.msg = request.msg
48 | next()
49 | after = (request, next)->
50 | after.msg = request.msg
51 | next()
52 |
53 | @instance.use 'q', before
54 | @instance.use 'q', change
55 | @instance.use 'q', after
56 |
57 | @instance._trigger {action: 'q', msg: 'say what'}
58 | assert.equal before.msg, 'say what'
59 | assert.equal change.msg, 'say what'
60 | assert.equal after.msg, 'gruezi'
61 |
62 |
63 | it 'interrupts execution on errors', ->
64 | middleware1 = sinon.spy (request, next)-> next('error')
65 | middleware2 = sinon.spy (request, next)-> next()
66 | @instance.use 'q', middleware1
67 | @instance.use 'q', middleware2
68 | @instance._trigger {action: 'q', msg: 'say what'}, (error)->
69 | assert.equal error, 'error'
70 | sinon.assert.called middleware1
71 | sinon.assert.notCalled middleware2
72 |
--------------------------------------------------------------------------------
/test/server/query.coffee:
--------------------------------------------------------------------------------
1 |
2 | it 'returns documents that exist when you subscribe'
3 |
4 | it 'fetches documents in the result set'
5 |
6 | it 'fills the documents with data when you set autoFetch = true'
7 |
8 | it 'adds documents when its subscribed'
9 |
10 | it 'removes documents when its subscribed'
11 |
12 |
13 | it 'calls subscribe callback'
14 |
15 | it 'subscribes once if you call subscribe multiple times'
16 |
--------------------------------------------------------------------------------
/test/server/rest.coffee:
--------------------------------------------------------------------------------
1 | # Tests for the REST-ful interface
2 |
3 | assert = require 'assert'
4 | http = require 'http'
5 |
6 | rest = require '../../lib/server/rest'
7 | textType = require('ot-text').type
8 | connect = require 'connect'
9 |
10 | # Async fetch. Aggregates whole response and sends to callback.
11 | # Callback should be function(response, data) {...}
12 | fetch = (method, port, path, postData, extraHeaders, callback) ->
13 | if typeof extraHeaders == 'function'
14 | callback = extraHeaders
15 | extraHeaders = null
16 |
17 | headers = extraHeaders || {'x-testing': 'booyah'}
18 |
19 | request = http.request {method, path, host: 'localhost', port, headers}, (response) ->
20 | data = ''
21 | response.on 'data', (chunk) -> data += chunk
22 | response.on 'end', ->
23 | data = data.trim()
24 | if response.headers['content-type'] == 'application/json'
25 | data = JSON.parse(data)
26 |
27 | callback response, data, response.headers
28 |
29 | if postData?
30 | postData = JSON.stringify(postData) if typeof(postData) == 'object'
31 | request.write postData
32 |
33 | request.end()
34 |
35 | # Frontend tests
36 | describe 'rest', ->
37 | beforeEach (done) ->
38 | @collection = '__c'
39 | @doc = '__doc'
40 |
41 | # Tests fill this in to provide expected backend functionality
42 | @docs = {}
43 | @ops = {}
44 | @userAgent =
45 | fetch: (cName, docName, callback) => callback null, @docs[cName]?[docName] ? {v:0}
46 | getOps: (cName, docName, start, end, callback) =>
47 | ops = @ops[cName]?[docName] ? []
48 | start = 0 if start < 0
49 |
50 | if end is null
51 | callback null, ops.slice start
52 | else
53 | return callback null, [] if end <= start
54 | callback null, ops.slice start, start + end
55 |
56 | sessionId: 'session id' # The unique client ID
57 | trigger: (a, b, c, d, callback) -> callback()
58 |
59 | @instance =
60 | createAgent: (req) => @userAgent
61 |
62 | app = connect()
63 | app.use '/doc', rest(@instance)
64 | @port = 4321
65 | @server = app.listen @port, done
66 |
67 | afterEach (done) ->
68 | @server.on 'close', done
69 | @server.close()
70 |
71 | describe 'GET/HEAD', ->
72 | it 'returns 404 for nonexistant documents', (done) ->
73 | fetch 'GET', @port, "/doc/#{@collection}/#{@name}", null, (res, data, headers) ->
74 | assert.strictEqual res.statusCode, 404
75 | assert.strictEqual headers['x-ot-version'], '0'
76 | assert.equal headers['x-ot-type'], null
77 | done()
78 |
79 | it 'return 404 and empty body when on HEAD on a nonexistant document', (done) ->
80 | fetch 'HEAD', @port, "/doc/#{@collection}/#{@name}", null, (res, data, headers) ->
81 | assert.strictEqual res.statusCode, 404
82 | assert.strictEqual data, ''
83 | assert.strictEqual headers['x-ot-version'], '0'
84 | assert.equal headers['x-ot-type'], null
85 | done()
86 |
87 | it 'returns 200, empty body, version and type when on HEAD on a document', (done) ->
88 | @docs.c = {}
89 | @docs.c.d = {v:1, type:textType.uri, data:'hi there'}
90 |
91 | fetch 'HEAD', @port, "/doc/c/d", null, (res, data, headers) ->
92 | assert.strictEqual res.statusCode, 200
93 | assert.strictEqual headers['x-ot-version'], '1'
94 | assert.strictEqual headers['x-ot-type'], textType.uri
95 | assert.ok headers['etag']
96 | assert.strictEqual data, ''
97 | done()
98 |
99 | it 'document returns the document snapshot', (done) ->
100 | @docs.c = {}
101 | @docs.c.d = {v:1, type:textType.uri, data:{str:'Hi'}}
102 |
103 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) ->
104 | assert.strictEqual res.statusCode, 200
105 | assert.strictEqual headers['x-ot-version'], '1'
106 | assert.strictEqual headers['x-ot-type'], textType.uri
107 | assert.ok headers['etag']
108 | assert.strictEqual headers['content-type'], 'application/json'
109 | assert.deepEqual data, {str:'Hi'}
110 | done()
111 |
112 | it 'document returns the entire document structure when envelope=true', (done) ->
113 | @docs.c = {}
114 | @docs.c.d = {v:1, type:textType.uri, data:{str:'Hi'}}
115 |
116 | fetch 'GET', @port, "/doc/c/d?envelope=true", null, (res, data, headers) ->
117 | assert.strictEqual res.statusCode, 200
118 | assert.strictEqual headers['x-ot-version'], '1'
119 | assert.strictEqual headers['x-ot-type'], textType.uri
120 | assert.strictEqual headers['content-type'], 'application/json'
121 | assert.deepEqual data, {v:1, type:textType.uri, data:{str:'Hi'}}
122 | done()
123 |
124 | it 'a plaintext document is returned as a string', (done) ->
125 | @docs.c = {}
126 | @docs.c.d = {v:1, type:textType.uri, data:'hi'}
127 |
128 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) ->
129 | assert.strictEqual res.statusCode, 200
130 | assert.strictEqual headers['x-ot-version'], '1'
131 | assert.strictEqual headers['x-ot-type'], textType.uri
132 | assert.ok headers['etag']
133 | assert.strictEqual headers['content-type'], 'text/plain'
134 | assert.deepEqual data, 'hi'
135 | done()
136 |
137 | it 'ETag is the same between responses', (done) ->
138 | @docs.c = {}
139 | @docs.c.d = {v:1, type:textType.uri, data:'hi'}
140 |
141 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) =>
142 | tag = headers['etag']
143 |
144 | # I don't care what the etag is, but if I fetch it again it should be the same.
145 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) ->
146 | assert.strictEqual headers['etag'], tag
147 | done()
148 |
149 | it 'ETag changes when version changes', (done) ->
150 | @docs.c = {}
151 | @docs.c.d = {v:1, type:textType.uri, data:'hi'}
152 |
153 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) =>
154 | tag = headers['etag']
155 | @docs.c.d.v = 2
156 | fetch 'GET', @port, "/doc/c/d", null, (res, data, headers) =>
157 | assert.notStrictEqual headers['etag'], tag
158 | done()
159 |
160 |
161 | describe 'GET /ops', ->
162 | it 'returns ops', (done) ->
163 | @ops.c = {}
164 | ops = @ops.c.d = [{v:0, create:{type:textType.uri}}, {v:1, op:[]}, {v:2, op:[]}]
165 | fetch 'GET', @port, '/doc/c/d/ops', null, (res, data, headers) ->
166 | assert.strictEqual res.statusCode, 200
167 | assert.deepEqual data, ops
168 | done()
169 |
170 | it 'limits FROM based on query parameter', (done) ->
171 | @ops.c = {}
172 | ops = @ops.c.d = [{v:0, create:{type:textType.uri}}, {v:1, op:[]}, {v:2, op:[]}]
173 | fetch 'GET', @port, '/doc/c/d/ops?to=2', null, (res, data, headers) ->
174 | assert.strictEqual res.statusCode, 200
175 | assert.deepEqual data, [ops[0], ops[1]]
176 | done()
177 |
178 | it 'limits TO based on query parameter', (done) ->
179 | @ops.c = {}
180 | ops = @ops.c.d = [{v:0, create:{type:textType.uri}}, {v:1, op:[]}, {v:2, op:[]}]
181 | fetch 'GET', @port, '/doc/c/d/ops?from=1', null, (res, data, headers) ->
182 | assert.strictEqual res.statusCode, 200
183 | assert.deepEqual data, [ops[1], ops[2]]
184 | done()
185 |
186 | it 'returns empty list for nonexistant document', (done) ->
187 | fetch 'GET', @port, '/doc/c/d/ops', null, (res, data, headers) ->
188 | assert.strictEqual res.statusCode, 200
189 | assert.deepEqual data, []
190 | done()
191 |
192 | describe 'POST', ->
193 | it 'lets you submit', (done) ->
194 | called = false
195 | @userAgent.submit = (cName, docName, opData, options, callback) ->
196 | assert.strictEqual cName, 'c'
197 | assert.strictEqual docName, 'd'
198 | assert.deepEqual opData, {v:5, op:[1,2,3]}
199 | called = true
200 | callback null, 5, []
201 |
202 | fetch 'POST', @port, "/doc/c/d", {v:5, op:[1,2,3]}, (res, ops) =>
203 | assert.strictEqual res.statusCode, 200
204 | assert.deepEqual ops, []
205 | assert called
206 | done()
207 |
208 | it 'POST a document with invalid JSON returns 400', (done) ->
209 | fetch 'POST', @port, "/doc/c/d", 'invalid>{json', (res, data) ->
210 | assert.strictEqual res.statusCode, 400
211 | done()
212 |
213 | describe 'PUT', ->
214 | it 'PUT a document creates it', (done) ->
215 | called = false
216 | @userAgent.submit = (cName, docName, opData, options, callback) ->
217 | assert.strictEqual cName, 'c'
218 | assert.strictEqual docName, 'd'
219 | assert.deepEqual opData, {create:{type:'simple'}}
220 | called = true
221 | callback null, 5, []
222 |
223 | fetch 'PUT', @port, "/doc/c/d", {type:'simple'}, (res, data, headers) =>
224 | assert.strictEqual res.statusCode, 200
225 | assert.strictEqual headers['x-ot-version'], '5'
226 |
227 | assert called
228 | done()
229 |
230 | describe 'DELETE', ->
231 | it 'deletes a document', (done) ->
232 | called = false
233 | @userAgent.submit = (cName, docName, opData, options, callback) ->
234 | assert.strictEqual cName, 'c'
235 | assert.strictEqual docName, 'd'
236 | assert.deepEqual opData, {del:true}
237 | called = true
238 | callback null, 5, []
239 |
240 | fetch 'DELETE', @port, "/doc/c/d", null, (res, data, headers) =>
241 | assert.strictEqual res.statusCode, 200
242 | assert.strictEqual headers['x-ot-version'], '5'
243 | assert called
244 | done()
245 |
246 |
247 | # Tests past this line haven't been rewritten yet for the new API.
248 |
249 | ###
250 | 'Cannot do anything if the server doesnt allow client connections': (test) ->
251 | @auth = (agent, action) ->
252 | assert.strictEqual action.type, 'connect'
253 | test.ok agent.remoteAddress in ['localhost', '127.0.0.1'] # Is there a nicer way to do this?
254 | assert.strictEqual typeof agent.sessionId, 'string'
255 | test.ok agent.sessionId.length > 5
256 | test.ok agent.connectTime
257 |
258 | assert.strictEqual typeof agent.headers, 'object'
259 |
260 | # This is added above
261 | assert.strictEqual agent.headers['x-testing'], 'booyah'
262 |
263 | action.reject()
264 |
265 | passPart = makePassPart test, 7
266 | checkResponse = (res, data) ->
267 | assert.strictEqual(res.statusCode, 403)
268 | assert.deepEqual data, 'Forbidden'
269 | passPart()
270 |
271 | # Non existant document
272 | doc1 = newDocName()
273 |
274 | # Get
275 | fetch 'GET', @port, "/doc/#{doc1}", null, checkResponse
276 |
277 | # Create
278 | fetch 'PUT', @port, "/doc/#{doc1}", {type:'simple'}, checkResponse
279 |
280 | # Submit an op to a nonexistant doc
281 | fetch 'POST', @port, "/doc/#{doc1}?v=0", {position: 0, text: 'Hi'}, checkResponse
282 |
283 | # Existing document
284 | doc2 = newDocName()
285 | @model.create doc2, 'simple', =>
286 | @model.applyOp doc2, {v:0, op:{position: 0, text: 'Hi'}}, =>
287 | fetch 'GET', @port, "/doc/#{doc2}", null, checkResponse
288 |
289 | # Create an existing document
290 | fetch 'PUT', @port, "/doc/#{doc2}", {type:'simple'}, checkResponse
291 |
292 | # Submit an op to an existing document
293 | fetch 'POST', @port, "/doc/#{doc2}?v=0", {position: 0, text: 'Hi'}, checkResponse
294 |
295 | # Delete a document
296 | fetch 'DELETE', @port, "/doc/#{doc2}", null, checkResponse
297 |
298 | "Can't GET if read is rejected": (test) ->
299 | @auth = (client, action) -> if action.type == 'read' then action.reject() else action.accept()
300 |
301 | @model.create @name, 'simple', =>
302 | @model.applyOp @name, {v:0, op:{position: 0, text: 'Hi'}}, =>
303 | fetch 'GET', @port, "/doc/#{@name}", null, (res, data) ->
304 | assert.strictEqual(res.statusCode, 403)
305 | assert.deepEqual data, 'Forbidden'
306 | done()
307 |
308 | "Can't PUT if create is rejected": (test) ->
309 | @auth = (client, action) -> if action.type == 'create' then action.reject() else action.accept()
310 |
311 | fetch 'PUT', @port, "/doc/#{@name}", {type:'simple'}, (res, data) =>
312 | assert.strictEqual res.statusCode, 403
313 | assert.deepEqual data, 'Forbidden'
314 |
315 | @model.getSnapshot @name, (error, doc) ->
316 | test.equal doc, null
317 | done()
318 |
319 | "Can't POST if submit op is rejected": (test) ->
320 | @auth = (client, action) -> if action.type == 'update' then action.reject() else action.accept()
321 |
322 | @model.create @name, 'simple', =>
323 | fetch 'POST', @port, "/doc/#{@name}?v=0", {position: 0, text: 'Hi'}, (res, data) =>
324 | assert.strictEqual res.statusCode, 403
325 | assert.deepEqual data, 'Forbidden'
326 |
327 | # & Check the document is unchanged
328 | @model.getSnapshot @name, (error, doc) ->
329 | assert.deepEqual doc, {v:0, type:types.simple, snapshot:{str:''}, meta:{}}
330 | done()
331 |
332 | 'A Forbidden DELETE on a nonexistant document returns 403': (test) ->
333 | @auth = (client, action) -> if action.type == 'delete' then action.reject() else action.accept()
334 |
335 | fetch 'DELETE', @port, "/doc/#{@name}", null, (res, data) ->
336 | assert.strictEqual res.statusCode, 403
337 | assert.deepEqual data, 'Forbidden'
338 | done()
339 |
340 | "Can't DELETE if delete is rejected": (test) ->
341 | @auth = (client, action) -> if action.type == 'delete' then action.reject() else action.accept()
342 |
343 | @model.create @name, 'simple', =>
344 | fetch 'DELETE', @port, "/doc/#{@name}", null, (res, data) =>
345 | assert.strictEqual res.statusCode, 403
346 | assert.deepEqual data, 'Forbidden'
347 |
348 | @model.getSnapshot @name, (error, doc) ->
349 | test.ok doc
350 | done()
351 |
352 | ###
353 |
--------------------------------------------------------------------------------
/test/server/session.coffee:
--------------------------------------------------------------------------------
1 | # Tests for the server session code.
2 | #
3 | # Most tests for this code should be in the session integration tests because
4 | # testing the protocol directly means the tests have to change when the wire
5 | # protocol changes.
6 |
7 | assert = require 'assert'
8 | {Duplex, Readable} = require 'stream'
9 | {EventEmitter} = require 'events'
10 | textType = require('ot-text').type
11 |
12 | Session = require '../../lib/server/session'
13 |
14 | describe.skip 'session', ->
15 | beforeEach ->
16 | @stream = new Duplex objectMode:yes
17 |
18 | @userAgent =
19 | sessionId: 'session id' # The unique client ID
20 | fetchAndSubscribe: (collection, doc, callback) =>
21 | @subscribedCollection = collection
22 | @subscribedDoc = doc
23 |
24 | return callback @subscribeError if @subscribeError
25 |
26 | @opStream = new Readable objectMode:yes
27 | @opStream._read = ->
28 | callback null, {v:100, type:textType, data:'hi there'}, @opStream
29 | trigger: (a, b, c, d, callback) -> callback()
30 |
31 | @instance =
32 | createAgent: (stream) =>
33 | assert.strictEqual stream, @stream
34 | @userAgent
35 |
36 | @send = (data) =>
37 | #console.log 'C->S', JSON.stringify data
38 | @stream.push data
39 |
40 | @stream._write = (chunk, encoding, callback) =>
41 | console.log 'S->C', JSON.stringify chunk
42 | @onmessage? chunk
43 | callback()
44 | @stream._read = ->
45 |
46 | # Let the test register an onmessage handler before creating the session.
47 | process.nextTick =>
48 | @session = new Session(@instance, @stream)
49 |
50 | afterEach ->
51 | @stream.emit 'close'
52 | @stream.emit 'end'
53 | @stream.end()
54 |
55 | # This is just a smoke test. Most of the tests for session should be done in
56 | # the session integration tests to allow the client-server API to change.
57 | it 'gives the client a session id', ->
58 | @onmessage = (msg) ->
59 | assert.deepEqual msg, a:'init', protocol:0, id:'session id'
60 |
61 |
--------------------------------------------------------------------------------
/test/server/testhelpers.coffee:
--------------------------------------------------------------------------------
1 | assert = require 'assert'
2 |
3 | helpers = require '../helpers'
4 |
5 | # Testing tool tests
6 | describe 'helpers', ->
7 | describe '#newDocName()', ->
8 | it 'creates a new doc name with each invocation', ->
9 | assert.notStrictEqual helpers.newDocName(), helpers.newDocName()
10 | assert.strictEqual typeof helpers.newDocName(), 'string'
11 |
12 | describe '#makePassPart()', ->
13 | it '#makePassPart() works', (done) ->
14 | passPart = helpers.makePassPart 3, done
15 | passPart()
16 | passPart()
17 | passPart()
18 |
19 | describe '#randomInt()', ->
20 | it 'never returns a value outside its range', ->
21 | for [1..1000]
22 | assert 0 <= helpers.randomInt(100) < 100
23 |
24 | it 'always returns an integer', ->
25 | for [1..1000]
26 | val = helpers.randomInt(100)
27 | assert.equal val, Math.floor val
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/test/server/text-api.coffee:
--------------------------------------------------------------------------------
1 | # Tests for the text types using the DSL interface. This includes the standard
2 | # text type as well as text-tp2 (and any other text types we add). Rich text
3 | # should probably support this API too.
4 | assert = require 'assert'
5 | {randomInt, randomReal, randomWord} = require 'ot-fuzzer'
6 |
7 | genTests = (type, genOp) -> describe "text api for '#{type.name}'", ->
8 | beforeEach ->
9 | # This is a little copy of the context structure created in client/doc.
10 | # It would probably be better to copy the code, but whatever.
11 | @ctx =
12 | _snapshot: type.create()
13 | getSnapshot: -> @_snapshot
14 | submitOp: (op, callback) ->
15 | op = type.normalize op
16 | @_snapshot = type.apply @_snapshot, op
17 | callback?()
18 |
19 | @apply = (op) ->
20 | @ctx._beforeOp? op
21 | @ctx.submitOp op
22 | @ctx._onOp op
23 |
24 | @ctx[k] = v for k, v of type.api
25 |
26 |
27 | it 'has no length when empty', ->
28 | assert.strictEqual @ctx.get(), ''
29 | assert.strictEqual @ctx.getLength(), 0
30 |
31 | it 'works with simple inserts and removes', ->
32 | @ctx.insert 0, 'hi'
33 | assert.strictEqual @ctx.get(), 'hi'
34 | assert.strictEqual @ctx.getLength(), 2
35 |
36 | @ctx.insert 2, ' mum'
37 | assert.strictEqual @ctx.get(), 'hi mum'
38 | assert.strictEqual @ctx.getLength(), 6
39 |
40 | @ctx.remove 0, 3
41 | assert.strictEqual @ctx.get(), 'mum'
42 | assert.strictEqual @ctx.getLength(), 3
43 |
44 | it 'gets edited correctly', ->
45 | # This is slow with text-tp2 because the snapshot gets filled with crap and
46 | # basically cloned with every operation in apply(). It could be fixed at
47 | # some point by making the document snapshot mutable (and make apply() not
48 | # clone the snapshot).
49 | #
50 | # If you do this, you'll also have to fix text-tp2.api._onOp. It currently
51 | # relies on being able to iterate through the previous document snapshot to
52 | # figure out what was inserted & removed.
53 | content = ''
54 |
55 | for i in [1..1000]
56 | if content.length == 0 || randomReal() > 0.5
57 | # Insert
58 | pos = randomInt(content.length + 1)
59 | str = randomWord() + ' '
60 | @ctx.insert pos, str
61 | content = content[...pos] + str + content[pos..]
62 | else
63 | # Delete
64 | pos = randomInt content.length
65 | len = Math.min(randomInt(4), content.length - pos)
66 | @ctx.remove pos, len
67 | content = content[...pos] + content[(pos + len)..]
68 |
69 | assert.strictEqual @ctx.get(), content
70 | assert.strictEqual @ctx.getLength(), content.length
71 |
72 | it 'emits events correctly', ->
73 | contents = ''
74 |
75 | @ctx.onInsert = (pos, text) ->
76 | contents = contents[...pos] + text + contents[pos...]
77 | @ctx.onRemove = (pos, len) ->
78 | contents = contents[...pos] + contents[(pos + len)...]
79 |
80 | for i in [1..1000]
81 | [op, newDoc] = genOp @ctx._snapshot
82 |
83 | @apply op
84 | assert.strictEqual @ctx.get(), contents
85 |
86 | genTests require('ot-text').type, require('ot-text/test/genOp')
87 | genTests require('ot-text-tp2').type, require('ot-text-tp2/test/genOp')
88 |
--------------------------------------------------------------------------------
/test/server/useragent.coffee:
--------------------------------------------------------------------------------
1 | sinon = require 'sinon'
2 | assert = require 'assert'
3 |
4 | UserAgent = require '../../lib/server/useragent'
5 | {Readable} = require 'stream'
6 | {EventEmitter} = require 'events'
7 | server = require '../../lib/server'
8 |
9 | describe 'UserAgent', ->
10 |
11 | backend = {}
12 |
13 | shareInstance =
14 | docFilters: []
15 | opFilters: []
16 | backend: backend
17 | _trigger: (request, callback) ->
18 | callback(null, request)
19 |
20 | beforeEach ->
21 | @userAgent = new UserAgent shareInstance
22 |
23 | shareInstance.docFilters = []
24 | shareInstance.opFilters = []
25 |
26 |
27 | describe 'fetch', ->
28 | backend.fetch = sinon.stub().yields null, {v:10, color: 'yellow'}
29 |
30 | it 'calls fetch on backend', (done) ->
31 | @userAgent.fetch 'flowers', 'lily', ->
32 | sinon.assert.calledWith backend.fetch, 'flowers', 'lily'
33 | done()
34 |
35 | it 'returns backend result', (done)->
36 | @userAgent.fetch 'flowers', 'lily', (error, document)->
37 | assert.deepEqual document, {v: 10, color: 'yellow'}
38 | done()
39 |
40 | describe 'with doc filters', ->
41 |
42 | it 'calls filter', (done) ->
43 | filter = sinon.spy (args..., next) -> next()
44 | shareInstance.docFilters.push filter
45 | @userAgent.fetch 'flowers', 'lily', (error, document)=>
46 | sinon.assert.calledWith filter, 'flowers', 'lily', {color: 'yellow', v: 10}
47 | done()
48 |
49 | it 'manipulates document', (done) ->
50 | shareInstance.docFilters.push (collection, docName, data, next) ->
51 | data.color = 'red'
52 | next()
53 | @userAgent.fetch 'flowers', 'lily', (error, document)=>
54 | assert.equal document.color, 'red'
55 | done()
56 |
57 | it.skip 'passes exceptions as error', (done)->
58 | shareInstance.docFilters.push -> throw Error 'oops'
59 | @userAgent.fetch 'flowers', 'lily', (error, document)=>
60 | assert.equal error, 'oops'
61 | done()
62 |
63 | it 'passes errors', (done) ->
64 | shareInstance.docFilters.push (args..., next) -> next('oops')
65 | @userAgent.fetch 'flowers', 'lily', (error, document)=>
66 | assert.equal error, 'oops'
67 | done()
68 |
69 |
70 | describe '#subscribe', ->
71 |
72 | beforeEach ->
73 | @opStream = new Readable objectMode: yes
74 | @opStream._read = ->
75 | @opStream.unpipe()
76 | backend.subscribe = sinon.stub().yields null, @opStream
77 |
78 | afterEach ->
79 | backend.subscribe = null
80 |
81 | it 'calls subscribe on the backend', (done) ->
82 | @userAgent.subscribe 'flowers', 'lily', 10, ->
83 | sinon.assert.calledWith backend.subscribe, 'flowers', 'lily', 10
84 | done()
85 |
86 | it 'can read operationStream', (done) ->
87 | @userAgent.subscribe 'flowers', 'lily', 10, (error, subscriptionStream) =>
88 | subscriptionStream.on 'readable', (data) ->
89 | assert.equal subscriptionStream.read(), 'first operation'
90 | done()
91 | @opStream.push 'first operation'
92 |
93 | describe 'with op filters', ->
94 |
95 | it 'calls the filter', (done) ->
96 | filter = sinon.stub().yields()
97 | shareInstance.opFilters.push filter
98 | @userAgent.subscribe 'flowers', 'lily', 10, (error, subscriptionStream) =>
99 | subscriptionStream.on 'readable', (data) ->
100 | sinon.assert.calledWith filter, 'flowers', 'lily', 'an op'
101 | done()
102 | @opStream.push 'an op'
103 |
104 | it.skip 'passes exceptions as errors to operationStream', (done)->
105 | shareInstance.opFilters.push -> throw Error 'oops'
106 |
107 | @userAgent.subscribe 'flowers', 'lily', 10, (error, subscriptionStream) =>
108 | subscriptionStream.on 'readable', (data) ->
109 | assert.deepEqual subscriptionStream.read(), {error: 'oops'}
110 | done()
111 | @opStream.push {op: 'first operation'}
112 |
113 | it 'passes errors to operationStream', (done) ->
114 | shareInstance.opFilters.push sinon.stub().yields 'oops'
115 |
116 | @userAgent.subscribe 'flowers', 'lily', 10, (error, subscriptionStream) =>
117 | subscriptionStream.on 'readable', (data) ->
118 | assert.deepEqual subscriptionStream.read(), {error: 'oops'}
119 | done()
120 | @opStream.push {op: 'first operation'}
121 |
122 | it 'manipulates operation', (done) ->
123 | shareInstance.opFilters.push (collection, docName, operation, next) ->
124 | operation.op = 'gotcha!'
125 | next()
126 |
127 | @userAgent.subscribe 'flowers', 'lily', 10, (error, subscriptionStream) =>
128 | subscriptionStream.on 'readable', (data) ->
129 | assert.deepEqual subscriptionStream.read(), {op: 'gotcha!'}
130 | done()
131 | @opStream.push {op: 'first operation'}
132 |
133 |
134 | describe '#submit', ->
135 |
136 | backend.submit = (collection, document, opData, options, callback) ->
137 | callback(null, 41, ['operation'], 'a document')
138 |
139 | it 'calls submit on backend', (done) ->
140 | sinon.spy backend, 'submit'
141 | @userAgent.submit 'flowers', 'lily', 'pluck', {}, ->
142 | sinon.assert.calledWith backend.submit, 'flowers', 'lily', 'pluck'
143 | done()
144 |
145 | it 'returns version and operations', (done) ->
146 | @userAgent.submit 'flowers', 'lily', 'pluck', {}, (error, version, operations) ->
147 | assert.equal version, 41
148 | assert.deepEqual operations, ['operation']
149 | done()
150 |
151 | it 'triggers after submit', (done) ->
152 | sinon.spy @userAgent, 'trigger'
153 | @userAgent.submit 'flowers', 'lily', 'pluck', {}, =>
154 | sinon.assert.calledWith @userAgent.trigger, 'after submit', 'flowers', 'lily'
155 | done()
156 |
157 |
158 | describe '#queryFetch', ->
159 | beforeEach ->
160 | backend.queryFetch = sinon.stub().yields null, [
161 | {docName: 'rose', color: 'white'},
162 | {docName: 'lily', color: 'yellow'}]
163 | , 'all'
164 |
165 | afterEach ->
166 | backend.queryFetch = null
167 |
168 | it 'calls queryFetch on backend', (done) ->
169 | @userAgent.queryFetch 'flowers', {smell: 'nice'}, {all: yes}, ->
170 | sinon.assert.calledWith backend.queryFetch, 'flowers', {smell: 'nice'}, {all: yes}
171 | done()
172 |
173 | it 'returns documents and extra', (done) ->
174 | @userAgent.queryFetch 'flowers', {smell: 'nice'}, {all: yes}, (error, results, extra) ->
175 | assert.equal extra, 'all'
176 | assert.deepEqual results[0], {docName: 'rose', color: 'white'}
177 | assert.deepEqual results[1], {docName: 'lily', color: 'yellow'}
178 | done()
179 |
180 | it 'filters documents', (done) ->
181 | shareInstance.docFilters.push (collection, docName, data, next) ->
182 | if docName == 'rose'
183 | data.color = 'red'
184 | next()
185 | @userAgent.queryFetch 'flowers', {}, {}, (error, results) ->
186 | assert.equal results[0].color, 'red'
187 | done()
188 |
189 |
190 | describe '#query', ->
191 |
192 | beforeEach ->
193 | @queryEmitter = {}
194 | results = [{docName: 'lily', color: 'yellow'}]
195 | backend.query = sinon.stub().yields null, @queryEmitter, results
196 |
197 | afterEach ->
198 | backend.query = null
199 |
200 | it 'calls query on backend', (done) ->
201 | @userAgent.query 'flowers', {smell: 'nice'}, {all: yes}, =>
202 | sinon.assert.calledWith backend.query, 'flowers', {smell: 'nice'}, {all: yes}
203 | done()
204 |
205 | it 'returns results', (done) ->
206 | @userAgent.query 'flowers', {}, {}, (error, emitter, results) =>
207 | assert.deepEqual results, [{docName: 'lily', color: 'yellow'}]
208 | done()
209 |
210 | it 'filters records inserted into query results'
211 |
212 |
213 | describe '#trigger with middleware', ->
214 |
215 | beforeEach ->
216 | backend.bulkSubscribe = true
217 | @instance = server.createClient backend: backend
218 | @userAgent.instance = @instance
219 |
220 | it 'runs middleware', (done) ->
221 | @instance.use 'smell', (request, next) ->
222 | done()
223 | @userAgent.trigger 'smell', 'flowers', 'lily', {}
224 |
225 | it 'runs default middleware', (done) ->
226 | @instance.use (request, next) ->
227 | done()
228 | @userAgent.trigger 'smell', 'flowers', 'lily', {}
229 |
230 | it 'runs middleware with request', (done) ->
231 | @instance.use 'smell', (request, next) ->
232 | assert.equal request.action, 'smell'
233 | assert.equal request.collection, 'flowers'
234 | assert.equal request.docName, 'lily'
235 | assert.equal request.deep, true
236 | assert.deepEqual request.backend, backend
237 | done()
238 | @userAgent.trigger 'smell', 'flowers', 'lily', deep: true
239 |
240 | it 'passes modified request to callback', (done) ->
241 | @instance.use 'smell', (request, next) ->
242 | request.eyesClosed = true
243 | next()
244 | @userAgent.trigger 'smell', 'flowers', 'lily', (error, request) ->
245 | assert.ok request.eyesClosed
246 | done()
247 |
248 | it 'passes errors to callback', (done) ->
249 | @instance.use 'smell', (request, next) ->
250 | next('Argh!')
251 | @userAgent.trigger 'smell', 'flowers', 'lily', (error, request) ->
252 | assert.equal error, 'Argh!'
253 | done()
254 |
--------------------------------------------------------------------------------