├── .eslintrc.js ├── .github ├── dependabot.yml └── workflows │ ├── docs.yml │ └── test.yml ├── .gitignore ├── .mocharc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── .gitignore ├── .ruby-version ├── 404.html ├── Gemfile ├── Gemfile.lock ├── _config.yml ├── _sass │ └── custom │ │ └── custom.scss ├── adapters │ ├── database.md │ ├── index.md │ ├── milestone.md │ └── pub-sub.md ├── api │ ├── agent.md │ ├── backend.md │ ├── connection.md │ ├── doc.md │ ├── index.md │ ├── local-presence.md │ ├── presence.md │ ├── query.md │ ├── sharedb-error.md │ └── snapshot.md ├── document-history.md ├── getting-started.md ├── index.md ├── middleware │ ├── actions.md │ ├── index.md │ ├── op-submission.md │ └── registration.md ├── presence.md ├── projections.md ├── pub-sub.md ├── queries.md └── types │ ├── index.md │ └── json0.md ├── examples ├── counter-json1-vite │ ├── .gitignore │ ├── index.html │ ├── main.js │ ├── package.json │ ├── server.js │ └── vite.config.js ├── counter-json1 │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── demo.gif │ ├── package.json │ ├── server.js │ └── static │ │ └── index.html ├── counter │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── demo.gif │ ├── package.json │ ├── server.js │ └── static │ │ └── index.html ├── leaderboard │ ├── .gitignore │ ├── README.md │ ├── client │ │ ├── Body.jsx │ │ ├── Leaderboard.jsx │ │ ├── Player.jsx │ │ ├── PlayerList.jsx │ │ ├── PlayerSelector.jsx │ │ ├── connection.js │ │ └── index.jsx │ ├── demo.gif │ ├── package.json │ ├── server │ │ └── index.js │ └── static │ │ ├── index.html │ │ └── leaderboard.css ├── rich-text-presence │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── package.json │ ├── server.js │ └── static │ │ ├── index.html │ │ └── style.css ├── rich-text │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── package.json │ ├── server.js │ └── static │ │ └── index.html └── textarea │ ├── .gitignore │ ├── README.md │ ├── client.js │ ├── package.json │ ├── server.js │ └── static │ └── index.html ├── lib ├── agent.js ├── backend.js ├── client │ ├── connection.js │ ├── doc.js │ ├── index.js │ ├── presence │ │ ├── doc-presence-emitter.js │ │ ├── doc-presence.js │ │ ├── local-doc-presence.js │ │ ├── local-presence.js │ │ ├── presence.js │ │ ├── remote-doc-presence.js │ │ └── remote-presence.js │ ├── query.js │ └── snapshot-request │ │ ├── snapshot-request.js │ │ ├── snapshot-timestamp-request.js │ │ └── snapshot-version-request.js ├── db │ ├── index.js │ └── memory.js ├── emitter.js ├── error.js ├── index.js ├── logger │ ├── index.js │ └── logger.js ├── message-actions.js ├── milestone-db │ ├── index.js │ ├── memory.js │ └── no-op.js ├── next-tick.js ├── op-stream.js ├── ot.js ├── projections.js ├── protocol.js ├── pubsub │ ├── index.js │ └── memory.js ├── query-emitter.js ├── read-snapshots-request.js ├── snapshot.js ├── stream-socket.js ├── submit-request.js ├── types.js └── util.js ├── package.json ├── scripts └── test-sharedb-mongo.sh └── test ├── .jshintrc ├── BasicQueryableMemoryDB.js ├── agent.js ├── backend.js ├── client ├── connection.js ├── deserialized-type.js ├── doc.js ├── number-type.js ├── pending.js ├── presence │ ├── doc-presence-emitter.js │ ├── doc-presence.js │ ├── presence-pauser.js │ ├── presence-test-type.js │ └── presence.js ├── projections.js ├── query-subscribe.js ├── query.js ├── snapshot-timestamp-request.js ├── snapshot-version-request.js ├── submit-json1.js ├── submit.js └── subscribe.js ├── db-memory.js ├── db.js ├── error.js ├── logger.js ├── middleware.js ├── milestone-db-memory.js ├── milestone-db.js ├── next-tick.js ├── ot.js ├── projections.js ├── protocol.js ├── pubsub-memory.js ├── pubsub.js ├── setup.js └── util.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // The ESLint ecmaVersion argument is inconsistently used. Some rules will ignore it entirely, so if the rule has 2 | // been set, it will still error even if it's not applicable to that version number. Since Google sets these 3 | // rules, we have to turn them off ourselves. 4 | var DISABLED_ES6_OPTIONS = { 5 | 'no-var': 'off', 6 | 'prefer-rest-params': 'off' 7 | }; 8 | 9 | var SHAREDB_RULES = { 10 | // Comma dangle is not supported in ES3 11 | 'comma-dangle': ['error', 'never'], 12 | // We control our own objects and prototypes, so no need for this check 13 | 'guard-for-in': 'off', 14 | // Google prescribes different indents for different cases. Let's just use 2 spaces everywhere. Note that we have 15 | // to override ESLint's default of 0 indents for this. 16 | indent: ['error', 2, { 17 | SwitchCase: 1 18 | }], 19 | // Less aggressive line length than Google, which is especially useful when we have a lot of callbacks in our code 20 | 'max-len': ['error', 21 | { 22 | code: 120, 23 | tabWidth: 2, 24 | ignoreUrls: true 25 | } 26 | ], 27 | // Google overrides the default ESLint behaviour here, which is slightly better for catching erroneously unused 28 | // variables 29 | 'no-unused-vars': ['error', {vars: 'all', args: 'after-used'}], 30 | // It's more readable to ensure we only have one statement per line 31 | 'max-statements-per-line': ['error', {max: 1}], 32 | // ES3 doesn't support spread 33 | 'prefer-spread': 'off', 34 | // as-needed quote props are easier to write 35 | 'quote-props': ['error', 'as-needed'], 36 | 'require-jsdoc': 'off', 37 | 'valid-jsdoc': 'off' 38 | }; 39 | 40 | module.exports = { 41 | extends: 'google', 42 | parserOptions: { 43 | ecmaVersion: 3, 44 | allowReserved: true 45 | }, 46 | rules: Object.assign( 47 | {}, 48 | DISABLED_ES6_OPTIONS, 49 | SHAREDB_RULES 50 | ), 51 | ignorePatterns: [ 52 | '/docs/' 53 | ], 54 | overrides: [ 55 | { 56 | files: ['examples/counter-json1-vite/*.js'], 57 | parserOptions: { 58 | ecmaVersion: 6, 59 | sourceType: 'module', 60 | allowReserved: false 61 | }, 62 | rules: { 63 | quotes: ['error', 'single'] 64 | } 65 | } 66 | ] 67 | }; 68 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - docs/** 9 | pull_request: 10 | branches: 11 | - master 12 | paths: 13 | - docs/** 14 | 15 | jobs: 16 | docs: 17 | name: Jekyll 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 10 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: '2.7' 25 | - name: Install 26 | run: cd docs && gem install bundler -v 2.4.22 && bundle install 27 | - name: Build 28 | run: cd docs && bundle exec jekyll build 29 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | test: 13 | name: Node ${{ matrix.node }} 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | node: 18 | - 18 19 | - 20 20 | - 22 21 | services: 22 | mongodb: 23 | image: mongo:4.4 24 | ports: 25 | - 27017:27017 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node }} 32 | - name: Install 33 | run: npm install 34 | - name: Lint 35 | run: npm run lint 36 | - name: Test 37 | run: npm run test-cover 38 | - name: Coveralls 39 | uses: coverallsapp/github-action@master 40 | with: 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | flag-name: node-${{ matrix.node }} 43 | parallel: true 44 | - name: Test sharedb-mongo 45 | run: scripts/test-sharedb-mongo.sh 46 | 47 | finish: 48 | needs: test 49 | runs-on: ubuntu-latest 50 | steps: 51 | - name: Submit coverage 52 | uses: coverallsapp/github-action@master 53 | with: 54 | github-token: ${{ secrets.GITHUB_TOKEN }} 55 | parallel-finished: true 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS X 2 | *.swp 3 | *.DS_Store 4 | 5 | # WebStorm 6 | .idea/ 7 | 8 | # Emacs 9 | \#*\# 10 | 11 | # VS Code 12 | .vscode/ 13 | 14 | # Logs 15 | logs 16 | *.log 17 | npm-debug.log* 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Dependency directories 35 | node_modules 36 | package-lock.json 37 | jspm_packages 38 | 39 | # Don't commit generated JS bundles 40 | examples/**/static/dist/bundle.js 41 | examples/**/dist 42 | -------------------------------------------------------------------------------- /.mocharc.yml: -------------------------------------------------------------------------------- 1 | reporter: spec 2 | check-leaks: true 3 | recursive: true 4 | file: test/setup.js 5 | globals: 6 | - MessageChannel # Set/unset to test nextTick() 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.0 2 | 3 | ### Breaking changes 4 | 5 | * Drop Node.js v10 support 6 | 7 | ## v1.0-beta 8 | 9 | ### Breaking changes 10 | 11 | * Add options argument to all public database adapter methods that read 12 | or write from snapshots or ops. 13 | 14 | * DB methods that get snapshots or ops no longer return metadata unless 15 | `{metadata: true}` option is passed. 16 | 17 | * Replace `source` argument with `options` in doc methods. Use `options.source` 18 | instead. 19 | 20 | * Backend streams now write objects intead of strings. 21 | 22 | * MemoryDB.prototype._querySync now returns `{snapshots: ..., extra: ...}` 23 | instead of just an array of snapshots. 24 | 25 | ### Non-breaking changes 26 | 27 | * Add options argument to backend.submit. 28 | 29 | * Add error codes to all errors. 30 | 31 | * Add `'updated'` event on queries which fires on all query result changes. 32 | 33 | * In clients, wrap errors in Error objects to they get passed through event 34 | emitters. 35 | 36 | * Sanitize stack traces when sending errors to client, but log them on the 37 | server. 38 | 39 | ## v0.11.37 40 | 41 | Beginning of changelog. 42 | 43 | If you're upgrading from ShareJS 0.7 or earlier, 44 | take a look at the [ShareJS upgrade guide](docs/upgrading-from-sharejs.md). 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Licensed under the standard MIT license: 2 | 3 | Copyright 2015 Nate Smith, 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ShareDB 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/sharedb.svg)](https://npmjs.org/package/sharedb) 4 | ![Test](https://github.com/share/sharedb/workflows/Test/badge.svg) 5 | [![Coverage Status](https://coveralls.io/repos/github/share/sharedb/badge.svg?branch=master)](https://coveralls.io/github/share/sharedb?branch=master) 6 | 7 | ShareDB is a realtime database backend based on [Operational Transformation 8 | (OT)](https://en.wikipedia.org/wiki/Operational_transformation) of JSON 9 | documents. It is the realtime backend for the [DerbyJS web application 10 | framework](http://derbyjs.com/). 11 | 12 | For help, questions, discussion and announcements, join the [ShareJS mailing 13 | list](https://groups.google.com/forum/?fromgroups#!forum/sharejs) or [read the documentation](https://share.github.io/sharedb/ 14 | ). 15 | 16 | Please report any bugs you find to the [issue 17 | tracker](https://github.com/share/sharedb/issues). 18 | 19 | ## Features 20 | 21 | - Realtime synchronization of any JSON document 22 | - Concurrent multi-user collaboration 23 | - Synchronous editing API with asynchronous eventual consistency 24 | - Realtime query subscriptions 25 | - Simple integration with any database 26 | - Horizontally scalable with pub/sub integration 27 | - Projections to select desired fields from documents and operations 28 | - Middleware for implementing access control and custom extensions 29 | - Ideal for use in browsers or on the server 30 | - Offline change syncing upon reconnection 31 | - In-memory implementations of database and pub/sub for unit testing 32 | - Access to historic document versions 33 | - Realtime user presence syncing 34 | 35 | ## Documentation 36 | 37 | https://share.github.io/sharedb/ 38 | 39 | ## Examples 40 | 41 | ### Counter 42 | 43 | [](examples/counter) 44 | 45 | ### Leaderboard 46 | 47 | [](examples/leaderboard) 48 | 49 | ## Development 50 | 51 | ### Documentation 52 | 53 | The documentation is stored as Markdown files, but sometimes it can be useful to run these locally. The docs are served using [Jekyll](https://jekyllrb.com/), and require Ruby >2.4.0 and [Bundler](https://bundler.io/): 54 | 55 | ```bash 56 | gem install jekyll bundler 57 | ``` 58 | 59 | The docs can be built locally and served with live reload: 60 | 61 | ```bash 62 | npm run docs:install 63 | npm run docs:start 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _site 2 | .sass-cache 3 | .jekyll-metadata 4 | -------------------------------------------------------------------------------- /docs/.ruby-version: -------------------------------------------------------------------------------- 1 | 2.7.3 2 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | 18 | 19 |
20 |

404

21 | 22 |

Page not found :(

23 |

The requested page could not be found.

24 |
25 | -------------------------------------------------------------------------------- /docs/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Hello! This is where you manage which Jekyll version is used to run. 4 | # When you want to use a different version, change it below, save the 5 | # file and run `bundle install`. Run Jekyll with `bundle exec`, like so: 6 | # 7 | # bundle exec jekyll serve 8 | # 9 | # This will help ensure the proper Jekyll version is running. 10 | # Happy Jekylling! 11 | 12 | # If you want to use GitHub Pages, remove the "gem "jekyll"" above and 13 | # uncomment the line below. To upgrade, run `bundle update github-pages`. 14 | gem "github-pages", "~> 228", group: :jekyll_plugins 15 | 16 | # If you have any plugins, put them here! 17 | group :jekyll_plugins do 18 | gem "jekyll-feed", "~> 0.15" 19 | end 20 | 21 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 22 | # and associated library. 23 | install_if -> { RUBY_PLATFORM =~ %r!mingw|mswin|java! } do 24 | gem "tzinfo", "~> 1.2" 25 | gem "tzinfo-data" 26 | end 27 | 28 | # Performance-booster for watching directories on Windows 29 | gem "wdm", "~> 0.1.0", :install_if => Gem.win_platform? 30 | 31 | # kramdown v2 ships without the gfm parser by default. If you're using 32 | # kramdown v1, comment out this line. 33 | gem "kramdown-parser-gfm" 34 | 35 | # Needed for Ruby 3: https://github.com/jekyll/jekyll/issues/8523 36 | gem "webrick", "~> 1.8" 37 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # This config file is meant for settings that affect your whole blog, values 4 | # which you are expected to set up once and rarely edit after that. If you find 5 | # yourself editing this file very often, consider using Jekyll's data files 6 | # feature for the data you need to update frequently. 7 | # 8 | # For technical reasons, this file is *NOT* reloaded automatically when you use 9 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 10 | 11 | # Site settings 12 | # These are used to personalize your new site. If you look in the HTML files, 13 | # you will see them accessed via {{ site.title }}, {{ site.email }}, and so on. 14 | # You can create any custom variable you would like, and they will be accessible 15 | # in the templates via {{ site.myvariable }}. 16 | title: ShareDB 17 | baseurl: "/sharedb" # the subpath of your site, e.g. /blog 18 | url: "" # the base hostname & protocol for your site, e.g. http://example.com 19 | 20 | aux_links: 21 | "ShareDB on GitHub": 22 | - "//github.com/share/sharedb" 23 | 24 | # Footer "Edit this page on GitHub" link text 25 | gh_edit_link: true # show or hide edit this page link 26 | gh_edit_link_text: "Edit this page on GitHub" 27 | gh_edit_repository: "https://github.com/share/sharedb" # the github URL for your repo 28 | gh_edit_branch: "master" # the branch that your docs is served from 29 | gh_edit_source: "docs" # the source that your files originate from 30 | gh_edit_view_mode: "tree" # "tree" or "edit" if you want the user to jump into the editor immediately 31 | 32 | # Build settings 33 | markdown: kramdown 34 | remote_theme: just-the-docs/just-the-docs 35 | permalink: /:path/:name 36 | 37 | # Exclude from processing. 38 | # The following items will not be processed, by default. Create a custom list 39 | # to override the default setting. 40 | # exclude: 41 | # - Gemfile 42 | # - Gemfile.lock 43 | # - node_modules 44 | # - vendor/bundle/ 45 | # - vendor/cache/ 46 | # - vendor/gems/ 47 | # - vendor/ruby/ 48 | 49 | # Global copy 50 | copy: 51 | events: This class extends [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter), and can be used with the normal methods such as [`on()`](https://nodejs.org/api/events.html#events_emitter_on_eventname_listener) and [`off()`](https://nodejs.org/api/events.html#events_emitter_off_eventname_listener). 52 | -------------------------------------------------------------------------------- /docs/_sass/custom/custom.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Add call-out support: https://github.com/pmarsceill/just-the-docs/issues/171#issuecomment-538794741 3 | */ 4 | $callouts: ( 5 | info: ($blue-300, rgba($blue-000, .2), 'Note'), 6 | warn: ($yellow-300, rgba($yellow-000, .2), 'Note'), 7 | danger: ($red-300, rgba($red-000, .2), 'Note') 8 | ); 9 | 10 | @each $class, $props in $callouts { 11 | .#{$class} { 12 | background: nth($props, 2); 13 | border-left: $border-radius solid nth($props, 1); 14 | border-radius: $border-radius; 15 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12), 0 3px 10px rgba(0, 0, 0, 0.08); 16 | padding: .8rem; 17 | 18 | &::before { 19 | color: nth($props, 1); 20 | content: nth($props, 3); 21 | display: block; 22 | font-weight: bold; 23 | font-size: .75em; 24 | padding-bottom: .125rem; 25 | } 26 | 27 | br { 28 | content: ''; 29 | display: block; 30 | margin-top: .5rem; 31 | } 32 | } 33 | } 34 | 35 | .label-grey { 36 | background: rgba($grey-dk-000, 1); 37 | } 38 | -------------------------------------------------------------------------------- /docs/adapters/database.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Database adapters 3 | nav_order: 1 4 | layout: default 5 | parent: Adapters 6 | --- 7 | 8 | # Database adapters 9 | {: .no_toc } 10 | 11 | 1. TOC 12 | {:toc} 13 | 14 | The database adapter is responsible for persisting document contents and ops. 15 | 16 | ## Available adapters 17 | 18 | ### MemoryDB 19 | 20 | ShareDB ships with an in-memory, non-persistent database. This is useful for testing. It has no query support. 21 | 22 | {: .warn } 23 | `MemoryDB` does not persist its data between app restarts, and is **not** suitable for use in a Production environment. 24 | 25 | ### ShareDBMongo 26 | 27 | [`sharedb-mongo`](https://github.com/share/sharedb-mongo) is backed by MongoDB, with full query support. 28 | 29 | ### ShareDBMingoMemory 30 | 31 | [`sharedb-mingo-memory`](https://github.com/share/sharedb-mingo-memory) is an in-memory database that implements a subset of Mongo operations, including queries. This can be useful for testing against a MongoDB-like ShareDB instance. 32 | 33 | ### ShareDBPostgres 34 | 35 | [`sharedb-postgres`](https://github.com/share/sharedb-postgres) is backed by PostgreSQL, and has no query support. 36 | 37 | ## Usage 38 | 39 | An instance of a database adapter should be provided to the [`Backend()` constructor]({{ site.baseurl }}{% link api/backend.md %}#backend-constructor)'s `db` option: 40 | 41 | ```js 42 | const backend = new Backend({ 43 | db: new MemoryDB(), 44 | }) 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/adapters/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Adapters 3 | nav_order: 4 4 | layout: default 5 | has_children: true 6 | --- 7 | 8 | # Adapters 9 | 10 | ShareDB tries to stay database-agnostic. Because of this, any data persisted to the database must be done through an appropriate database adapter. 11 | -------------------------------------------------------------------------------- /docs/adapters/milestone.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Milestone adapters 3 | nav_order: 3 4 | layout: default 5 | parent: Adapters 6 | --- 7 | 8 | # Milestone adapters 9 | {: .no_toc } 10 | 11 | 1. TOC 12 | {:toc} 13 | 14 | The milestone adapter is responsible for storing periodic snapshots of documents, primarily in order to speed up [document history]({{ site.baseurl }}{% link document-history.md %}). 15 | 16 | ## Available adapters 17 | 18 | ### ShareDBMilestoneMongo 19 | 20 | [`sharedb-milestone-mongo`](https://github.com/share/sharedb-milestone-mongo) runs on MongoDB. 21 | 22 | ## Usage 23 | 24 | An instance of a milestone adapter should be provided to the [`Backend()` constructor]({{ site.baseurl }}{% link api/backend.md %}#backend-constructor)'s `milestoneDb` option: 25 | 26 | ```js 27 | const backend = new Backend({ 28 | milestoneDb: new ShareDBMilestoneMongo(), 29 | }) 30 | ``` 31 | 32 | ## Requesting snapshots 33 | 34 | Adapters will define default snapshot behaviour. However, this logic can be overridden using the `saveMilestoneSnapshot` option in [middleware]({{ site.baseurl }}{% link middleware/index.md %}). 35 | 36 | Setting `context.saveMilestoneSnapshot` to `true` will request a snapshot be saved, and setting it to `false` means a snapshot will not be saved. 37 | 38 | {: .info } 39 | If `context.saveMilestoneSnapshot` is left to its default value of `null`, it will assume the default behaviour defined by the adapter. 40 | 41 | ```js 42 | shareDb.use('commit', (context, next) => { 43 | switch (context.collection) { 44 | case 'foo': 45 | // Save every 100 versions for collection 'foo' 46 | context.saveMilestoneSnapshot = context.snapshot.v % 100 === 0; 47 | break; 48 | case 'bar': 49 | case 'baz': 50 | // Save every 500 versions for collections 'bar' and 'baz' 51 | context.saveMilestoneSnapshot = context.snapshot.v % 500 === 0; 52 | break; 53 | default: 54 | // Don't save any milestones for collections not named here. 55 | context.saveMilestoneSnapshot = false; 56 | } 57 | 58 | next(); 59 | }); 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/adapters/pub-sub.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pub/Sub adapters 3 | nav_order: 2 4 | layout: default 5 | parent: Adapters 6 | --- 7 | 8 | # Pub/Sub adapters 9 | {: .no_toc } 10 | 11 | 1. TOC 12 | {:toc} 13 | 14 | The pub/sub adapter is responsible for notifying other ShareDB instances of changes to data. 15 | 16 | ## Available adapters 17 | 18 | ### MemoryPubSub 19 | 20 | ShareDB ships with an in-memory Pub/Sub, which can be used for a single, standalone ShareDB instance. 21 | 22 | {: .info } 23 | Unlike the [database adapter]({{ site.baseurl }}{% link adapters/database.md %}), the in-memory Pub/Sub adapter **is** suitable for use in a Production environment, where only a single, standalone ShareDB instance is being used. 24 | 25 | ### ShareDBRedisPubSub 26 | 27 | [`sharedb-redis-pubsub`](https://github.com/share/sharedb-redis-pubsub) runs on Redis. 28 | 29 | ### ShareDBWSBusPubSub 30 | 31 | [`sharedb-wsbus-pubsub`](https://github.com/dmapper/sharedb-wsbus-pubsub) runs on ws-bus. 32 | 33 | ## Usage 34 | 35 | An instance of a pub/sub adapter should be provided to the [`Backend()` constructor]({{ site.baseurl }}{% link api/backend.md %}#backend-constructor)'s `pubsub` option: 36 | 37 | ```js 38 | const backend = new Backend({ 39 | pubsub: new MemoryPubSub(), 40 | }) 41 | ``` 42 | -------------------------------------------------------------------------------- /docs/api/agent.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Agent 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # Agent 8 | 9 | {: .no_toc } 10 | 11 | 1. TOC 12 | {:toc} 13 | 14 | An `Agent` is the representation of a client's [`Connection`]({{ site.baseurl }}{% link api/connection.md %}) state on the server. 15 | 16 | The `Agent` instance will be made available in all [middleware]({{ site.baseurl }}{% link middleware/index.md %}) contexts, where [`agent.custom`](#custom--object) can be particularly useful for storing custom context. 17 | 18 | {: .info } 19 | If the `Connection` was created through [`backend.connect()`]({{ site.baseurl }}{% link api/backend.md %}#connect()) (i.e. the client is running on the server), then the `Agent` associated with a `Connection` can be accessed through [`connection.agent`]({{ site.baseurl }}{% link api/connection.md %}#agent--agent). 20 | 21 | ## Properties 22 | 23 | ### `custom` -- Object 24 | 25 | > An object that consumers can use to pass information around through the middleware. 26 | 27 | {: .info } 28 | The `agent.custom` is passed onto the `options` field in [database adapter]({{ site.baseurl }}{% link adapters/database.md %}) calls as `options.agentCustom`. This allows further customisation at the database level e.g. [in `sharedb-mongo` middleware](https://github.com/share/sharedb-mongo#middlewares). 29 | 30 | ### `backend` -- [Backend]({{ site.baseurl }}{% link api/backend.md %}) 31 | 32 | > The [`Backend`]({{ site.baseurl }}{% link api/backend.md %}) instance that created this `Agent` 33 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: API 3 | nav_order: 99 4 | layout: default 5 | has_children: true 6 | --- 7 | 8 | # API 9 | -------------------------------------------------------------------------------- /docs/api/local-presence.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: LocalPresence 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # LocalPresence 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | `LocalPresence` represents the [presence]({{ site.baseurl }}{% link presence.md %}) of the local client. For example, this might be the position of a caret in a text document; which field has been highlighted in a complex JSON object; etc. 14 | 15 | Local presence is created from a parent [`Presence`]({{ site.baseurl }}{% link api/presence.md %}) instance using [`presence.create()`]({{ site.baseurl }}{% link api/presence.md %}#create). 16 | 17 | ## Methods 18 | 19 | ### submit() 20 | 21 | Update the local representation of presence, and broadcast that presence to any other presence subscribers. 22 | 23 | ```javascript 24 | localPresence.submit(presence [, callback]) 25 | ``` 26 | 27 | `presence` -- Object 28 | 29 | > The presence object to broadcast. The structure of `presence` will depend on the [type]({{ site.baseurl }}{% link types/index.md %}) 30 | 31 | {: .info } 32 | > A value of `null` will be interpreted as the client no longer being present 33 | 34 | {: .d-inline-block } 35 | 36 | `callback` -- Function 37 | 38 | Optional 39 | {: .label .label-grey } 40 | 41 | > ```js 42 | > function(error) { ... } 43 | > ``` 44 | 45 | > A callback that will be called once the presence has been sent 46 | 47 | ### send() 48 | 49 | Send the current value of presence to other subscribers, without updating it. This is like [`submit()`](#submit) but without changing the value. 50 | 51 | This can be useful if local presence is set to periodically expire (e.g. after a period of inactivity). 52 | 53 | ```javascript 54 | localPresence.send([callback]) 55 | ``` 56 | 57 | {: .d-inline-block } 58 | 59 | `callback` -- Function 60 | 61 | Optional 62 | {: .label .label-grey } 63 | 64 | > ```js 65 | > function(error) { ... } 66 | > ``` 67 | 68 | > A callback that will be called once the presence has been sent 69 | 70 | ### destroy() 71 | 72 | Inform all remote clients that this presence is now `null`, and deletes itself for garbage collection. 73 | 74 | ```javascript 75 | localPresence.destroy([callback]) 76 | ``` 77 | 78 | {: .d-inline-block } 79 | 80 | `callback` -- Function 81 | 82 | Optional 83 | {: .label .label-grey } 84 | 85 | > ```js 86 | > function(error) { ... } 87 | > ``` 88 | 89 | > A callback that will be called once the presence has been destroyed 90 | -------------------------------------------------------------------------------- /docs/api/presence.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Presence 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # Presence 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | Representation of the [presence]({{ site.baseurl }}{% link presence.md %}) data associated with a given channel. 14 | 15 | A `Presence` instance can be obtained with [`connection.getPresence()`]({{ site.baseurl }}{% link api/connection.md %}#getpresence) or [`connection.getDocPresence()`]({{ site.baseurl }}{% link api/connection.md %}#getdocpresence). 16 | 17 | If created with [`connection.getDocPresence()`]({{ site.baseurl }}{% link api/connection.md %}#getdocpresence), this will represent the presence data associated with a given [`Doc`]({{ site.baseurl }}{% link api/doc.md %}). 18 | 19 | ## Properties 20 | 21 | ### `remotePresences` -- Object 22 | 23 | > Map of remote presence IDs to their values 24 | 25 | ### `localPresences` -- Object 26 | 27 | > Map of local presence IDs to their [`LocalPresence`]({{ site.baseurl }}{% link api/local-presence.md %}) instances 28 | 29 | ## Methods 30 | 31 | ### subscribe() 32 | 33 | Subscribe to presence updates from other clients. 34 | 35 | ```javascript 36 | presence.subscribe([callback]) 37 | ``` 38 | 39 | {: .warn } 40 | Presence can be submitted without subscribing, but remote clients will not be able to re-request presence from an unsubscribed client. 41 | 42 | {: .d-inline-block } 43 | 44 | `callback` -- Function 45 | 46 | Optional 47 | {: .label .label-grey } 48 | 49 | > ```js 50 | > function(error) { ... } 51 | > ``` 52 | 53 | > A callback that will be called once the presence has been subscribed 54 | 55 | ### unsubscribe() 56 | 57 | Unsubscribe from presence updates from remote clients. 58 | 59 | ```javascript 60 | presence.unsubscribe([callback]) 61 | ``` 62 | 63 | {: .d-inline-block } 64 | 65 | `callback` -- Function 66 | 67 | Optional 68 | {: .label .label-grey } 69 | 70 | > ```js 71 | > function(error) { ... } 72 | > ``` 73 | 74 | > A callback that will be called once the presence has been unsubscribed 75 | 76 | ### create() 77 | 78 | Create an instance of [`LocalPresence`]({{ site.baseurl }}{% link api/local-presence.md %}), which can be used to represent the client's presence. Many -- or none -- such local presences may exist on a `Presence` instance. 79 | 80 | ```javascript 81 | presence.create([presenceId]) 82 | ``` 83 | 84 | {: .d-inline-block } 85 | 86 | `presenceId` -- string 87 | 88 | Optional 89 | {: .label .label-grey } 90 | 91 | > A unique ID representing the local presence. If omitted, a random ID will be assigned 92 | 93 | {: .warn } 94 | > Depending on use-case, the same client may have **multiple** presences, so a user or client ID may not be appropriate to use as a presence ID. 95 | 96 | Return value 97 | 98 | > A new [`LocalPresence`]({{ site.baseurl }}{% link api/local-presence.md %}) instance 99 | 100 | ### destroy() 101 | 102 | Clear all [`LocalPresence`]({{ site.baseurl }}{% link api/local-presence.md %}) instances associated with this `Presence`, setting them all to have a value of `null`, and sending the update to remote subscribers. 103 | 104 | Also deletes this `Presence` instance for garbage-collection. 105 | 106 | ```javascript 107 | presence.destroy([callback]) 108 | ``` 109 | 110 | {: .d-inline-block } 111 | 112 | `callback` -- Function 113 | 114 | Optional 115 | {: .label .label-grey } 116 | 117 | > ```js 118 | > function(error) { ... } 119 | > ``` 120 | 121 | > A callback that will be called once the presence has been destroyed 122 | 123 | ## Events 124 | 125 | {{ site.copy.events }} 126 | 127 | ### `'receive'` 128 | 129 | An update from a remote presence client has been received. 130 | 131 | ```javascript 132 | presence.on('receive', function(id, value) { ... }); 133 | ``` 134 | 135 | `id` -- string 136 | 137 | > The ID of the remote presence 138 | 139 | {: .warn } 140 | > The same client may have multiple presence IDs 141 | 142 | `value` -- Object 143 | 144 | > The presence value. The structure of this object will depend on the [type]({{ site.baseurl }}{% link types/index.md %}) 145 | 146 | {: .info } 147 | > A `null` value means the remote client is no longer present in the document (e.g. they disconnected) 148 | 149 | ### `'error'` 150 | 151 | An error has occurred. 152 | 153 | ```javascript 154 | presence.on('error', function(error) { ... }) 155 | ``` 156 | 157 | `error` -- [ShareDBError]({{ site.baseurl }}{% link api/sharedb-error.md %}) 158 | 159 | > The error that occurred 160 | -------------------------------------------------------------------------------- /docs/api/query.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Query 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # Query 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | Representation of a query made through [`connection.createFetchQuery()`]({{ site.baseurl }}{% link api/connection.md %}#createfetchquery) or [`connection.createSubscribeQuery()`]({{ site.baseurl }}{% link api/connection.md %}#createsubscribequery). 14 | 15 | ## Properties 16 | 17 | ### `ready` -- boolean 18 | 19 | > Represents if results are ready and available in [`results`](#results) 20 | 21 | ### `results` -- Array 22 | 23 | > Query results, as an array of [`Doc`]({{ site.baseurl }}{% link api/doc.md %}) instances 24 | 25 | ### `extra` -- Object 26 | 27 | > Extra query results that are not an array of `Doc`s. Available for certain [database adapters]({{ site.baseurl }}{% link adapters/database.md %}) and queries 28 | 29 | ## Methods 30 | 31 | ### destroy() 32 | 33 | Unsubscribe and stop firing events. 34 | 35 | ```js 36 | query.destroy([callback]) 37 | ``` 38 | 39 | {: .d-inline-block } 40 | 41 | `callback` -- Function 42 | 43 | Optional 44 | {: .label .label-grey } 45 | 46 | > ```js 47 | > function(error) { ... } 48 | > ``` 49 | 50 | > A callback that will be called when the query has been destroyed 51 | 52 | ## Events 53 | 54 | {{ site.copy.events }} 55 | 56 | ### `'ready'` 57 | 58 | The initial query results were loaded from the server. Triggered on [`connection.createFetchQuery()`]({{ site.baseurl }}{% link api/connection.md %}#createfetchquery) or [`connection.createSubscribeQuery()`]({{ site.baseurl }}{% link api/connection.md %}#createsubscribequery). 59 | 60 | ```js 61 | query.on('ready', function() { ... }) 62 | ``` 63 | 64 | ### `'changed'` 65 | 66 | The subscribed query results have changed. Fires only after a sequence of diffs are handled. 67 | 68 | ```js 69 | query.on('changed', function(results) { ... }) 70 | ``` 71 | 72 | `results` -- Array 73 | 74 | > Query results, as an array of [`Doc`]({{ site.baseurl }}{% link api/doc.md %}) instances 75 | 76 | ### `'insert'` 77 | 78 | A contiguous sequence of documents were added to the query results array. 79 | 80 | ```js 81 | query.on('insert', function(docs, index) { ... }) 82 | ``` 83 | 84 | `docs` -- Array 85 | 86 | > Array of inserted [`Doc`]({{ site.baseurl }}{% link api/doc.md %})s 87 | 88 | `index` -- number 89 | 90 | > The index at which the documents were inserted 91 | 92 | ### `'move'` 93 | 94 | A contiguous sequence of documents moved position in the query results array. 95 | 96 | ```js 97 | query.on('move', function(docs, from, to) { ... }) 98 | ``` 99 | 100 | `docs` -- Array 101 | 102 | > Array of moved [`Doc`]({{ site.baseurl }}{% link api/doc.md %})s 103 | 104 | `from` -- number 105 | 106 | > The index the documents were moved from 107 | 108 | `to` -- number 109 | 110 | > The index the documents were moved to 111 | 112 | ### `'remove'` 113 | 114 | A contiguous sequence of documents were removed from the query results array. 115 | 116 | ```js 117 | query.on('remove', function(docs, index) { ... }) 118 | ``` 119 | 120 | `docs` -- Array 121 | 122 | > Array of removed [`Doc`]({{ site.baseurl }}{% link api/doc.md %})s 123 | 124 | `index` -- number 125 | 126 | > The index at which the documents were removed 127 | 128 | ### `'extra'` 129 | 130 | The [`extra`](#extra--object) property was changed. 131 | 132 | ```js 133 | query.on('extra', function(extra) { ... }) 134 | ``` 135 | 136 | `extra` -- Object 137 | 138 | > The updated [`extra`](#extra--object) value 139 | 140 | ### `'error'` 141 | 142 | There was an error receiving updates to a subscription. 143 | 144 | ```js 145 | query.on('error', function(error) { ... }) 146 | ``` 147 | 148 | `error` -- [ShareDBError]({{ site.baseurl }}{% link api/sharedb-error.md %}) 149 | 150 | > The error that occurred -------------------------------------------------------------------------------- /docs/api/sharedb-error.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: ShareDBError 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # ShareDBError 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | Representation of an error, with a machine-parsable [code](#error-codes). 14 | 15 | ## Properties 16 | 17 | ### `code` -- string 18 | 19 | > A machine-parsable [code](#error-codes) representing the type of error 20 | 21 | ### `message` -- string 22 | 23 | > A human-readable message providing more detail about the error 24 | 25 | {: .warn } 26 | > Consumer code should never rely on the value of `message`, which may be fragile. 27 | 28 | ## Error codes 29 | 30 | ### `ERR_OP_SUBMIT_REJECTED` 31 | 32 | > The op submitted by the client has been rejected by the server for a non-critical reason. 33 | 34 | > When the client receives this code, it will attempt to roll back the rejected op, leaving the client in a usable state. 35 | 36 | > This error might be used as part of standard control flow. For example, consumers may define a middleware that validates document structure, and rejects operations that do not conform to this schema using this error code to reset the client to a valid state. 37 | 38 | ### `ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED` 39 | 40 | > This may happen if server rejected op with ERR_OP_SUBMIT_REJECTED and the type is not invertible or there are some pending ops after the create op was rejected with ERR_OP_SUBMIT_REJECTED 41 | 42 | ### `ERR_OP_ALREADY_SUBMITTED` 43 | 44 | > The same op has been received by the server twice. 45 | 46 | > This is non-critical, and part of normal control flow, and is sent as an error in order to short-circuit the op processing. It is eventually swallowed by the server, and shouldn't need further handling. 47 | 48 | ### `ERR_SUBMIT_TRANSFORM_OPS_NOT_FOUND` 49 | 50 | > The ops needed to transform the submitted op up to the current version of the snapshot could not be found. 51 | 52 | > If a client on an old version of a document submits an op, that op needs to be transformed by all the ops that have been applied to the document in the meantime. If the server cannot fetch these ops from the database, then this error is returned. 53 | 54 | > The most common case of this would be ops being deleted from the database. For example, let's assume we have a TTL set up on the ops in our database. Let's also say we have a client that is so out-of-date that the op corresponding to its version has been deleted by the TTL policy. If this client then attempts to submit an op, the server will not be able to find the ops required to transform the op to apply to the current version of the snapshot. 55 | 56 | > Other causes of this error may be dropping the ops collection all together, or having the database corrupted in some other way. 57 | 58 | ### `ERR_MAX_SUBMIT_RETRIES_EXCEEDED` 59 | 60 | > The number of retries defined by the [`maxSubmitRetries`]({{ site.baseurl }}{% link api/backend.md %}#options) option has been exceeded by a submission. 61 | 62 | ### `ERR_DOC_ALREADY_CREATED` 63 | 64 | > The creation request has failed, because the document was already created by another client. 65 | 66 | > This can happen when two clients happen to simultaneously try to create the same document, and is potentially recoverable by simply fetching the already-created document. 67 | 68 | ### `ERR_DOC_WAS_DELETED` 69 | 70 | > The deletion request has failed, because the document was already deleted by another client. 71 | 72 | > This can happen when two clients happen to simultaneously try to delete the same document. Given that the end result is the same, this error can potentially just be ignored. 73 | 74 | ### `ERR_DOC_TYPE_NOT_RECOGNIZED` 75 | 76 | > The specified document [type]({{ site.baseurl }}{% link types/index.md %}) has not been registered with ShareDB. 77 | 78 | > This error can usually be remedied by remembering to [register]({{ site.baseurl }}{% link types/index.md %}#installing-other-types) any types you need. 79 | 80 | ### `ERR_DEFAULT_TYPE_MISMATCH` 81 | 82 | > The default type being used by the client does not match the default type expected by the server. 83 | 84 | > This will typically only happen when using a different default type to the built-in `json0` used by ShareDB by default (e.g. if using a fork). The exact same type must be used by both the client and the server, and should be registered as the default type: 85 | 86 | > ```javascript 87 | > var ShareDB = require('sharedb'); 88 | > var forkedJson0 = require('forked-json0'); 89 | > 90 | > // Make sure to also do this on your client 91 | > ShareDB.types.defaultType = forkedJson0.type; 92 | > ``` 93 | 94 | ### `ERR_OP_NOT_ALLOWED_IN_PROJECTION` 95 | 96 | > The submitted op is not valid when applied to the projection. 97 | 98 | > This may happen if the op targets some property that is not included in the projection. 99 | 100 | ### `ERR_TYPE_CANNOT_BE_PROJECTED` 101 | 102 | > The document's type cannot be projected. [`json0`]({{ site.baseurl }}{% link types/json0.md %}) is currently the only type that supports projections. 103 | 104 | ### `ERR_NO_OP` 105 | 106 | > The submitted op resulted in a no-op, possibly after transformation by a remote op. 107 | 108 | > This is normal behavior and the client should swallow this error without bumping doc version. -------------------------------------------------------------------------------- /docs/api/snapshot.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Snapshot 3 | layout: default 4 | parent: API 5 | --- 6 | 7 | # Snapshot 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | Represents a **read-only** ShareDB document at a particular version number. 14 | 15 | {: .info } 16 | Snapshots can **not** be used to manipulate the current version of the document stored in the database. That should be achieved by using a [`Doc`]({{ site.baseurl }}{% link api/doc.md %}). 17 | 18 | ## Properties 19 | 20 | ### `type` -- string 21 | 22 | > The URI of the document [type]({{ site.baseurl }}{% link types/index.md %}) 23 | 24 | {: .info } 25 | > Document types can change between versions if the document is deleted, and created again. 26 | 27 | ### `data` -- Object 28 | 29 | > The snapshot data 30 | 31 | ### `v` -- number 32 | 33 | > The snapshot version 34 | -------------------------------------------------------------------------------- /docs/document-history.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Document history 3 | nav_order: 9 4 | layout: default 5 | --- 6 | 7 | # Document history 8 | 9 | Since -- by default -- ShareDB stores all of the submitted operations, these operations can be used to reconstruct a document at any point in its history. 10 | 11 | ShareDB exposes two methods for this: 12 | 13 | - [`connection.fetchSnapshot()`]({{ site.baseurl }}{% link api/connection.md %}#fetchsnapshot) -- fetches a snapshot by version number 14 | - [`connection.fetchSnapshotByTimestamp()`]({{ site.baseurl }}{% link api/connection.md %}#fetchsnapshotbytimestamp) -- fetches a snapshot by UNIX timestamp 15 | 16 | {: .info } 17 | ShareDB doesn't support "branching" a document. Any historical snapshots fetched will be read-only. 18 | 19 | ## Milestone snapshots 20 | 21 | Since OT types are only optionally reversible, ShareDB rebuilds its historic snapshots by replaying ops all the way from creation to the requested version. 22 | 23 | Once documents reach a high version, rebuilding a document like this can get slow. In order to facilitate this, ShareDB supports Milestone Snapshots -- snapshots that are periodically saved, so that ShareDB can jump to the nearest snapshot, and rebuild from there. 24 | 25 | In order to benefit from this performance improvement, a [milestone adapter]({{ site.baseurl }}{% link adapters/milestone.md %}) should be configured. 26 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Getting started 3 | nav_order: 2 4 | layout: default 5 | --- 6 | 7 | # Getting started 8 | {: .no_toc } 9 | 10 | 1. TOC 11 | {:toc} 12 | 13 | ## Installation 14 | 15 | ShareDB is distributed through [npm](https://www.npmjs.com/package/sharedb): 16 | 17 | ```bash 18 | npm install --save sharedb 19 | ``` 20 | 21 | If your server and client have separate dependencies, ShareDB should be added as a dependency to **both** packages. 22 | 23 | 24 | You may also wish to install other [OT types]({{ site.baseurl }}{% link types/index.md %}). 25 | 26 | ## Examples 27 | 28 | There are [working examples](https://github.com/share/sharedb/tree/master/examples) in the git repository. 29 | 30 | ## Usage 31 | 32 | ### Server 33 | 34 | The following is an example using [Express](https://expressjs.com/) and [ws](https://github.com/websockets/ws). 35 | 36 | The ShareDB backend expects an instance of a [`Stream`](https://nodejs.org/api/stream.html), so this example also uses [`@teamwork/websocket-json-stream`](https://www.npmjs.com/package/@teamwork/websocket-json-stream) to turn a `WebSocket` into a `Stream`. 37 | 38 | ```js 39 | var express = require('express') 40 | var WebSocket = require('ws') 41 | var http = require('http') 42 | var ShareDB = require('sharedb') 43 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream') 44 | 45 | var app = express() 46 | var server = http.createServer(app) 47 | var webSocketServer = new WebSocket.Server({server: server}) 48 | 49 | var backend = new ShareDB() 50 | webSocketServer.on('connection', (webSocket) => { 51 | var stream = new WebSocketJSONStream(webSocket) 52 | backend.listen(stream) 53 | }) 54 | 55 | server.listen(8080) 56 | ``` 57 | 58 | This server will accept any WebSocket connection on port 8080, and bind it to ShareDB. 59 | 60 | 61 | 62 | 63 | 64 | ### Client 65 | 66 | This client example uses [`reconnecting-websocket`](https://www.npmjs.com/package/reconnecting-websocket) to reconnect clients after a socket is closed. 67 | 68 | Try running the [working example](https://github.com/share/sharedb/tree/master/examples/counter) to see this in action. 69 | 70 | ```js 71 | var ReconnectingWebSocket = require('reconnecting-websocket') 72 | var Connection = require('sharedb/lib/client').Connection 73 | 74 | var socket = new ReconnectingWebSocket('ws://localhost:8080', [], { 75 | // ShareDB handles dropped messages, and buffering them while the socket 76 | // is closed has undefined behavior 77 | maxEnqueuedMessages: 0 78 | }) 79 | var connection = new Connection(socket) 80 | 81 | var doc = connection.get('doc-collection', 'doc-id') 82 | 83 | doc.subscribe((error) => { 84 | if (error) return console.error(error) 85 | 86 | // If doc.type is undefined, the document has not been created, so let's create it 87 | if (!doc.type) { 88 | doc.create({counter: 0}, (error) => { 89 | if (error) console.error(error) 90 | }) 91 | } 92 | }); 93 | 94 | doc.on('op', (op) => { 95 | console.log('count', doc.data.counter) 96 | }) 97 | 98 | window.increment = () => { 99 | // Increment the counter by 1 100 | doc.submitOp([{p: ['counter'], na: 1}]) 101 | } 102 | ``` 103 | 104 | 105 | 106 | 107 | {: .info } 108 | This example uses the `json0` type (ShareDB's default type). 109 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Introduction 3 | nav_order: 1 4 | layout: default 5 | --- 6 | 7 | # Introduction 8 | 9 | ShareDB is a full-stack library for realtime JSON document collaboration. It provides a Node.js server for coordinating and committing edits from multiple clients. It also provides a JavaScript client for manipulating documents, which can be run either in Node.js or in the browser. 10 | 11 | 12 | The underlying conflict management is handled through [Operational Transformation (OT)](https://en.wikipedia.org/wiki/Operational_transformation). The implementation of this strategy is delegated to ShareDB's type plugins. 13 | 14 | ## Features 15 | 16 | - Realtime synchronization of any JSON document 17 | - Concurrent multi-user collaboration 18 | - Synchronous editing API with asynchronous eventual consistency 19 | - Realtime query subscriptions 20 | 21 | - Simple integration with any database 22 | 23 | - Horizontally scalable with pub/sub integration 24 | - Projections to select desired fields from documents and operations 25 | - Middleware for implementing access control and custom extensions 26 | - Ideal for use in browsers or on the server 27 | - Offline change syncing upon reconnection 28 | - In-memory implementations of database and pub/sub for unit testing 29 | 30 | - Access to historic document versions 31 | 32 | - Realtime user presence syncing 33 | -------------------------------------------------------------------------------- /docs/middleware/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Middleware 3 | nav_order: 5 4 | layout: default 5 | has_children: true 6 | 7 | --- 8 | 9 | # Middleware 10 | 11 | Middleware enables consumers to hook into the ShareDB server pipeline. Objects can be asynchronously manipulated as they flow through ShareDB. 12 | 13 | 14 | 15 | This can be particularly useful for authentication, or adding metadata. 16 | -------------------------------------------------------------------------------- /docs/middleware/registration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Registration 3 | nav_order: 1 4 | layout: default 5 | parent: Middleware 6 | --- 7 | 8 | # Registering middleware 9 | 10 | Middleware is registered on the server with [`backend.use()`]({{ site.baseurl }}{% link api/backend.md %}#use): 11 | 12 | ```js 13 | backend.use(action, function(context, next) { 14 | // Do something with the context 15 | 16 | // Call next when ready. Can optionally pass an error to stop 17 | // the current action, and return the error to the client 18 | next(error) 19 | }) 20 | ``` 21 | 22 | Valid `action`s and their corresponding `context` shape can be found [here]({{ site.baseurl }}{% link middleware/actions.md %}). 23 | -------------------------------------------------------------------------------- /docs/presence.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Presence 3 | nav_order: 10 4 | layout: default 5 | --- 6 | 7 | # Presence 8 | 9 | ShareDB supports sharing "presence": transient information about a client's whereabouts in a given document. For example, this might be their position in a text document; their mouse pointer coordinates on the screen; or a selected field in a form. 10 | 11 | {: .info } 12 | Presence needs to be enabled in the [`Backend`]({{ site.baseurl }}{% link api/backend.md %}). 13 | 14 | ## Usage 15 | 16 | ### Untyped presence 17 | 18 | Presence can be used independently of a document (for example, sharing a mouse pointer position). 19 | 20 | In this case, clients just need to subscribe to a common channel using [`connection.getPresence()`]({{ site.baseurl }}{% link api/connection.md %}#getpresence) to get a [`Presence`]({{ site.baseurl }}{% link api/presence.md %}) instance: 21 | 22 | ```js 23 | const presence = connection.getPresence('my-channel') 24 | presence.subscribe() 25 | 26 | presence.on('receive', (presenceId, update) => { 27 | if (update === null) { 28 | // The remote client is no longer present in the document 29 | } else { 30 | // Handle the new value by updating UI, etc. 31 | } 32 | }) 33 | ``` 34 | 35 | In order to send presence information to other clients, a [`LocalPresence`]({{ site.baseurl }}{% link api/local-presence.md %}) should be created. The presence object can take any arbitrary value 36 | 37 | ```js 38 | const localPresence = presence.create() 39 | // The presence value can take any shape 40 | localPresence.submit({foo: 'bar'}) 41 | ``` 42 | 43 | {: .info } 44 | Multiple local presences can be created from a single `presence` instance, which can be used to represent columnar text cursors, multi-touch input, etc. 45 | 46 | ### Typed presence 47 | 48 | Presence can be coupled to a particular document by getting a [`DocPresence`]({{ site.baseurl }}{% link api/presence.md %}) instance with [`connection.getDocPresence()`]({{ site.baseurl }}{% link api/doc.md %}#getdocpresence). 49 | 50 | The special thing about a `DocPresence` (as opposed to a `Presence`) instance is that `DocPresence` will automatically handle synchronisation issues. Since presence and ops are submitted independently of one another, they can arrive out-of-sync, which might make a text cursor jitter, for example. `DocPresence` will handle these cases, and make sure the correct presence is always applied to the correct version of a document. 51 | 52 | Support depends on the [type]({{ site.baseurl }}{% link types/index.md %}) being used. 53 | 54 | {: .info } 55 | Currently, only `rich-text` supports presence information 56 | 57 | Clients subscribe to a particular [`Doc`]({{ site.baseurl }}{% link api/doc.md %}) instead of a channel: 58 | 59 | ```js 60 | const presence = connection.getDocPresence(collection, id) 61 | presence.subscribe() 62 | 63 | presence.on('receive', (presenceId, update) => { 64 | if (update === null) { 65 | // The remote client is no longer present in the document 66 | } else { 67 | // Handle the new value by updating UI, etc. 68 | } 69 | }) 70 | ``` 71 | 72 | The shape of the presence value will be defined by the [type]({{ site.baseurl }}{% link types/index.md %}): 73 | 74 | ```js 75 | const localPresence = presence.create() 76 | // The presence value depends on the type 77 | localPresence.submit(value) 78 | ``` 79 | -------------------------------------------------------------------------------- /docs/projections.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Projections 3 | nav_order: 7 4 | layout: default 5 | --- 6 | 7 | # Projections 8 | 9 | Some [types]({{ site.baseurl }}{% link types/index.md %}) support exposing a projection of a real collection, with a specified set of allowed fields. 10 | 11 | {: .info } 12 | Currently, only [`json0`]({{ site.baseurl }}{% link types/json0.md %}) supports projections. 13 | 14 | Once configured, the projected collection looks just like a real collection -- except documents only have the fields that have been specified. 15 | 16 | Operations on the projected collection work, but only a small portion of the data can be seen and altered. 17 | 18 | ## Usage 19 | 20 | Projections are configured using [`backend.addProjection()`]({{ site.baseurl }}{% link api/backend.md %}#addprojection). For example, imagine we have a collection `users` with lots of information that should not be leaked. To add a projection `names`, which only has access to the `firstName` and `lastName` properties on a user: 21 | 22 | ```js 23 | backend.addProjection('names', 'users', {firstName: true, lastName: true}) 24 | ``` 25 | 26 | Once the projection has been defined, it can be interacted with like a "normal" collection: 27 | 28 | ```js 29 | const doc = connection.get('names', '123') 30 | doc.fetch(() => { 31 | // Only doc.data.firstName and doc.data.lastName will be present 32 | }); 33 | ``` 34 | -------------------------------------------------------------------------------- /docs/pub-sub.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pub/Sub 3 | nav_order: 6 4 | layout: default 5 | --- 6 | 7 | # Pub/Sub 8 | 9 | Pub/Sub is used to communicate between multiple ShareDB instances. To communicate with more than one ShareDB instance, a [pub/sub adapter]({{ site.baseurl }}{% link adapters/pub-sub.md %}) should be configured. 10 | -------------------------------------------------------------------------------- /docs/types/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: OT Types 3 | nav_order: 3 4 | layout: default 5 | has_children: true 6 | --- 7 | 8 | # OT Types 9 | {: .no_toc } 10 | 11 | 1. TOC 12 | {:toc} 13 | 14 | ShareDB provides a realtime collaborative platform based on [Operational Transformation (OT)](https://en.wikipedia.org/wiki/Operational_transformation). However, ShareDB itself is only part of the solution. ShareDB provides a lot of the machinery for handling ops, but does not provide the actual implementation for transforming ops. 15 | 16 | Transforming and handling ops is delegated to an underlying OT type. 17 | 18 | ShareDB ships with a single, default type -- [`json0`]({{ site.baseurl }}{% link types/json0.md %}). 19 | 20 | ## Registering types 21 | 22 | In order to use other OT types with ShareDB, they must first be registered. 23 | 24 | {: .warn } 25 | Types must be registered on **both** the server **and** the client. 26 | 27 | ### Server 28 | 29 | ```js 30 | const Backend = require('sharedb') 31 | const richText = require('rich-text') 32 | 33 | Backend.types.register(richText.type) 34 | ``` 35 | 36 | ### Client 37 | 38 | ```js 39 | const Client = require('sharedb/lib/client') 40 | const richText = require('rich-text') 41 | 42 | Client.types.register(richText.type) 43 | ``` 44 | 45 | ## Using types 46 | 47 | A [registered](#registering-types) type can be used by specifying its name or URI when creating a [`Doc`]({{ site.baseurl }}{% link api/doc.md %}): 48 | 49 | ```js 50 | doc.create([{insert: 'Lorem'}], 'http://sharejs.org/types/rich-text/v1') 51 | // The Doc will now use the type that it was created with when submitting more ops 52 | doc.submitOp([{retain: 5}, {insert: ' ipsum'}]) 53 | ``` 54 | 55 | {: .warn } 56 | The short-hand name can also be used (e.g. `'rich-text'`), but these don't have to be unique, so types may clash if multiple types with the same name have been registered. Best practice is to use the URI. 57 | -------------------------------------------------------------------------------- /docs/types/json0.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: JSON0 3 | layout: default 4 | nav_order: 2 5 | parent: OT Types 6 | --- 7 | 8 | # JSON0 9 | 10 | [`json0`](https://github.com/ottypes/json0) is ShareDB's default type. This means that if no other type is specified in [`doc.create()`]({{ site.baseurl }}{% link api/doc.md %}), `json0` will be used by default. 11 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ShareDB Counter (ottype json1 with Vite) 6 | 7 | 8 |
9 | You clicked times. 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/main.js: -------------------------------------------------------------------------------- 1 | import ReconnectingWebSocket from 'reconnecting-websocket'; 2 | import {json1} from 'sharedb-client-browser/dist/ot-json1-umd.cjs'; 3 | import sharedb from 'sharedb-client-browser/dist/sharedb-client-umd.cjs'; 4 | 5 | // Open WebSocket connection to ShareDB server 6 | var socket = new ReconnectingWebSocket('ws://' + window.location.host + '/ws', [], { 7 | // ShareDB handles dropped messages, and buffering them while the socket 8 | // is closed has undefined behavior 9 | maxEnqueuedMessages: 0 10 | }); 11 | sharedb.types.register(json1.type); 12 | var connection = new sharedb.Connection(socket); 13 | 14 | // Create local Doc instance mapped to 'examples' collection document with id 'counter' 15 | var doc = connection.get('examples', 'counter'); 16 | 17 | // Get initial value of document and subscribe to changes 18 | doc.subscribe(showNumbers); 19 | // When document changes (by this client or any other, or the server), 20 | // update the number on the page 21 | doc.on('op', showNumbers); 22 | 23 | function showNumbers() { 24 | document.querySelector('#num-clicks').textContent = doc.data.numClicks; 25 | }; 26 | 27 | // When clicking on the '+1' button, change the number in the local 28 | // document and sync the change to the server and other connected 29 | // clients 30 | function increment() { 31 | // Increment `doc.data.numClicks`. See 32 | // https://github.com/ottypes/json1/blob/master/spec.md for list of valid operations. 33 | doc.submitOp(['numClicks', {ena: 1}]); 34 | } 35 | 36 | var button = document.querySelector('button.increment'); 37 | button.addEventListener('click', increment); 38 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "counter-json1-vite", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "start": "node server.js" 11 | }, 12 | "dependencies": { 13 | "@teamwork/websocket-json-stream": "^2.0.0", 14 | "express": "^4.18.2", 15 | "ot-json1": "^1.0.2", 16 | "reconnecting-websocket": "^4.4.0", 17 | "sharedb": "^3.3.1", 18 | "sharedb-client-browser": "^4.2.1", 19 | "ws": "^8.13.0" 20 | }, 21 | "devDependencies": { 22 | "vite": "^4.2.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/server.js: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import express from 'express'; 3 | import ShareDB from 'sharedb'; 4 | import {WebSocketServer} from 'ws'; 5 | import WebSocketJSONStream from '@teamwork/websocket-json-stream'; 6 | import json1 from 'ot-json1'; 7 | 8 | ShareDB.types.register(json1.type); 9 | var backend = new ShareDB(); 10 | createDoc(startServer); 11 | 12 | // Create initial document then fire callback 13 | function createDoc(callback) { 14 | var connection = backend.connect(); 15 | var doc = connection.get('examples', 'counter'); 16 | doc.fetch(function(err) { 17 | if (err) throw err; 18 | if (doc.type === null) { 19 | doc.create({numClicks: 0}, json1.type.uri, callback); 20 | return; 21 | } 22 | callback(); 23 | }); 24 | } 25 | 26 | function startServer() { 27 | // Create a web server to serve files and listen to WebSocket connections 28 | var app = express(); 29 | app.use(express.static('dist')); 30 | var server = http.createServer(app); 31 | 32 | // Connect any incoming WebSocket connection to ShareDB 33 | var wss = new WebSocketServer({server: server, path: '/ws'}); 34 | wss.on('connection', function(ws) { 35 | var stream = new WebSocketJSONStream(ws); 36 | backend.listen(stream); 37 | }); 38 | 39 | server.listen(8080); 40 | console.log('Listening on http://localhost:8080'); 41 | } 42 | -------------------------------------------------------------------------------- /examples/counter-json1-vite/vite.config.js: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vite'; 2 | 3 | export default defineConfig({ 4 | server: { 5 | proxy: { 6 | // Proxy websockets to ws://localhost:8080 for `npm run dev` 7 | '/ws': { 8 | target: 'ws://localhost:8080', 9 | ws: true 10 | } 11 | } 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /examples/counter-json1/.gitignore: -------------------------------------------------------------------------------- 1 | static/dist/ 2 | -------------------------------------------------------------------------------- /examples/counter-json1/README.md: -------------------------------------------------------------------------------- 1 | # Simple realtime client/server sync with ShareDB (ottype json1) 2 | 3 | ![Demo](demo.gif) 4 | 5 | This is a simple websocket server that exposes the ShareDB protocol, 6 | with a client showing an incrementing number that is sychronized 7 | across all open browser tabs. 8 | 9 | In this demo, data is not persisted. To persist data, run a Mongo 10 | server and initialize ShareDB with the 11 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 12 | 13 | ## Install dependencies 14 | ``` 15 | npm install 16 | ``` 17 | 18 | ## Build JavaScript bundle and run server 19 | ``` 20 | npm run build && npm start 21 | ``` 22 | 23 | ## Run app in browser 24 | Load [http://localhost:8080](http://localhost:8080) 25 | -------------------------------------------------------------------------------- /examples/counter-json1/client.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnecting-websocket'); 2 | var sharedb = require('sharedb/lib/client'); 3 | var json1 = require('ot-json1'); 4 | 5 | // Open WebSocket connection to ShareDB server 6 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 7 | // ShareDB handles dropped messages, and buffering them while the socket 8 | // is closed has undefined behavior 9 | maxEnqueuedMessages: 0 10 | }); 11 | sharedb.types.register(json1.type); 12 | var connection = new sharedb.Connection(socket); 13 | 14 | // Create local Doc instance mapped to 'examples' collection document with id 'counter' 15 | var doc = connection.get('examples', 'counter'); 16 | 17 | // Get initial value of document and subscribe to changes 18 | doc.subscribe(showNumbers); 19 | // When document changes (by this client or any other, or the server), 20 | // update the number on the page 21 | doc.on('op', showNumbers); 22 | 23 | function showNumbers() { 24 | document.querySelector('#num-clicks').textContent = doc.data.numClicks; 25 | }; 26 | 27 | // When clicking on the '+1' button, change the number in the local 28 | // document and sync the change to the server and other connected 29 | // clients 30 | function increment() { 31 | // Increment `doc.data.numClicks`. See 32 | // https://github.com/ottypes/json1/blob/master/spec.md for list of valid operations. 33 | doc.submitOp(['numClicks', {ena: 1}]); 34 | } 35 | 36 | // Expose to index.html 37 | global.increment = increment; 38 | -------------------------------------------------------------------------------- /examples/counter-json1/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/share/sharedb/a8f997bdae6bb6f502b8f55db81525c6e0236670/examples/counter-json1/demo.gif -------------------------------------------------------------------------------- /examples/counter-json1/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-counter-json1", 3 | "version": "1.0.0", 4 | "description": "A simple client/server app using ShareDB (ottype json1) and WebSockets", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify client.js -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server.js" 10 | }, 11 | "author": "Dmitry Kharitonov (https://dkharitonov.me/)", 12 | "contributors": [ 13 | "Dmitry Kharitonov (https://dkharitonov.me/)", 14 | "Avital Oliver (https://aoliver.org/)", 15 | "Marko Bregant (https://bregant.si/)" 16 | ], 17 | "license": "MIT", 18 | "dependencies": { 19 | "@teamwork/websocket-json-stream": "^2.0.0", 20 | "express": "^4.18.2", 21 | "ot-json1": "^1.0.2", 22 | "reconnecting-websocket": "^4.4.0", 23 | "sharedb": "^3.3.0", 24 | "ws": "^8.12.1" 25 | }, 26 | "devDependencies": { 27 | "browserify": "^17.0.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/counter-json1/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var express = require('express'); 3 | var ShareDB = require('sharedb'); 4 | var WebSocket = require('ws'); 5 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 6 | var json1 = require('ot-json1'); 7 | 8 | ShareDB.types.register(json1.type); 9 | var backend = new ShareDB(); 10 | createDoc(startServer); 11 | 12 | // Create initial document then fire callback 13 | function createDoc(callback) { 14 | var connection = backend.connect(); 15 | var doc = connection.get('examples', 'counter'); 16 | doc.fetch(function(err) { 17 | if (err) throw err; 18 | if (doc.type === null) { 19 | doc.create({numClicks: 0}, json1.type.uri, callback); 20 | return; 21 | } 22 | callback(); 23 | }); 24 | } 25 | 26 | function startServer() { 27 | // Create a web server to serve files and listen to WebSocket connections 28 | var app = express(); 29 | app.use(express.static('static')); 30 | var server = http.createServer(app); 31 | 32 | // Connect any incoming WebSocket connection to ShareDB 33 | var wss = new WebSocket.Server({server: server}); 34 | wss.on('connection', function(ws) { 35 | var stream = new WebSocketJSONStream(ws); 36 | backend.listen(stream); 37 | }); 38 | 39 | server.listen(8080); 40 | console.log('Listening on http://localhost:8080'); 41 | } 42 | -------------------------------------------------------------------------------- /examples/counter-json1/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Counter (ottype json1) 4 | 5 |
6 | You clicked times. 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/counter/.gitignore: -------------------------------------------------------------------------------- 1 | static/dist/ 2 | -------------------------------------------------------------------------------- /examples/counter/README.md: -------------------------------------------------------------------------------- 1 | # Simple realtime client/server sync with ShareDB 2 | 3 | ![Demo](demo.gif) 4 | 5 | This is a simple websocket server that exposes the ShareDB protocol, 6 | with a client showing an incrementing number that is sychronized 7 | across all open browser tabs. 8 | 9 | In this demo, data is not persisted. To persist data, run a Mongo 10 | server and initialize ShareDB with the 11 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 12 | 13 | ## Install dependencies 14 | ``` 15 | npm install 16 | ``` 17 | 18 | ## Build JavaScript bundle and run server 19 | ``` 20 | npm run build && npm start 21 | ``` 22 | 23 | ## Run app in browser 24 | Load [http://localhost:8080](http://localhost:8080) 25 | -------------------------------------------------------------------------------- /examples/counter/client.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnecting-websocket'); 2 | var sharedb = require('sharedb/lib/client'); 3 | 4 | // Open WebSocket connection to ShareDB server 5 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 6 | // ShareDB handles dropped messages, and buffering them while the socket 7 | // is closed has undefined behavior 8 | maxEnqueuedMessages: 0 9 | }); 10 | var connection = new sharedb.Connection(socket); 11 | 12 | // Create local Doc instance mapped to 'examples' collection document with id 'counter' 13 | var doc = connection.get('examples', 'counter'); 14 | 15 | // Get initial value of document and subscribe to changes 16 | doc.subscribe(showNumbers); 17 | // When document changes (by this client or any other, or the server), 18 | // update the number on the page 19 | doc.on('op', showNumbers); 20 | 21 | function showNumbers() { 22 | document.querySelector('#num-clicks').textContent = doc.data.numClicks; 23 | }; 24 | 25 | // When clicking on the '+1' button, change the number in the local 26 | // document and sync the change to the server and other connected 27 | // clients 28 | function increment() { 29 | // Increment `doc.data.numClicks`. See 30 | // https://github.com/ottypes/json0 for list of valid operations. 31 | doc.submitOp([{p: ['numClicks'], na: 1}]); 32 | } 33 | 34 | // Expose to index.html 35 | global.increment = increment; 36 | -------------------------------------------------------------------------------- /examples/counter/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/share/sharedb/a8f997bdae6bb6f502b8f55db81525c6e0236670/examples/counter/demo.gif -------------------------------------------------------------------------------- /examples/counter/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-counter", 3 | "version": "1.0.0", 4 | "description": "A simple client/server app using ShareDB and WebSockets", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify client.js -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server.js" 10 | }, 11 | "author": "Dmitry Kharitonov (https://dkharitonov.me/)", 12 | "contributors": [ 13 | "Dmitry Kharitonov (https://dkharitonov.me/)", 14 | "Avital Oliver (https://aoliver.org/)" 15 | ], 16 | "license": "MIT", 17 | "dependencies": { 18 | "@teamwork/websocket-json-stream": "^2.0.0", 19 | "express": "^4.18.2", 20 | "reconnecting-websocket": "^4.4.0", 21 | "sharedb": "^3.3.0", 22 | "ws": "^8.12.1" 23 | }, 24 | "devDependencies": { 25 | "browserify": "^17.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/counter/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var express = require('express'); 3 | var ShareDB = require('sharedb'); 4 | var WebSocket = require('ws'); 5 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 6 | 7 | var backend = new ShareDB(); 8 | createDoc(startServer); 9 | 10 | // Create initial document then fire callback 11 | function createDoc(callback) { 12 | var connection = backend.connect(); 13 | var doc = connection.get('examples', 'counter'); 14 | doc.fetch(function(err) { 15 | if (err) throw err; 16 | if (doc.type === null) { 17 | doc.create({numClicks: 0}, callback); 18 | return; 19 | } 20 | callback(); 21 | }); 22 | } 23 | 24 | function startServer() { 25 | // Create a web server to serve files and listen to WebSocket connections 26 | var app = express(); 27 | app.use(express.static('static')); 28 | var server = http.createServer(app); 29 | 30 | // Connect any incoming WebSocket connection to ShareDB 31 | var wss = new WebSocket.Server({server: server}); 32 | wss.on('connection', function(ws) { 33 | var stream = new WebSocketJSONStream(ws); 34 | backend.listen(stream); 35 | }); 36 | 37 | server.listen(8080); 38 | console.log('Listening on http://localhost:8080'); 39 | } 40 | -------------------------------------------------------------------------------- /examples/counter/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Counter 4 | 5 |
6 | You clicked times. 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/leaderboard/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /examples/leaderboard/README.md: -------------------------------------------------------------------------------- 1 | # Leaderboard 2 | 3 | ![Demo](demo.gif) 4 | 5 | This is a port of [Leaderboard](https://github.com/percolatestudio/react-leaderboard) to 6 | ShareDB. 7 | 8 | In this demo, data is not persisted. To persist data, run a Mongo 9 | server and initialize ShareDB with the 10 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 11 | 12 | ## Install dependencies 13 | 14 | Make sure you're in the `examples/leaderboard` folder so that it uses the `package.json` located here). 15 | ``` 16 | npm install 17 | ``` 18 | 19 | ## Build JavaScript bundle and run server 20 | ``` 21 | npm run build && npm start 22 | ``` 23 | 24 | Finally, open the example app in the browser. It runs on port 8080 by default: 25 | [http://localhost:8080](http://localhost:8080) 26 | 27 | For testing out the real-time aspects of this demo, you'll want to open two browser windows! 28 | -------------------------------------------------------------------------------- /examples/leaderboard/client/Body.jsx: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var Leaderboard = require('./Leaderboard.jsx'); 3 | 4 | function Body() { 5 | return ( 6 |
7 |
8 |
9 |

Leaderboard

10 |
Select a scientist to give them points
11 | 12 |
13 |
14 | ); 15 | } 16 | 17 | module.exports = Body; 18 | -------------------------------------------------------------------------------- /examples/leaderboard/client/Leaderboard.jsx: -------------------------------------------------------------------------------- 1 | var PlayerList = require('./PlayerList.jsx'); 2 | var PlayerSelector = require('./PlayerSelector.jsx'); 3 | var React = require('react'); 4 | var _ = require('underscore'); 5 | var connection = require('./connection'); 6 | 7 | class Leaderboard extends React.Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | selectedPlayerId: null, 12 | players: [] 13 | }; 14 | this.handlePlayerSelected = this.handlePlayerSelected.bind(this); 15 | this.handleAddPoints = this.handleAddPoints.bind(this); 16 | } 17 | 18 | componentDidMount() { 19 | var comp = this; 20 | var query = connection.createSubscribeQuery('players', {$sort: {score: -1}}); 21 | query.on('ready', update); 22 | query.on('changed', update); 23 | 24 | function update() { 25 | comp.setState({players: query.results}); 26 | } 27 | } 28 | 29 | componentWillUnmount() { 30 | query.destroy(); 31 | } 32 | 33 | selectedPlayer() { 34 | return _.find(this.state.players, function(x) { 35 | return x.id === this.state.selectedPlayerId; 36 | }.bind(this)); 37 | } 38 | 39 | handlePlayerSelected(id) { 40 | this.setState({selectedPlayerId: id}); 41 | } 42 | 43 | handleAddPoints() { 44 | var op = [{p: ['score'], na: 5}]; 45 | connection.get('players', this.state.selectedPlayerId).submitOp(op, function(err) { 46 | if (err) { console.error(err); return; } 47 | }); 48 | } 49 | 50 | render() { 51 | return ( 52 |
53 |
54 | 55 |
56 | 57 |
58 | ); 59 | } 60 | } 61 | 62 | module.exports = Leaderboard; 63 | 64 | -------------------------------------------------------------------------------- /examples/leaderboard/client/Player.jsx: -------------------------------------------------------------------------------- 1 | var PropTypes = require('prop-types'); 2 | var React = require('react'); 3 | var classNames = require('classnames'); 4 | 5 | class Player extends React.Component { 6 | constructor(props) { 7 | super(props); 8 | this.handleClick = this.handleClick.bind(this); 9 | } 10 | 11 | handleClick(event) { 12 | this.props.onPlayerSelected(this.props.doc.id); 13 | } 14 | 15 | componentDidMount() { 16 | var comp = this; 17 | var doc = comp.props.doc; 18 | doc.subscribe(); 19 | doc.on('load', update); 20 | doc.on('op', update); 21 | function update() { 22 | // `comp.props.doc.data` is now updated. re-render component. 23 | comp.forceUpdate(); 24 | } 25 | } 26 | 27 | componentWillUnmount() { 28 | this.doc.unsubscribe(); 29 | } 30 | 31 | render() { 32 | var classes = { 33 | 'player': true, 34 | 'selected': this.props.selected 35 | }; 36 | 37 | return ( 38 |
  • 39 | {this.props.doc.data.name} 40 | {this.props.doc.data.score} 41 |
  • 42 | ); 43 | } 44 | } 45 | 46 | Player.propTypes = { 47 | doc: PropTypes.object.isRequired, 48 | onPlayerSelected: PropTypes.func.isRequired, 49 | selected: PropTypes.bool.isRequired 50 | }; 51 | 52 | module.exports = Player; 53 | -------------------------------------------------------------------------------- /examples/leaderboard/client/PlayerList.jsx: -------------------------------------------------------------------------------- 1 | var PropTypes = require('prop-types'); 2 | var React = require('react'); 3 | var Player = require('./Player.jsx'); 4 | var _ = require('underscore'); 5 | 6 | function PlayerList(props) { 7 | var { players, selectedPlayerId } = props; 8 | var other = _.omit(props, 'players', 'selectedPlayerId'); 9 | 10 | var playerNodes = players.map(function(player, index) { 11 | var selected = selectedPlayerId === player.id; 12 | 13 | return ( 14 | 15 | ); 16 | }); 17 | return ( 18 |
    19 | {playerNodes} 20 |
    21 | ); 22 | } 23 | 24 | PlayerList.propTypes = { 25 | players: PropTypes.array.isRequired, 26 | selectedPlayerId: PropTypes.string 27 | }; 28 | 29 | module.exports = PlayerList; 30 | -------------------------------------------------------------------------------- /examples/leaderboard/client/PlayerSelector.jsx: -------------------------------------------------------------------------------- 1 | var PropTypes = require('prop-types'); 2 | var React = require('react'); 3 | 4 | function PlayerSelector({ selectedPlayer, onAddPoints }) { 5 | var node; 6 | 7 | if (selectedPlayer) { 8 | node =
    9 |
    {selectedPlayer.data.name}
    10 | 11 |
    ; 12 | } else { 13 | node =
    Click a player to select
    ; 14 | } 15 | 16 | return node; 17 | } 18 | 19 | PlayerSelector.propTypes = { 20 | selectedPlayer: PropTypes.object 21 | }; 22 | 23 | module.exports = PlayerSelector; 24 | -------------------------------------------------------------------------------- /examples/leaderboard/client/connection.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnecting-websocket'); 2 | var sharedb = require('sharedb/lib/client'); 3 | 4 | // Expose a singleton WebSocket connection to ShareDB server 5 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 6 | // ShareDB handles dropped messages, and buffering them while the socket 7 | // is closed has undefined behavior 8 | maxEnqueuedMessages: 0 9 | }); 10 | var connection = new sharedb.Connection(socket); 11 | module.exports = connection; 12 | -------------------------------------------------------------------------------- /examples/leaderboard/client/index.jsx: -------------------------------------------------------------------------------- 1 | var Body = require('./Body.jsx'); 2 | var React = require('react'); 3 | var ReactDOM = require('react-dom/client'); 4 | 5 | var container = document.getElementById('main'); 6 | var root = ReactDOM.createRoot(container); 7 | root.render(); 8 | -------------------------------------------------------------------------------- /examples/leaderboard/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/share/sharedb/a8f997bdae6bb6f502b8f55db81525c6e0236670/examples/leaderboard/demo.gif -------------------------------------------------------------------------------- /examples/leaderboard/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-leaderboard", 3 | "version": "1.0.0", 4 | "description": "React Leaderboard backed by ShareDB", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify -t [ babelify --presets [ @babel/react ] ] client/index.jsx -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server/index.js" 10 | }, 11 | "author": "Tom Coleman (https://tom.thesnail.org/)", 12 | "contributors": [ 13 | "Tom Coleman (https://tom.thesnail.org/)", 14 | "Avital Oliver (https://aoliver.org/)" 15 | ], 16 | "license": "MIT", 17 | "dependencies": { 18 | "@teamwork/websocket-json-stream": "^2.0.0", 19 | "classnames": "^2.3.2", 20 | "express": "^4.18.2", 21 | "prop-types": "^15.8.1", 22 | "react": "^18.2.0", 23 | "react-dom": "^18.2.0", 24 | "reconnecting-websocket": "^4.4.0", 25 | "sharedb": "^3.3.0", 26 | "sharedb-mingo-memory": "^2.1.2", 27 | "underscore": "^1.13.6", 28 | "ws": "^8.12.1" 29 | }, 30 | "devDependencies": { 31 | "@babel/preset-react": "^7.18.6", 32 | "babelify": "^10.0.0", 33 | "browserify": "^17.0.0" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/leaderboard/server/index.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var ShareDB = require('sharedb'); 3 | var express = require('express'); 4 | var ShareDBMingoMemory = require('sharedb-mingo-memory'); 5 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 6 | var WebSocket = require('ws'); 7 | 8 | // Start ShareDB 9 | var share = new ShareDB({db: new ShareDBMingoMemory()}); 10 | 11 | // Create a WebSocket server 12 | var app = express(); 13 | app.use(express.static('static')); 14 | var server = http.createServer(app); 15 | var wss = new WebSocket.Server({server: server}); 16 | server.listen(8080); 17 | console.log('Listening on http://localhost:8080'); 18 | 19 | // Connect any incoming WebSocket connection with ShareDB 20 | wss.on('connection', function(ws) { 21 | var stream = new WebSocketJSONStream(ws); 22 | share.listen(stream); 23 | }); 24 | 25 | // Create initial documents 26 | var connection = share.connect(); 27 | connection.createFetchQuery('players', {}, {}, function(err, results) { 28 | if (err) { 29 | throw err; 30 | } 31 | 32 | if (results.length === 0) { 33 | var names = ['Ada Lovelace', 'Grace Hopper', 'Marie Curie', 34 | 'Carl Friedrich Gauss', 'Nikola Tesla', 'Claude Shannon']; 35 | 36 | names.forEach(function(name, index) { 37 | var doc = connection.get('players', ''+index); 38 | var data = {name: name, score: Math.floor(Math.random() * 10) * 5}; 39 | doc.create(data); 40 | }); 41 | } 42 | }); 43 | -------------------------------------------------------------------------------- /examples/leaderboard/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Leaderboard 4 | 5 | 6 |
    7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/leaderboard/static/leaderboard.css: -------------------------------------------------------------------------------- 1 | * { 2 | -moz-box-sizing: border-box; 3 | box-sizing: border-box; 4 | } 5 | 6 | body { 7 | font-family: 'Source Sans Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif; 8 | font-size: 16px; 9 | font-weight: normal; 10 | margin: 3em 0; 11 | padding: 0; 12 | } 13 | 14 | .outer { 15 | margin: 0 auto; 16 | max-width: 480px; 17 | } 18 | 19 | .logo { 20 | background: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4KPCEtLSBHZW5lcmF0ZWQgYnkgSWNvTW9vbi5pbyAtLT4KPCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj4KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj4KPGcgaWQ9Imljb21vb24taWdub3JlIj4KCTxsaW5lIHN0cm9rZS13aWR0aD0iMSIgeDE9IiIgeTE9IiIgeDI9IiIgeTI9IiIgc3Ryb2tlPSIjNDQ5RkRCIiBvcGFjaXR5PSIiPjwvbGluZT4KPC9nPgoJPHBhdGggZD0iTTI2LjUyIDI0LjcwNWwtNi41Mi0xMS4yOTN2LTcuODc3aDAuNzVjMC42OSAwIDEuMjUtMC41NiAxLjI1LTEuMjVzLTAuNTYtMS4yNS0xLjI1LTEuMjVoLTkuNWMtMC42OSAwLTEuMjUgMC41Ni0xLjI1IDEuMjVzMC41NiAxLjI1IDEuMjUgMS4yNWgwLjc1djcuODc3bC02LjUyIDExLjI5M2MtMS4zNzUgMi4zODEtMC4yNSA0LjMzIDIuNSA0LjMzaDE2LjA0MGMyLjc1IDAgMy44NzUtMS45NDkgMi41LTQuMzN6TTE0LjUgNS41MzVoM2wtMC4wMDIgOC41NDMgMS45OTYgMy40NTdoLTYuOTljMS4xMjMtMS45NDYgMS45OTYtMy40NTkgMS45OTYtMy40NTl2LTguNTQxek0yNC4wMjAgMjYuNTM1aC0xNi4wNDBjLTAuMjU5IDAtMC40NDMtMC4wMzAtMC41NjEtMC4wNjMgMC4wMzEtMC4xMTkgMC4wOTctMC4yOTQgMC4yMjctMC41MTggMCAwIDEuNzA2LTIuOTU3IDMuNDE2LTUuOTJsOS44NzYtMCAzLjQxOCA1LjkyYzAuMTI5IDAuMjI0IDAuMTk1IDAuMzk5IDAuMjI3IDAuNTE4LTAuMTE4IDAuMDMyLTAuMzAzIDAuMDYzLTAuNTYyIDAuMDYzeiIgZmlsbD0iIzAwMDAwMCI+PC9wYXRoPgo8L3N2Zz4K'); 21 | background-position: center center; 22 | background-repeat: no-repeat; 23 | background-size: contain; 24 | height: 1.5em; 25 | margin: 0 auto .75em; 26 | width: 1.5em; 27 | } 28 | 29 | .title { 30 | font-size: 1.5em; 31 | font-weight: 600; 32 | letter-spacing: .3em; 33 | margin: 0 0 .25em; 34 | text-align: center; 35 | text-indent: .3em; 36 | text-transform: uppercase; 37 | } 38 | 39 | .subtitle { 40 | color: #999; 41 | font-size: .875em; 42 | margin-bottom: 2em; 43 | text-align: center; 44 | } 45 | 46 | .leaderboard { 47 | border-top: 1px solid #f1f1f1; 48 | counter-reset: ol-counter; 49 | list-style-type: none; 50 | margin: 0 0 1.5em; 51 | padding: 0; 52 | } 53 | 54 | .player { 55 | border-bottom: 1px solid #f1f1f1; 56 | cursor: pointer; 57 | padding: .5em 0; 58 | position: relative; 59 | overflow: hidden; 60 | transition: background 300ms ease-out; 61 | border-left: 4px solid white; 62 | } 63 | 64 | .player:before { 65 | color: #999; 66 | content: counter(ol-counter); 67 | counter-increment: ol-counter; 68 | display: inline-block; 69 | font-weight: 300; 70 | line-height: 3em; 71 | text-align: center; 72 | vertical-align: middle; 73 | width: 3em; 74 | } 75 | 76 | .player .avatar { 77 | display: inline-block; 78 | margin-right: 1em; 79 | vertical-align: middle; 80 | } 81 | 82 | .player .name { 83 | display: inline-block; 84 | font-size: 1.25em; 85 | font-weight: 300; 86 | vertical-align: middle; 87 | } 88 | 89 | .player .score { 90 | color: #333; 91 | display: block; 92 | float: right; 93 | font-size: 1.25em; 94 | font-weight: 600; 95 | line-height: 2.4em; 96 | padding-right: 1.25em; 97 | } 98 | 99 | .player.selected { 100 | background-color: #fefff4; 101 | border-left: #eb5f3a 4px solid; 102 | } 103 | 104 | .player:hover { 105 | background-color: #fefff4; 106 | } 107 | 108 | .details { 109 | overflow: hidden; 110 | } 111 | 112 | .details .name { 113 | display: inline-block; 114 | font-size: 1.5em; 115 | font-weight: 300; 116 | line-height: 2.25rem; 117 | padding-left: 1.25rem; 118 | vertical-align: middle; 119 | } 120 | 121 | .details .inc { 122 | border-radius: 3em; 123 | border: #eb5f3a 1px solid; 124 | background: transparent; 125 | color: #eb5f3a; 126 | cursor: pointer; 127 | float: right; 128 | font-family: 'Source Sans Pro' ,'Helvetica Neue', Helvetica, Arial, sans-serif; 129 | font-size: 1rem; 130 | line-height: 1; 131 | margin: 0; 132 | outline: none; 133 | padding: 10px 30px; 134 | transition: all 200ms ease-in; 135 | } 136 | 137 | .inc:hover { 138 | background: #eb5f3a; 139 | color: #fff; 140 | } 141 | 142 | .inc:active { 143 | box-shadow: rgba(0,0,0,.3) 0 1px 3px 0 inset; 144 | } 145 | 146 | .message { 147 | color: #aaa; 148 | line-height: 2.25rem; 149 | text-align: center; 150 | } 151 | 152 | @media (max-width: 500px) { 153 | .details, .message { 154 | display: block; 155 | position: fixed; 156 | bottom: 0; 157 | background-color: #fafafa; 158 | width: 100%; 159 | padding: 12px 15px; 160 | border-top: 1px solid #ccc; 161 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); 162 | } 163 | 164 | .details .name { 165 | font-size: 1.2em; 166 | padding-left: 0; 167 | } 168 | 169 | .details .inc { 170 | padding: 10px 20px; 171 | } 172 | 173 | body { 174 | margin: 2em 0 4em 0; 175 | } 176 | 177 | .player:hover { 178 | background-color: inherit; 179 | } 180 | 181 | .player.selected:hover { 182 | background-color: #fefff4; 183 | } 184 | } -------------------------------------------------------------------------------- /examples/rich-text-presence/.gitignore: -------------------------------------------------------------------------------- 1 | static/dist/ 2 | -------------------------------------------------------------------------------- /examples/rich-text-presence/README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Rich Text Editor with ShareDB 2 | 3 | This is a collaborative rich text editor using [Quill](https://github.com/quilljs/quill) and the [rich-text OT type](https://github.com/ottypes/rich-text). 4 | 5 | In this demo, data is not persisted. To persist data, run a Mongo 6 | server and initialize ShareDB with the 7 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 8 | 9 | ## Install dependencies 10 | ``` 11 | npm install 12 | ``` 13 | 14 | ## Build JavaScript bundle and run server 15 | ``` 16 | npm run build && npm start 17 | ``` 18 | 19 | ## Run app in browser 20 | Load [http://localhost:8080](http://localhost:8080) 21 | -------------------------------------------------------------------------------- /examples/rich-text-presence/client.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnecting-websocket'); 2 | var sharedb = require('sharedb/lib/client'); 3 | var richText = require('rich-text'); 4 | var Quill = require('quill'); 5 | var QuillCursors = require('quill-cursors'); 6 | var tinycolor = require('tinycolor2'); 7 | var ObjectID = require('bson-objectid'); 8 | 9 | sharedb.types.register(richText.type); 10 | Quill.register('modules/cursors', QuillCursors); 11 | 12 | var connectionButton = document.getElementById('client-connection'); 13 | connectionButton.addEventListener('click', function() { 14 | toggleConnection(connectionButton); 15 | }); 16 | 17 | var nameInput = document.getElementById('name'); 18 | 19 | var colors = {}; 20 | 21 | var collection = 'examples'; 22 | var id = 'richtext'; 23 | var presenceId = new ObjectID().toString(); 24 | 25 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 26 | // ShareDB handles dropped messages, and buffering them while the socket 27 | // is closed has undefined behavior 28 | maxEnqueuedMessages: 0 29 | }); 30 | var connection = new sharedb.Connection(socket); 31 | var doc = connection.get(collection, id); 32 | 33 | doc.subscribe(function(err) { 34 | if (err) throw err; 35 | initialiseQuill(doc); 36 | }); 37 | 38 | function initialiseQuill(doc) { 39 | var quill = new Quill('#editor', { 40 | theme: 'bubble', 41 | modules: {cursors: true} 42 | }); 43 | var cursors = quill.getModule('cursors'); 44 | 45 | quill.setContents(doc.data); 46 | 47 | quill.on('text-change', function(delta, oldDelta, source) { 48 | if (source !== 'user') return; 49 | doc.submitOp(delta); 50 | }); 51 | 52 | doc.on('op', function(op, source) { 53 | if (source) return; 54 | quill.updateContents(op); 55 | }); 56 | 57 | var presence = doc.connection.getDocPresence(collection, id); 58 | presence.subscribe(function(error) { 59 | if (error) throw error; 60 | }); 61 | var localPresence = presence.create(presenceId); 62 | 63 | quill.on('selection-change', function(range, oldRange, source) { 64 | // We only need to send updates if the user moves the cursor 65 | // themselves. Cursor updates as a result of text changes will 66 | // automatically be handled by the remote client. 67 | if (source !== 'user') return; 68 | // Ignore blurring, so that we can see lots of users in the 69 | // same window. In real use, you may want to clear the cursor. 70 | if (!range) return; 71 | // In this particular instance, we can send extra information 72 | // on the presence object. This ability will vary depending on 73 | // type. 74 | range.name = nameInput.value; 75 | localPresence.submit(range, function(error) { 76 | if (error) throw error; 77 | }); 78 | }); 79 | 80 | presence.on('receive', function(id, range) { 81 | colors[id] = colors[id] || tinycolor.random().toHexString(); 82 | var name = (range && range.name) || 'Anonymous'; 83 | cursors.createCursor(id, name, colors[id]); 84 | cursors.moveCursor(id, range); 85 | }); 86 | 87 | return quill; 88 | } 89 | 90 | function toggleConnection(button) { 91 | if (button.classList.contains('connected')) { 92 | button.classList.remove('connected'); 93 | button.textContent = 'Connect'; 94 | disconnect(); 95 | } else { 96 | button.classList.add('connected'); 97 | button.textContent = 'Disconnect'; 98 | connect(); 99 | } 100 | } 101 | 102 | function disconnect() { 103 | doc.connection.close(); 104 | } 105 | 106 | function connect() { 107 | var socket = new ReconnectingWebSocket('ws://' + window.location.host); 108 | doc.connection.bindToSocket(socket); 109 | } 110 | -------------------------------------------------------------------------------- /examples/rich-text-presence/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-rich-text-presence", 3 | "version": "1.0.0", 4 | "description": "An example of presence using ShareDB and Quill", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify client.js -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server.js" 10 | }, 11 | "author": "Nate Smith", 12 | "contributors": [ 13 | "Avital Oliver (https://aoliver.org/)", 14 | "Alec Gibson " 15 | ], 16 | "license": "MIT", 17 | "dependencies": { 18 | "@teamwork/websocket-json-stream": "^2.0.0", 19 | "bson-objectid": "^2.0.4", 20 | "express": "^4.18.2", 21 | "quill": "^1.3.7", 22 | "quill-cursors": "^4.0.2", 23 | "reconnecting-websocket": "^4.4.0", 24 | "rich-text": "^4.1.0", 25 | "sharedb": "^3.3.0", 26 | "tinycolor2": "^1.6.0", 27 | "ws": "^8.12.1" 28 | }, 29 | "devDependencies": { 30 | "browserify": "^17.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/rich-text-presence/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var express = require('express'); 3 | var ShareDB = require('sharedb'); 4 | var richText = require('rich-text'); 5 | var WebSocket = require('ws'); 6 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 7 | 8 | ShareDB.types.register(richText.type); 9 | var backend = new ShareDB({ 10 | presence: true, 11 | doNotForwardSendPresenceErrorsToClient: true 12 | }); 13 | createDoc(startServer); 14 | 15 | // Create initial document then fire callback 16 | function createDoc(callback) { 17 | var connection = backend.connect(); 18 | var doc = connection.get('examples', 'richtext'); 19 | doc.fetch(function(err) { 20 | if (err) throw err; 21 | if (doc.type === null) { 22 | doc.create([{insert: 'Hi!'}], 'rich-text', callback); 23 | return; 24 | } 25 | callback(); 26 | }); 27 | } 28 | 29 | function startServer() { 30 | // Create a web server to serve files and listen to WebSocket connections 31 | var app = express(); 32 | app.use(express.static('static')); 33 | app.use(express.static('node_modules/quill/dist')); 34 | var server = http.createServer(app); 35 | 36 | // Connect any incoming WebSocket connection to ShareDB 37 | var wss = new WebSocket.Server({server: server}); 38 | wss.on('connection', function(ws) { 39 | var stream = new WebSocketJSONStream(ws); 40 | backend.listen(stream); 41 | }); 42 | 43 | server.listen(8080); 44 | console.log('Listening on http://localhost:8080'); 45 | } 46 | -------------------------------------------------------------------------------- /examples/rich-text-presence/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Rich Text 4 | 5 | 6 | 7 | 8 |
    9 | 10 | 11 |
    12 | 13 |
    14 | Open a new window to see another client! 15 |
    16 | 17 |
    18 | 19 | 20 | -------------------------------------------------------------------------------- /examples/rich-text-presence/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | input, button { 6 | font-size: 16px; 7 | margin-right: 10px; 8 | } 9 | 10 | .controls { 11 | width: 100%; 12 | text-align: center; 13 | margin: 20px; 14 | } 15 | 16 | .ql-container { 17 | padding: 10px; 18 | } 19 | 20 | .ql-editor { 21 | border: 1px solid grey; 22 | } 23 | 24 | /* Keep the example simple by hiding the toolbar */ 25 | .ql-tooltip { 26 | display: none; 27 | } 28 | -------------------------------------------------------------------------------- /examples/rich-text/.gitignore: -------------------------------------------------------------------------------- 1 | static/dist/ 2 | -------------------------------------------------------------------------------- /examples/rich-text/README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Rich Text Editor with ShareDB 2 | 3 | This is a collaborative rich text editor using [Quill](https://github.com/quilljs/quill) and the [rich-text OT type](https://github.com/ottypes/rich-text). 4 | 5 | In this demo, data is not persisted. To persist data, run a Mongo 6 | server and initialize ShareDB with the 7 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 8 | 9 | ## Install dependencies 10 | ``` 11 | npm install 12 | ``` 13 | 14 | ## Build JavaScript bundle and run server 15 | ``` 16 | npm run build && npm start 17 | ``` 18 | 19 | ## Run app in browser 20 | Load [http://localhost:8080](http://localhost:8080) 21 | -------------------------------------------------------------------------------- /examples/rich-text/client.js: -------------------------------------------------------------------------------- 1 | var ReconnectingWebSocket = require('reconnecting-websocket'); 2 | var sharedb = require('sharedb/lib/client'); 3 | var richText = require('rich-text'); 4 | var Quill = require('quill'); 5 | sharedb.types.register(richText.type); 6 | 7 | // Open WebSocket connection to ShareDB server 8 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 9 | // ShareDB handles dropped messages, and buffering them while the socket 10 | // is closed has undefined behavior 11 | maxEnqueuedMessages: 0 12 | }); 13 | var connection = new sharedb.Connection(socket); 14 | 15 | // For testing reconnection 16 | window.disconnect = function() { 17 | connection.close(); 18 | }; 19 | window.connect = function() { 20 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 21 | // ShareDB handles dropped messages, and buffering them while the socket 22 | // is closed has undefined behavior 23 | maxEnqueuedMessages: 0 24 | }); 25 | connection.bindToSocket(socket); 26 | }; 27 | 28 | // Create local Doc instance mapped to 'examples' collection document with id 'richtext' 29 | var doc = connection.get('examples', 'richtext'); 30 | doc.subscribe(function(err) { 31 | if (err) throw err; 32 | var quill = new Quill('#editor', {theme: 'snow'}); 33 | quill.setContents(doc.data); 34 | quill.on('text-change', function(delta, oldDelta, source) { 35 | if (source !== 'user') return; 36 | doc.submitOp(delta, {source: quill}); 37 | }); 38 | doc.on('op', function(op, source) { 39 | if (source === quill) return; 40 | quill.updateContents(op); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /examples/rich-text/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-rich-text", 3 | "version": "1.0.0", 4 | "description": "A simple rich-text editor example based on Quill and ShareDB", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify client.js -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server.js" 10 | }, 11 | "author": "Nate Smith", 12 | "contributors": [ 13 | "Avital Oliver (https://aoliver.org/)" 14 | ], 15 | "license": "MIT", 16 | "dependencies": { 17 | "@teamwork/websocket-json-stream": "^2.0.0", 18 | "express": "^4.18.2", 19 | "quill": "^1.3.7", 20 | "reconnecting-websocket": "^4.4.0", 21 | "rich-text": "^4.1.0", 22 | "sharedb": "^3.3.0", 23 | "ws": "^8.12.1" 24 | }, 25 | "devDependencies": { 26 | "browserify": "^17.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/rich-text/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var express = require('express'); 3 | var ShareDB = require('sharedb'); 4 | var richText = require('rich-text'); 5 | var WebSocket = require('ws'); 6 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 7 | 8 | ShareDB.types.register(richText.type); 9 | var backend = new ShareDB(); 10 | createDoc(startServer); 11 | 12 | // Create initial document then fire callback 13 | function createDoc(callback) { 14 | var connection = backend.connect(); 15 | var doc = connection.get('examples', 'richtext'); 16 | doc.fetch(function(err) { 17 | if (err) throw err; 18 | if (doc.type === null) { 19 | doc.create([{insert: 'Hi!'}], 'rich-text', callback); 20 | return; 21 | } 22 | callback(); 23 | }); 24 | } 25 | 26 | function startServer() { 27 | // Create a web server to serve files and listen to WebSocket connections 28 | var app = express(); 29 | app.use(express.static('static')); 30 | app.use(express.static('node_modules/quill/dist')); 31 | var server = http.createServer(app); 32 | 33 | // Connect any incoming WebSocket connection to ShareDB 34 | var wss = new WebSocket.Server({server: server}); 35 | wss.on('connection', function(ws) { 36 | var stream = new WebSocketJSONStream(ws); 37 | backend.listen(stream); 38 | }); 39 | 40 | server.listen(8080); 41 | console.log('Listening on http://localhost:8080'); 42 | } 43 | -------------------------------------------------------------------------------- /examples/rich-text/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Rich Text 4 | 5 | 6 | 7 |
    8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/textarea/.gitignore: -------------------------------------------------------------------------------- 1 | static/dist/ 2 | -------------------------------------------------------------------------------- /examples/textarea/README.md: -------------------------------------------------------------------------------- 1 | # Collaborative Textarea with ShareDB 2 | 3 | This is a collaborative plain textarea using the default ShareDB JSON document 4 | type and the `sharedb-string-binding` module. 5 | 6 | In this demo, data is not persisted. To persist data, run a Mongo 7 | server and initialize ShareDB with the 8 | [ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter. 9 | 10 | ## Install dependencies 11 | ``` 12 | npm install 13 | ``` 14 | 15 | ## Build JavaScript bundle and run server 16 | ``` 17 | npm run build && npm start 18 | ``` 19 | 20 | ## Run app in browser 21 | Load [http://localhost:8080](http://localhost:8080) 22 | -------------------------------------------------------------------------------- /examples/textarea/client.js: -------------------------------------------------------------------------------- 1 | var sharedb = require('sharedb/lib/client'); 2 | var StringBinding = require('sharedb-string-binding'); 3 | 4 | // Open WebSocket connection to ShareDB server 5 | var ReconnectingWebSocket = require('reconnecting-websocket'); 6 | var socket = new ReconnectingWebSocket('ws://' + window.location.host, [], { 7 | // ShareDB handles dropped messages, and buffering them while the socket 8 | // is closed has undefined behavior 9 | maxEnqueuedMessages: 0 10 | }); 11 | var connection = new sharedb.Connection(socket); 12 | 13 | var element = document.querySelector('textarea'); 14 | var statusSpan = document.getElementById('status-span'); 15 | statusSpan.innerHTML = 'Not Connected'; 16 | 17 | element.style.backgroundColor = 'gray'; 18 | socket.addEventListener('open', function() { 19 | statusSpan.innerHTML = 'Connected'; 20 | element.style.backgroundColor = 'white'; 21 | }); 22 | 23 | socket.addEventListener('close', function() { 24 | statusSpan.innerHTML = 'Closed'; 25 | element.style.backgroundColor = 'gray'; 26 | }); 27 | 28 | socket.addEventListener('error', function() { 29 | statusSpan.innerHTML = 'Error'; 30 | element.style.backgroundColor = 'red'; 31 | }); 32 | 33 | // Create local Doc instance mapped to 'examples' collection document with id 'textarea' 34 | var doc = connection.get('examples', 'textarea'); 35 | doc.subscribe(function(err) { 36 | if (err) throw err; 37 | 38 | var binding = new StringBinding(element, doc, ['content']); 39 | binding.setup(); 40 | }); 41 | -------------------------------------------------------------------------------- /examples/textarea/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb-example-textarea", 3 | "version": "1.0.0", 4 | "description": "A simple client/server app using ShareDB and WebSockets", 5 | "main": "server.js", 6 | "scripts": { 7 | "build": "browserify client.js -o static/dist/bundle.js", 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "start": "node server.js" 10 | }, 11 | "author": "Nate Smith", 12 | "contributors": [ 13 | "Avital Oliver (https://aoliver.org/)" 14 | ], 15 | "license": "MIT", 16 | "dependencies": { 17 | "@teamwork/websocket-json-stream": "^2.0.0", 18 | "express": "^4.18.2", 19 | "reconnecting-websocket": "^4.4.0", 20 | "sharedb": "^3.3.0", 21 | "sharedb-string-binding": "^1.0.0", 22 | "ws": "^8.12.1" 23 | }, 24 | "devDependencies": { 25 | "browserify": "^17.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/textarea/server.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var express = require('express'); 3 | var ShareDB = require('sharedb'); 4 | var WebSocket = require('ws'); 5 | var WebSocketJSONStream = require('@teamwork/websocket-json-stream'); 6 | 7 | var backend = new ShareDB(); 8 | createDoc(startServer); 9 | 10 | // Create initial document then fire callback 11 | function createDoc(callback) { 12 | var connection = backend.connect(); 13 | var doc = connection.get('examples', 'textarea'); 14 | doc.fetch(function(err) { 15 | if (err) throw err; 16 | if (doc.type === null) { 17 | doc.create({content: ''}, callback); 18 | return; 19 | } 20 | callback(); 21 | }); 22 | } 23 | 24 | function startServer() { 25 | // Create a web server to serve files and listen to WebSocket connections 26 | var app = express(); 27 | app.use(express.static('static')); 28 | var server = http.createServer(app); 29 | 30 | // Connect any incoming WebSocket connection to ShareDB 31 | var wss = new WebSocket.Server({server: server}); 32 | wss.on('connection', function(ws) { 33 | var stream = new WebSocketJSONStream(ws); 34 | backend.listen(stream); 35 | }); 36 | 37 | server.listen(8080); 38 | console.log('Listening on http://localhost:8080'); 39 | } 40 | -------------------------------------------------------------------------------- /examples/textarea/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | ShareDB Textarea 4 | 5 | 27 | 28 |
    29 |

    Text Area Example with Reconnecting Websockets

    30 |

    Connection Status:

    31 | 32 |
    33 | 34 | 35 | -------------------------------------------------------------------------------- /lib/client/index.js: -------------------------------------------------------------------------------- 1 | exports.Connection = require('./connection'); 2 | exports.Doc = require('./doc'); 3 | exports.Error = require('../error'); 4 | exports.Query = require('./query'); 5 | exports.types = require('../types'); 6 | exports.logger = require('../logger'); 7 | -------------------------------------------------------------------------------- /lib/client/presence/doc-presence-emitter.js: -------------------------------------------------------------------------------- 1 | var util = require('../../util'); 2 | var EventEmitter = require('events').EventEmitter; 3 | 4 | var EVENTS = [ 5 | 'create', 6 | 'del', 7 | 'destroy', 8 | 'load', 9 | 'op' 10 | ]; 11 | 12 | module.exports = DocPresenceEmitter; 13 | 14 | function DocPresenceEmitter() { 15 | this._docs = Object.create(null); 16 | this._forwarders = Object.create(null); 17 | this._emitters = Object.create(null); 18 | } 19 | 20 | DocPresenceEmitter.prototype.addEventListener = function(doc, event, listener) { 21 | this._registerDoc(doc); 22 | var emitter = util.dig(this._emitters, doc.collection, doc.id); 23 | emitter.on(event, listener); 24 | }; 25 | 26 | DocPresenceEmitter.prototype.removeEventListener = function(doc, event, listener) { 27 | var emitter = util.dig(this._emitters, doc.collection, doc.id); 28 | if (!emitter) return; 29 | emitter.off(event, listener); 30 | // We'll always have at least one, because of the destroy listener 31 | if (emitter._eventsCount === 1) this._unregisterDoc(doc); 32 | }; 33 | 34 | DocPresenceEmitter.prototype._registerDoc = function(doc) { 35 | var alreadyRegistered = true; 36 | util.digOrCreate(this._docs, doc.collection, doc.id, function() { 37 | alreadyRegistered = false; 38 | return doc; 39 | }); 40 | 41 | if (alreadyRegistered) return; 42 | 43 | var emitter = util.digOrCreate(this._emitters, doc.collection, doc.id, function() { 44 | var e = new EventEmitter(); 45 | // Set a high limit to avoid unnecessary warnings, but still 46 | // retain some degree of memory leak detection 47 | e.setMaxListeners(1000); 48 | return e; 49 | }); 50 | 51 | var self = this; 52 | EVENTS.forEach(function(event) { 53 | var forwarder = util.digOrCreate(self._forwarders, doc.collection, doc.id, event, function() { 54 | return emitter.emit.bind(emitter, event); 55 | }); 56 | 57 | doc.on(event, forwarder); 58 | }); 59 | 60 | this.addEventListener(doc, 'destroy', this._unregisterDoc.bind(this, doc)); 61 | }; 62 | 63 | DocPresenceEmitter.prototype._unregisterDoc = function(doc) { 64 | var forwarders = util.dig(this._forwarders, doc.collection, doc.id); 65 | for (var event in forwarders) { 66 | doc.off(event, forwarders[event]); 67 | } 68 | 69 | var emitter = util.dig(this._emitters, doc.collection, doc.id); 70 | emitter.removeAllListeners(); 71 | 72 | util.digAndRemove(this._forwarders, doc.collection, doc.id); 73 | util.digAndRemove(this._emitters, doc.collection, doc.id); 74 | util.digAndRemove(this._docs, doc.collection, doc.id); 75 | }; 76 | -------------------------------------------------------------------------------- /lib/client/presence/doc-presence.js: -------------------------------------------------------------------------------- 1 | var Presence = require('./presence'); 2 | var LocalDocPresence = require('./local-doc-presence'); 3 | var RemoteDocPresence = require('./remote-doc-presence'); 4 | 5 | function DocPresence(connection, collection, id) { 6 | var channel = DocPresence.channel(collection, id); 7 | Presence.call(this, connection, channel); 8 | 9 | this.collection = collection; 10 | this.id = id; 11 | } 12 | module.exports = DocPresence; 13 | 14 | DocPresence.prototype = Object.create(Presence.prototype); 15 | 16 | DocPresence.channel = function(collection, id) { 17 | return collection + '.' + id; 18 | }; 19 | 20 | DocPresence.prototype._createLocalPresence = function(id) { 21 | return new LocalDocPresence(this, id); 22 | }; 23 | 24 | DocPresence.prototype._createRemotePresence = function(id) { 25 | return new RemoteDocPresence(this, id); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/client/presence/local-doc-presence.js: -------------------------------------------------------------------------------- 1 | var LocalPresence = require('./local-presence'); 2 | var ShareDBError = require('../../error'); 3 | var util = require('../../util'); 4 | var ERROR_CODE = ShareDBError.CODES; 5 | 6 | module.exports = LocalDocPresence; 7 | function LocalDocPresence(presence, presenceId) { 8 | LocalPresence.call(this, presence, presenceId); 9 | 10 | this.collection = this.presence.collection; 11 | this.id = this.presence.id; 12 | 13 | this._doc = this.connection.get(this.collection, this.id); 14 | this._emitter = this.connection._docPresenceEmitter; 15 | this._isSending = false; 16 | this._docDataVersionByPresenceVersion = Object.create(null); 17 | 18 | this._opHandler = this._transformAgainstOp.bind(this); 19 | this._createOrDelHandler = this._handleCreateOrDel.bind(this); 20 | this._loadHandler = this._handleLoad.bind(this); 21 | this._destroyHandler = this.destroy.bind(this); 22 | this._registerWithDoc(); 23 | } 24 | 25 | LocalDocPresence.prototype = Object.create(LocalPresence.prototype); 26 | 27 | LocalDocPresence.prototype.submit = function(value, callback) { 28 | if (!this._doc.type) { 29 | // If the Doc hasn't been created, we already assume all presence to 30 | // be null. Let's early return, instead of error since this is a harmless 31 | // no-op 32 | if (value === null) return this._callbackOrEmit(null, callback); 33 | 34 | var error = null; 35 | if (this._doc._isInHardRollback) { 36 | error = { 37 | code: ERROR_CODE.ERR_DOC_IN_HARD_ROLLBACK, 38 | message: 'Cannot submit presence. Document is processing hard rollback' 39 | }; 40 | } else { 41 | error = { 42 | code: ERROR_CODE.ERR_DOC_DOES_NOT_EXIST, 43 | message: 'Cannot submit presence. Document has not been created' 44 | }; 45 | } 46 | 47 | return this._callbackOrEmit(error, callback); 48 | }; 49 | 50 | // Record the current data state version to check if we need to transform 51 | // the presence later 52 | this._docDataVersionByPresenceVersion[this.presenceVersion] = this._doc._dataStateVersion; 53 | LocalPresence.prototype.submit.call(this, value, callback); 54 | }; 55 | 56 | LocalDocPresence.prototype.destroy = function(callback) { 57 | this._emitter.removeEventListener(this._doc, 'op', this._opHandler); 58 | this._emitter.removeEventListener(this._doc, 'create', this._createOrDelHandler); 59 | this._emitter.removeEventListener(this._doc, 'del', this._createOrDelHandler); 60 | this._emitter.removeEventListener(this._doc, 'load', this._loadHandler); 61 | this._emitter.removeEventListener(this._doc, 'destroy', this._destroyHandler); 62 | 63 | LocalPresence.prototype.destroy.call(this, callback); 64 | }; 65 | 66 | LocalDocPresence.prototype._sendPending = function() { 67 | if (this._isSending) return; 68 | this._isSending = true; 69 | var presence = this; 70 | this._doc.whenNothingPending(function() { 71 | presence._isSending = false; 72 | if (!presence.connection.canSend) return; 73 | 74 | presence._pendingMessages.forEach(function(message) { 75 | message.t = presence._doc.type.uri; 76 | message.v = presence._doc.version; 77 | presence.connection.send(message); 78 | }); 79 | 80 | presence._pendingMessages = []; 81 | presence._docDataVersionByPresenceVersion = Object.create(null); 82 | }); 83 | }; 84 | 85 | LocalDocPresence.prototype._registerWithDoc = function() { 86 | this._emitter.addEventListener(this._doc, 'op', this._opHandler); 87 | this._emitter.addEventListener(this._doc, 'create', this._createOrDelHandler); 88 | this._emitter.addEventListener(this._doc, 'del', this._createOrDelHandler); 89 | this._emitter.addEventListener(this._doc, 'load', this._loadHandler); 90 | this._emitter.addEventListener(this._doc, 'destroy', this._destroyHandler); 91 | }; 92 | 93 | LocalDocPresence.prototype._transformAgainstOp = function(op, source) { 94 | var presence = this; 95 | var docDataVersion = this._doc._dataStateVersion; 96 | 97 | this._pendingMessages.forEach(function(message) { 98 | // Check if the presence needs transforming against the op - this is to check against 99 | // edge cases where presence is submitted from an 'op' event 100 | var messageDocDataVersion = presence._docDataVersionByPresenceVersion[message.pv]; 101 | if (messageDocDataVersion >= docDataVersion) return; 102 | try { 103 | message.p = presence._transformPresence(message.p, op, source); 104 | // Ensure the presence's data version is kept consistent to deal with "deep" op 105 | // submissions 106 | presence._docDataVersionByPresenceVersion[message.pv] = docDataVersion; 107 | } catch (error) { 108 | var callback = presence._getCallback(message.pv); 109 | presence._callbackOrEmit(error, callback); 110 | } 111 | }); 112 | 113 | try { 114 | this.value = this._transformPresence(this.value, op, source); 115 | } catch (error) { 116 | this.emit('error', error); 117 | } 118 | }; 119 | 120 | LocalDocPresence.prototype._handleCreateOrDel = function() { 121 | this._pendingMessages.forEach(function(message) { 122 | message.p = null; 123 | }); 124 | 125 | this.value = null; 126 | }; 127 | 128 | LocalDocPresence.prototype._handleLoad = function() { 129 | this.value = null; 130 | this._pendingMessages = []; 131 | this._docDataVersionByPresenceVersion = Object.create(null); 132 | }; 133 | 134 | LocalDocPresence.prototype._message = function() { 135 | var message = LocalPresence.prototype._message.call(this); 136 | message.c = this.collection, 137 | message.d = this.id, 138 | message.v = null; 139 | message.t = null; 140 | return message; 141 | }; 142 | 143 | LocalDocPresence.prototype._transformPresence = function(value, op, source) { 144 | var type = this._doc.type; 145 | if (!util.supportsPresence(type)) { 146 | throw new ShareDBError( 147 | ERROR_CODE.ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE, 148 | 'Type does not support presence: ' + type.name 149 | ); 150 | } 151 | return type.transformPresence(value, op, source); 152 | }; 153 | -------------------------------------------------------------------------------- /lib/client/presence/local-presence.js: -------------------------------------------------------------------------------- 1 | var emitter = require('../../emitter'); 2 | var ACTIONS = require('../../message-actions').ACTIONS; 3 | var util = require('../../util'); 4 | 5 | module.exports = LocalPresence; 6 | function LocalPresence(presence, presenceId) { 7 | emitter.EventEmitter.call(this); 8 | 9 | if (!presenceId || typeof presenceId !== 'string') { 10 | throw new Error('LocalPresence presenceId must be a string'); 11 | } 12 | 13 | this.presence = presence; 14 | this.presenceId = presenceId; 15 | this.connection = presence.connection; 16 | this.presenceVersion = 0; 17 | 18 | this.value = null; 19 | 20 | this._pendingMessages = []; 21 | this._callbacksByPresenceVersion = Object.create(null); 22 | } 23 | emitter.mixin(LocalPresence); 24 | 25 | LocalPresence.prototype.submit = function(value, callback) { 26 | this.value = value; 27 | this.send(callback); 28 | }; 29 | 30 | LocalPresence.prototype.send = function(callback) { 31 | var message = this._message(); 32 | this._pendingMessages.push(message); 33 | this._callbacksByPresenceVersion[message.pv] = callback; 34 | this._sendPending(); 35 | }; 36 | 37 | LocalPresence.prototype.destroy = function(callback) { 38 | var presence = this; 39 | this.submit(null, function(error) { 40 | if (error) return presence._callbackOrEmit(error, callback); 41 | delete presence.presence.localPresences[presence.presenceId]; 42 | if (callback) callback(); 43 | }); 44 | }; 45 | 46 | LocalPresence.prototype._sendPending = function() { 47 | if (!this.connection.canSend) return; 48 | var presence = this; 49 | this._pendingMessages.forEach(function(message) { 50 | presence.connection.send(message); 51 | }); 52 | 53 | this._pendingMessages = []; 54 | }; 55 | 56 | LocalPresence.prototype._ack = function(error, presenceVersion) { 57 | var callback = this._getCallback(presenceVersion); 58 | this._callbackOrEmit(error, callback); 59 | }; 60 | 61 | LocalPresence.prototype._message = function() { 62 | return { 63 | a: ACTIONS.presence, 64 | ch: this.presence.channel, 65 | id: this.presenceId, 66 | p: this.value, 67 | pv: this.presenceVersion++ 68 | }; 69 | }; 70 | 71 | LocalPresence.prototype._getCallback = function(presenceVersion) { 72 | var callback = this._callbacksByPresenceVersion[presenceVersion]; 73 | delete this._callbacksByPresenceVersion[presenceVersion]; 74 | return callback; 75 | }; 76 | 77 | LocalPresence.prototype._callbackOrEmit = function(error, callback) { 78 | if (callback) return util.nextTick(callback, error); 79 | if (error) this.emit('error', error); 80 | }; 81 | -------------------------------------------------------------------------------- /lib/client/presence/remote-doc-presence.js: -------------------------------------------------------------------------------- 1 | var RemotePresence = require('./remote-presence'); 2 | var ot = require('../../ot'); 3 | 4 | module.exports = RemoteDocPresence; 5 | function RemoteDocPresence(presence, presenceId) { 6 | RemotePresence.call(this, presence, presenceId); 7 | 8 | this.collection = this.presence.collection; 9 | this.id = this.presence.id; 10 | this.src = null; 11 | this.presenceVersion = null; 12 | 13 | this._doc = this.connection.get(this.collection, this.id); 14 | this._emitter = this.connection._docPresenceEmitter; 15 | this._pending = null; 16 | this._opCache = null; 17 | this._pendingSetPending = false; 18 | 19 | this._opHandler = this._handleOp.bind(this); 20 | this._createDelHandler = this._handleCreateDel.bind(this); 21 | this._loadHandler = this._handleLoad.bind(this); 22 | this._registerWithDoc(); 23 | } 24 | 25 | RemoteDocPresence.prototype = Object.create(RemotePresence.prototype); 26 | 27 | RemoteDocPresence.prototype.receiveUpdate = function(message) { 28 | if (this._pending && message.pv < this._pending.pv) return; 29 | this.src = message.src; 30 | this._pending = message; 31 | this._setPendingPresence(); 32 | }; 33 | 34 | RemoteDocPresence.prototype.destroy = function(callback) { 35 | this._emitter.removeEventListener(this._doc, 'op', this._opHandler); 36 | this._emitter.removeEventListener(this._doc, 'create', this._createDelHandler); 37 | this._emitter.removeEventListener(this._doc, 'del', this._createDelHandler); 38 | this._emitter.removeEventListener(this._doc, 'load', this._loadHandler); 39 | 40 | RemotePresence.prototype.destroy.call(this, callback); 41 | }; 42 | 43 | RemoteDocPresence.prototype._registerWithDoc = function() { 44 | this._emitter.addEventListener(this._doc, 'op', this._opHandler); 45 | this._emitter.addEventListener(this._doc, 'create', this._createDelHandler); 46 | this._emitter.addEventListener(this._doc, 'del', this._createDelHandler); 47 | this._emitter.addEventListener(this._doc, 'load', this._loadHandler); 48 | }; 49 | 50 | RemoteDocPresence.prototype._setPendingPresence = function() { 51 | if (this._pendingSetPending) return; 52 | this._pendingSetPending = true; 53 | var presence = this; 54 | this._doc.whenNothingPending(function() { 55 | presence._pendingSetPending = false; 56 | if (!presence._pending) return; 57 | if (presence._pending.pv < presence.presenceVersion) return presence._pending = null; 58 | 59 | if (presence._pending.v > presence._doc.version) { 60 | return presence._doc.fetch(); 61 | } 62 | 63 | if (!presence._catchUpStalePresence()) return; 64 | 65 | presence.value = presence._pending.p; 66 | presence.presenceVersion = presence._pending.pv; 67 | presence._pending = null; 68 | presence.presence._updateRemotePresence(presence); 69 | }); 70 | }; 71 | 72 | RemoteDocPresence.prototype._handleOp = function(op, source, connectionId) { 73 | var isOwnOp = connectionId === this.src; 74 | this._transformAgainstOp(op, isOwnOp); 75 | this._cacheOp(op, isOwnOp); 76 | this._setPendingPresence(); 77 | }; 78 | 79 | RemotePresence.prototype._handleCreateDel = function() { 80 | this._cacheOp(null); 81 | this._setPendingPresence(); 82 | }; 83 | 84 | RemotePresence.prototype._handleLoad = function() { 85 | this.value = null; 86 | this._pending = null; 87 | this._opCache = null; 88 | this.presence._updateRemotePresence(this); 89 | }; 90 | 91 | RemoteDocPresence.prototype._transformAgainstOp = function(op, isOwnOp) { 92 | if (!this.value) return; 93 | 94 | try { 95 | this.value = this._doc.type.transformPresence(this.value, op, isOwnOp); 96 | } catch (error) { 97 | return this.presence.emit('error', error); 98 | } 99 | this.presence._updateRemotePresence(this); 100 | }; 101 | 102 | RemoteDocPresence.prototype._catchUpStalePresence = function() { 103 | if (this._pending.v >= this._doc.version) return true; 104 | 105 | if (!this._opCache) { 106 | this._startCachingOps(); 107 | this._doc.fetch(); 108 | this.presence._requestRemotePresence(); 109 | return false; 110 | } 111 | 112 | while (this._opCache[this._pending.v]) { 113 | var item = this._opCache[this._pending.v]; 114 | var op = item.op; 115 | var isOwnOp = item.isOwnOp; 116 | // We use a null op to signify a create or a delete operation. In both 117 | // cases we just want to reset the presence (which doesn't make sense 118 | // in a new document), so just set the presence to null. 119 | if (op === null) { 120 | this._pending.p = null; 121 | this._pending.v++; 122 | } else { 123 | ot.transformPresence(this._pending, op, isOwnOp); 124 | } 125 | } 126 | 127 | var hasCaughtUp = this._pending.v >= this._doc.version; 128 | if (hasCaughtUp) { 129 | this._stopCachingOps(); 130 | } 131 | 132 | return hasCaughtUp; 133 | }; 134 | 135 | RemoteDocPresence.prototype._startCachingOps = function() { 136 | this._opCache = []; 137 | }; 138 | 139 | RemoteDocPresence.prototype._stopCachingOps = function() { 140 | this._opCache = null; 141 | }; 142 | 143 | RemoteDocPresence.prototype._cacheOp = function(op, isOwnOp) { 144 | if (this._opCache) { 145 | op = op ? {op: op} : null; 146 | // Subtract 1 from the current doc version, because an op with v3 147 | // should be read as the op that takes a doc from v3 -> v4 148 | this._opCache[this._doc.version - 1] = {op: op, isOwnOp: isOwnOp}; 149 | } 150 | }; 151 | -------------------------------------------------------------------------------- /lib/client/presence/remote-presence.js: -------------------------------------------------------------------------------- 1 | var util = require('../../util'); 2 | 3 | module.exports = RemotePresence; 4 | function RemotePresence(presence, presenceId) { 5 | this.presence = presence; 6 | this.presenceId = presenceId; 7 | this.connection = this.presence.connection; 8 | 9 | this.value = null; 10 | this.presenceVersion = 0; 11 | } 12 | 13 | RemotePresence.prototype.receiveUpdate = function(message) { 14 | if (message.pv < this.presenceVersion) return; 15 | this.value = message.p; 16 | this.presenceVersion = message.pv; 17 | this.presence._updateRemotePresence(this); 18 | }; 19 | 20 | RemotePresence.prototype.destroy = function(callback) { 21 | delete this.presence._remotePresenceInstances[this.presenceId]; 22 | delete this.presence.remotePresences[this.presenceId]; 23 | if (callback) util.nextTick(callback); 24 | }; 25 | -------------------------------------------------------------------------------- /lib/client/snapshot-request/snapshot-request.js: -------------------------------------------------------------------------------- 1 | var Snapshot = require('../../snapshot'); 2 | var emitter = require('../../emitter'); 3 | 4 | module.exports = SnapshotRequest; 5 | 6 | function SnapshotRequest(connection, requestId, collection, id, callback) { 7 | emitter.EventEmitter.call(this); 8 | 9 | if (typeof callback !== 'function') { 10 | throw new Error('Callback is required for SnapshotRequest'); 11 | } 12 | 13 | this.requestId = requestId; 14 | this.connection = connection; 15 | this.id = id; 16 | this.collection = collection; 17 | this.callback = callback; 18 | 19 | this.sent = false; 20 | } 21 | emitter.mixin(SnapshotRequest); 22 | 23 | SnapshotRequest.prototype.send = function() { 24 | if (!this.connection.canSend) { 25 | return; 26 | } 27 | 28 | this.connection.send(this._message()); 29 | this.sent = true; 30 | }; 31 | 32 | SnapshotRequest.prototype._onConnectionStateChanged = function() { 33 | if (this.connection.canSend) { 34 | if (!this.sent) this.send(); 35 | } else { 36 | // If the connection can't send, then we've had a disconnection, and even if we've already sent 37 | // the request previously, we need to re-send it over this reconnected client, so reset the 38 | // sent flag to false. 39 | this.sent = false; 40 | } 41 | }; 42 | 43 | SnapshotRequest.prototype._handleResponse = function(error, message) { 44 | this.emit('ready'); 45 | 46 | if (error) { 47 | return this.callback(error); 48 | } 49 | 50 | var metadata = message.meta ? message.meta : null; 51 | var snapshot = new Snapshot(this.id, message.v, message.type, message.data, metadata); 52 | 53 | this.callback(null, snapshot); 54 | }; 55 | -------------------------------------------------------------------------------- /lib/client/snapshot-request/snapshot-timestamp-request.js: -------------------------------------------------------------------------------- 1 | var SnapshotRequest = require('./snapshot-request'); 2 | var util = require('../../util'); 3 | var ACTIONS = require('../../message-actions').ACTIONS; 4 | 5 | module.exports = SnapshotTimestampRequest; 6 | 7 | function SnapshotTimestampRequest(connection, requestId, collection, id, timestamp, callback) { 8 | SnapshotRequest.call(this, connection, requestId, collection, id, callback); 9 | 10 | if (!util.isValidTimestamp(timestamp)) { 11 | throw new Error('Snapshot timestamp must be a positive integer or null'); 12 | } 13 | 14 | this.timestamp = timestamp; 15 | } 16 | 17 | SnapshotTimestampRequest.prototype = Object.create(SnapshotRequest.prototype); 18 | 19 | SnapshotTimestampRequest.prototype._message = function() { 20 | return { 21 | a: ACTIONS.snapshotFetchByTimestamp, 22 | id: this.requestId, 23 | c: this.collection, 24 | d: this.id, 25 | ts: this.timestamp 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /lib/client/snapshot-request/snapshot-version-request.js: -------------------------------------------------------------------------------- 1 | var SnapshotRequest = require('./snapshot-request'); 2 | var util = require('../../util'); 3 | var ACTIONS = require('../../message-actions').ACTIONS; 4 | 5 | module.exports = SnapshotVersionRequest; 6 | 7 | function SnapshotVersionRequest(connection, requestId, collection, id, version, callback) { 8 | SnapshotRequest.call(this, connection, requestId, collection, id, callback); 9 | 10 | if (!util.isValidVersion(version)) { 11 | throw new Error('Snapshot version must be a positive integer or null'); 12 | } 13 | 14 | this.version = version; 15 | } 16 | 17 | SnapshotVersionRequest.prototype = Object.create(SnapshotRequest.prototype); 18 | 19 | SnapshotVersionRequest.prototype._message = function() { 20 | return { 21 | a: ACTIONS.snapshotFetch, 22 | id: this.requestId, 23 | c: this.collection, 24 | d: this.id, 25 | v: this.version 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /lib/db/index.js: -------------------------------------------------------------------------------- 1 | var async = require('async'); 2 | var ShareDBError = require('../error'); 3 | 4 | var ERROR_CODE = ShareDBError.CODES; 5 | 6 | function DB(options) { 7 | // pollDebounce is the minimum time in ms between query polls 8 | this.pollDebounce = options && options.pollDebounce; 9 | } 10 | module.exports = DB; 11 | 12 | // When false, Backend will handle projections instead of DB 13 | DB.prototype.projectsSnapshots = false; 14 | DB.prototype.disableSubscribe = false; 15 | 16 | DB.prototype.close = function(callback) { 17 | if (callback) callback(); 18 | }; 19 | 20 | DB.prototype.commit = function(collection, id, op, snapshot, options, callback) { 21 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'commit DB method unimplemented')); 22 | }; 23 | 24 | DB.prototype.getSnapshot = function(collection, id, fields, options, callback) { 25 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'getSnapshot DB method unimplemented')); 26 | }; 27 | 28 | DB.prototype.getSnapshotBulk = function(collection, ids, fields, options, callback) { 29 | var results = Object.create(null); 30 | var db = this; 31 | async.each(ids, function(id, eachCb) { 32 | db.getSnapshot(collection, id, fields, options, function(err, snapshot) { 33 | if (err) return eachCb(err); 34 | results[id] = snapshot; 35 | eachCb(); 36 | }); 37 | }, function(err) { 38 | if (err) return callback(err); 39 | callback(null, results); 40 | }); 41 | }; 42 | 43 | DB.prototype.getOps = function(collection, id, from, to, options, callback) { 44 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'getOps DB method unimplemented')); 45 | }; 46 | 47 | DB.prototype.getOpsToSnapshot = function(collection, id, from, snapshot, options, callback) { 48 | var to = snapshot.v; 49 | this.getOps(collection, id, from, to, options, callback); 50 | }; 51 | 52 | DB.prototype.getOpsBulk = function(collection, fromMap, toMap, options, callback) { 53 | var results = Object.create(null); 54 | var db = this; 55 | async.forEachOf(fromMap, function(from, id, eachCb) { 56 | var to = toMap && toMap[id]; 57 | db.getOps(collection, id, from, to, options, function(err, ops) { 58 | if (err) return eachCb(err); 59 | results[id] = ops; 60 | eachCb(); 61 | }); 62 | }, function(err) { 63 | if (err) return callback(err); 64 | callback(null, results); 65 | }); 66 | }; 67 | 68 | DB.prototype.getCommittedOpVersion = function(collection, id, snapshot, op, options, callback) { 69 | this.getOpsToSnapshot(collection, id, 0, snapshot, options, function(err, ops) { 70 | if (err) return callback(err); 71 | for (var i = ops.length; i--;) { 72 | var item = ops[i]; 73 | if (op.src === item.src && op.seq === item.seq) { 74 | return callback(null, item.v); 75 | } 76 | } 77 | callback(); 78 | }); 79 | }; 80 | 81 | DB.prototype.query = function(collection, query, fields, options, callback) { 82 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'query DB method unimplemented')); 83 | }; 84 | 85 | DB.prototype.queryPoll = function(collection, query, options, callback) { 86 | var fields = Object.create(null); 87 | this.query(collection, query, fields, options, function(err, snapshots, extra) { 88 | if (err) return callback(err); 89 | var ids = []; 90 | for (var i = 0; i < snapshots.length; i++) { 91 | ids.push(snapshots[i].id); 92 | } 93 | callback(null, ids, extra); 94 | }); 95 | }; 96 | 97 | DB.prototype.queryPollDoc = function(collection, id, query, options, callback) { 98 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 'queryPollDoc DB method unimplemented')); 99 | }; 100 | 101 | DB.prototype.canPollDoc = function() { 102 | return false; 103 | }; 104 | 105 | DB.prototype.skipPoll = function() { 106 | return false; 107 | }; 108 | -------------------------------------------------------------------------------- /lib/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/error.js: -------------------------------------------------------------------------------- 1 | function ShareDBError(code, message) { 2 | this.code = code; 3 | this.message = message || ''; 4 | if (Error.captureStackTrace) { 5 | Error.captureStackTrace(this, ShareDBError); 6 | } else { 7 | this.stack = new Error().stack; 8 | } 9 | } 10 | 11 | ShareDBError.prototype = Object.create(Error.prototype); 12 | ShareDBError.prototype.constructor = ShareDBError; 13 | ShareDBError.prototype.name = 'ShareDBError'; 14 | 15 | ShareDBError.CODES = { 16 | ERR_APPLY_OP_VERSION_DOES_NOT_MATCH_SNAPSHOT: 'ERR_APPLY_OP_VERSION_DOES_NOT_MATCH_SNAPSHOT', 17 | ERR_APPLY_SNAPSHOT_NOT_PROVIDED: 'ERR_APPLY_SNAPSHOT_NOT_PROVIDED', 18 | ERR_FIXUP_IS_ONLY_VALID_ON_APPLY: 'ERR_FIXUP_IS_ONLY_VALID_ON_APPLY', 19 | ERR_CANNOT_FIXUP_DELETION: 'ERR_CANNOT_FIXUP_DELETION', 20 | ERR_CLIENT_ID_BADLY_FORMED: 'ERR_CLIENT_ID_BADLY_FORMED', 21 | ERR_CANNOT_PING_OFFLINE: 'ERR_CANNOT_PING_OFFLINE', 22 | ERR_CONNECTION_SEQ_INTEGER_OVERFLOW: 'ERR_CONNECTION_SEQ_INTEGER_OVERFLOW', 23 | ERR_CONNECTION_STATE_TRANSITION_INVALID: 'ERR_CONNECTION_STATE_TRANSITION_INVALID', 24 | ERR_DATABASE_ADAPTER_NOT_FOUND: 'ERR_DATABASE_ADAPTER_NOT_FOUND', 25 | ERR_DATABASE_DOES_NOT_SUPPORT_SUBSCRIBE: 'ERR_DATABASE_DOES_NOT_SUPPORT_SUBSCRIBE', 26 | ERR_DATABASE_METHOD_NOT_IMPLEMENTED: 'ERR_DATABASE_METHOD_NOT_IMPLEMENTED', 27 | ERR_DEFAULT_TYPE_MISMATCH: 'ERR_DEFAULT_TYPE_MISMATCH', 28 | ERR_DOC_MISSING_VERSION: 'ERR_DOC_MISSING_VERSION', 29 | ERR_DOC_ALREADY_CREATED: 'ERR_DOC_ALREADY_CREATED', 30 | ERR_DOC_DOES_NOT_EXIST: 'ERR_DOC_DOES_NOT_EXIST', 31 | ERR_DOC_TYPE_NOT_RECOGNIZED: 'ERR_DOC_TYPE_NOT_RECOGNIZED', 32 | ERR_DOC_WAS_DELETED: 'ERR_DOC_WAS_DELETED', 33 | ERR_DOC_IN_HARD_ROLLBACK: 'ERR_DOC_IN_HARD_ROLLBACK', 34 | ERR_INFLIGHT_OP_MISSING: 'ERR_INFLIGHT_OP_MISSING', 35 | ERR_INGESTED_SNAPSHOT_HAS_NO_VERSION: 'ERR_INGESTED_SNAPSHOT_HAS_NO_VERSION', 36 | ERR_MAX_SUBMIT_RETRIES_EXCEEDED: 'ERR_MAX_SUBMIT_RETRIES_EXCEEDED', 37 | ERR_MESSAGE_BADLY_FORMED: 'ERR_MESSAGE_BADLY_FORMED', 38 | ERR_MILESTONE_ARGUMENT_INVALID: 'ERR_MILESTONE_ARGUMENT_INVALID', 39 | ERR_NO_OP: 'ERR_NO_OP', 40 | ERR_OP_ALREADY_SUBMITTED: 'ERR_OP_ALREADY_SUBMITTED', 41 | ERR_OP_NOT_ALLOWED_IN_PROJECTION: 'ERR_OP_NOT_ALLOWED_IN_PROJECTION', 42 | ERR_OP_SUBMIT_REJECTED: 'ERR_OP_SUBMIT_REJECTED', 43 | ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED: 'ERR_PENDING_OP_REMOVED_BY_OP_SUBMIT_REJECTED', 44 | ERR_HARD_ROLLBACK_FETCH_FAILED: 'ERR_HARD_ROLLBACK_FETCH_FAILED', 45 | ERR_OP_VERSION_MISMATCH_AFTER_TRANSFORM: 'ERR_OP_VERSION_MISMATCH_AFTER_TRANSFORM', 46 | ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM: 'ERR_OP_VERSION_MISMATCH_DURING_TRANSFORM', 47 | ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT: 'ERR_OP_VERSION_NEWER_THAN_CURRENT_SNAPSHOT', 48 | ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED: 'ERR_OT_LEGACY_JSON0_OP_CANNOT_BE_NORMALIZED', 49 | ERR_OT_OP_BADLY_FORMED: 'ERR_OT_OP_BADLY_FORMED', 50 | ERR_OT_OP_NOT_APPLIED: 'ERR_OT_OP_NOT_APPLIED', 51 | ERR_OT_OP_NOT_PROVIDED: 'ERR_OT_OP_NOT_PROVIDED', 52 | ERR_PRESENCE_TRANSFORM_FAILED: 'ERR_PRESENCE_TRANSFORM_FAILED', 53 | ERR_PROTOCOL_VERSION_NOT_SUPPORTED: 'ERR_PROTOCOL_VERSION_NOT_SUPPORTED', 54 | ERR_QUERY_CHANNEL_MISSING: 'ERR_QUERY_CHANNEL_MISSING', 55 | ERR_QUERY_EMITTER_LISTENER_NOT_ASSIGNED: 'ERR_QUERY_EMITTER_LISTENER_NOT_ASSIGNED', 56 | /** 57 | * A special error that a "readSnapshots" middleware implementation can use to indicate that it 58 | * wishes for the ShareDB client to treat it as a silent rejection, not passing the error back to 59 | * user code. 60 | * 61 | * For subscribes, the ShareDB client will still cancel the document subscription. 62 | */ 63 | ERR_SNAPSHOT_READ_SILENT_REJECTION: 'ERR_SNAPSHOT_READ_SILENT_REJECTION', 64 | /** 65 | * A "readSnapshots" middleware rejected the reads of specific snapshots. 66 | * 67 | * This error code is mostly for server use and generally will not be encountered on the client. 68 | * Instead, each specific doc that encountered an error will receive its specific error. 69 | * 70 | * The one exception is for queries, where a "readSnapshots" rejection of specific snapshots will 71 | * cause the client to receive this error for the whole query, since queries don't support 72 | * doc-specific errors. 73 | */ 74 | ERR_SNAPSHOT_READS_REJECTED: 'ERR_SNAPSHOT_READS_REJECTED', 75 | ERR_SUBMIT_TRANSFORM_OPS_NOT_FOUND: 'ERR_SUBMIT_TRANSFORM_OPS_NOT_FOUND', 76 | ERR_TYPE_CANNOT_BE_PROJECTED: 'ERR_TYPE_CANNOT_BE_PROJECTED', 77 | ERR_TYPE_DOES_NOT_SUPPORT_COMPOSE: 'ERR_TYPE_DOES_NOT_SUPPORT_COMPOSE', 78 | ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE: 'ERR_TYPE_DOES_NOT_SUPPORT_PRESENCE', 79 | ERR_UNKNOWN_ERROR: 'ERR_UNKNOWN_ERROR' 80 | }; 81 | 82 | module.exports = ShareDBError; 83 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | var Backend = require('./backend'); 2 | module.exports = Backend; 3 | 4 | Backend.Agent = require('./agent'); 5 | Backend.Backend = Backend; 6 | Backend.DB = require('./db'); 7 | Backend.Error = require('./error'); 8 | Backend.logger = require('./logger'); 9 | Backend.MemoryDB = require('./db/memory'); 10 | Backend.MemoryMilestoneDB = require('./milestone-db/memory'); 11 | Backend.MemoryPubSub = require('./pubsub/memory'); 12 | Backend.MESSAGE_ACTIONS = require('./message-actions').ACTIONS; 13 | Backend.MilestoneDB = require('./milestone-db'); 14 | Backend.ot = require('./ot'); 15 | Backend.projections = require('./projections'); 16 | Backend.PubSub = require('./pubsub'); 17 | Backend.QueryEmitter = require('./query-emitter'); 18 | Backend.SubmitRequest = require('./submit-request'); 19 | Backend.types = require('./types'); 20 | -------------------------------------------------------------------------------- /lib/logger/index.js: -------------------------------------------------------------------------------- 1 | var Logger = require('./logger'); 2 | var logger = new Logger(); 3 | module.exports = logger; 4 | -------------------------------------------------------------------------------- /lib/logger/logger.js: -------------------------------------------------------------------------------- 1 | var SUPPORTED_METHODS = [ 2 | 'info', 3 | 'warn', 4 | 'error' 5 | ]; 6 | 7 | function Logger() { 8 | var defaultMethods = Object.create(null); 9 | SUPPORTED_METHODS.forEach(function(method) { 10 | // Deal with Chrome issue: https://bugs.chromium.org/p/chromium/issues/detail?id=179628 11 | defaultMethods[method] = console[method].bind(console); 12 | }); 13 | this.setMethods(defaultMethods); 14 | } 15 | module.exports = Logger; 16 | 17 | Logger.prototype.setMethods = function(overrides) { 18 | overrides = overrides || {}; 19 | var logger = this; 20 | 21 | SUPPORTED_METHODS.forEach(function(method) { 22 | if (typeof overrides[method] === 'function') { 23 | logger[method] = overrides[method]; 24 | } 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/message-actions.js: -------------------------------------------------------------------------------- 1 | exports.ACTIONS = { 2 | initLegacy: 'init', 3 | handshake: 'hs', 4 | queryFetch: 'qf', 5 | querySubscribe: 'qs', 6 | queryUnsubscribe: 'qu', 7 | queryUpdate: 'q', 8 | bulkFetch: 'bf', 9 | bulkSubscribe: 'bs', 10 | bulkUnsubscribe: 'bu', 11 | fetch: 'f', 12 | fixup: 'fixup', 13 | subscribe: 's', 14 | unsubscribe: 'u', 15 | op: 'op', 16 | snapshotFetch: 'nf', 17 | snapshotFetchByTimestamp: 'nt', 18 | pingPong: 'pp', 19 | presence: 'p', 20 | presenceSubscribe: 'ps', 21 | presenceUnsubscribe: 'pu', 22 | presenceRequest: 'pr' 23 | }; 24 | -------------------------------------------------------------------------------- /lib/milestone-db/index.js: -------------------------------------------------------------------------------- 1 | var emitter = require('../emitter'); 2 | var ShareDBError = require('../error'); 3 | var util = require('../util'); 4 | 5 | var ERROR_CODE = ShareDBError.CODES; 6 | 7 | module.exports = MilestoneDB; 8 | function MilestoneDB(options) { 9 | emitter.EventEmitter.call(this); 10 | 11 | // The interval at which milestone snapshots should be saved 12 | this.interval = options && options.interval; 13 | } 14 | emitter.mixin(MilestoneDB); 15 | 16 | MilestoneDB.prototype.close = function(callback) { 17 | if (callback) util.nextTick(callback); 18 | }; 19 | 20 | /** 21 | * Fetch a milestone snapshot from the database 22 | * @param {string} collection - name of the snapshot's collection 23 | * @param {string} id - ID of the snapshot to fetch 24 | * @param {number} version - the desired version of the milestone snapshot. The database will return 25 | * the most recent milestone snapshot whose version is equal to or less than the provided value 26 | * @param {Function} callback - a callback to invoke once the snapshot has been fetched. Should have 27 | * the signature (error, snapshot) => void; 28 | */ 29 | MilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { 30 | var error = new ShareDBError( 31 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 32 | 'getMilestoneSnapshot MilestoneDB method unimplemented' 33 | ); 34 | this._callBackOrEmitError(error, callback); 35 | }; 36 | 37 | /** 38 | * @param {string} collection - name of the snapshot's collection 39 | * @param {Snapshot} snapshot - the milestone snapshot to save 40 | * @param {Function} callback (optional) - a callback to invoke after the snapshot has been saved. 41 | * Should have the signature (error) => void; 42 | */ 43 | MilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { 44 | var error = new ShareDBError( 45 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 46 | 'saveMilestoneSnapshot MilestoneDB method unimplemented' 47 | ); 48 | this._callBackOrEmitError(error, callback); 49 | }; 50 | 51 | MilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { 52 | var error = new ShareDBError( 53 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 54 | 'getMilestoneSnapshotAtOrBeforeTime MilestoneDB method unimplemented' 55 | ); 56 | this._callBackOrEmitError(error, callback); 57 | }; 58 | 59 | MilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { 60 | var error = new ShareDBError( 61 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 62 | 'getMilestoneSnapshotAtOrAfterTime MilestoneDB method unimplemented' 63 | ); 64 | this._callBackOrEmitError(error, callback); 65 | }; 66 | 67 | MilestoneDB.prototype._isValidVersion = function(version) { 68 | return util.isValidVersion(version); 69 | }; 70 | 71 | MilestoneDB.prototype._isValidTimestamp = function(timestamp) { 72 | return util.isValidTimestamp(timestamp); 73 | }; 74 | 75 | MilestoneDB.prototype._callBackOrEmitError = function(error, callback) { 76 | if (callback) return util.nextTick(callback, error); 77 | this.emit('error', error); 78 | }; 79 | -------------------------------------------------------------------------------- /lib/milestone-db/memory.js: -------------------------------------------------------------------------------- 1 | var MilestoneDB = require('./index'); 2 | var ShareDBError = require('../error'); 3 | var util = require('../util'); 4 | 5 | var ERROR_CODE = ShareDBError.CODES; 6 | 7 | /** 8 | * In-memory ShareDB milestone database 9 | * 10 | * Milestone snapshots exist to speed up Backend.fetchSnapshot by providing milestones 11 | * on top of which fewer ops can be applied to reach a desired version of the document. 12 | * This very concept relies on persistence, which means that an in-memory database like 13 | * this is in no way appropriate for production use. 14 | * 15 | * The main purpose of this class is to provide a simple example of implementation, 16 | * and for use in tests. 17 | */ 18 | module.exports = MemoryMilestoneDB; 19 | function MemoryMilestoneDB(options) { 20 | MilestoneDB.call(this, options); 21 | 22 | // Map from collection name -> doc id -> array of milestone snapshots 23 | this._milestoneSnapshots = Object.create(null); 24 | } 25 | 26 | MemoryMilestoneDB.prototype = Object.create(MilestoneDB.prototype); 27 | 28 | MemoryMilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { 29 | if (!this._isValidVersion(version)) { 30 | return util.nextTick(callback, new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Invalid version')); 31 | } 32 | 33 | var predicate = versionLessThanOrEqualTo(version); 34 | this._findMilestoneSnapshot(collection, id, predicate, callback); 35 | }; 36 | 37 | MemoryMilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { 38 | callback = callback || function(error) { 39 | if (error) return this.emit('error', error); 40 | this.emit('save', collection, snapshot); 41 | }.bind(this); 42 | 43 | if (!collection) return callback(new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Missing collection')); 44 | if (!snapshot) return callback(new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Missing snapshot')); 45 | 46 | var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, snapshot.id); 47 | milestoneSnapshots.push(snapshot); 48 | milestoneSnapshots.sort(function(a, b) { 49 | return a.v - b.v; 50 | }); 51 | 52 | util.nextTick(callback, null); 53 | }; 54 | 55 | MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { 56 | if (!this._isValidTimestamp(timestamp)) { 57 | return util.nextTick(callback, new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Invalid timestamp')); 58 | } 59 | 60 | var filter = timestampLessThanOrEqualTo(timestamp); 61 | this._findMilestoneSnapshot(collection, id, filter, callback); 62 | }; 63 | 64 | MemoryMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { 65 | if (!this._isValidTimestamp(timestamp)) { 66 | return util.nextTick(callback, new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Invalid timestamp')); 67 | } 68 | 69 | var filter = timestampGreaterThanOrEqualTo(timestamp); 70 | this._findMilestoneSnapshot(collection, id, filter, function(error, snapshot) { 71 | if (error) return util.nextTick(callback, error); 72 | 73 | var mtime = snapshot && snapshot.m && snapshot.m.mtime; 74 | if (timestamp !== null && mtime < timestamp) { 75 | snapshot = undefined; 76 | } 77 | 78 | util.nextTick(callback, null, snapshot); 79 | }); 80 | }; 81 | 82 | MemoryMilestoneDB.prototype._findMilestoneSnapshot = function(collection, id, breakCondition, callback) { 83 | if (!collection) { 84 | return util.nextTick( 85 | callback, new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Missing collection') 86 | ); 87 | } 88 | if (!id) return util.nextTick(callback, new ShareDBError(ERROR_CODE.ERR_MILESTONE_ARGUMENT_INVALID, 'Missing ID')); 89 | 90 | var milestoneSnapshots = this._getMilestoneSnapshotsSync(collection, id); 91 | 92 | var milestoneSnapshot; 93 | for (var i = 0; i < milestoneSnapshots.length; i++) { 94 | var nextMilestoneSnapshot = milestoneSnapshots[i]; 95 | if (breakCondition(milestoneSnapshot, nextMilestoneSnapshot)) { 96 | break; 97 | } else { 98 | milestoneSnapshot = nextMilestoneSnapshot; 99 | } 100 | } 101 | 102 | util.nextTick(callback, null, milestoneSnapshot); 103 | }; 104 | 105 | MemoryMilestoneDB.prototype._getMilestoneSnapshotsSync = function(collection, id) { 106 | var collectionSnapshots = this._milestoneSnapshots[collection] || 107 | (this._milestoneSnapshots[collection] = Object.create(null)); 108 | return collectionSnapshots[id] || (collectionSnapshots[id] = []); 109 | }; 110 | 111 | function versionLessThanOrEqualTo(version) { 112 | return function(currentSnapshot, nextSnapshot) { 113 | if (version === null) { 114 | return false; 115 | } 116 | 117 | return nextSnapshot.v > version; 118 | }; 119 | } 120 | 121 | function timestampGreaterThanOrEqualTo(timestamp) { 122 | return function(currentSnapshot) { 123 | if (timestamp === null) { 124 | return false; 125 | } 126 | 127 | var mtime = currentSnapshot && currentSnapshot.m && currentSnapshot.m.mtime; 128 | return mtime >= timestamp; 129 | }; 130 | } 131 | 132 | function timestampLessThanOrEqualTo(timestamp) { 133 | return function(currentSnapshot, nextSnapshot) { 134 | if (timestamp === null) { 135 | return !!currentSnapshot; 136 | } 137 | 138 | var mtime = nextSnapshot && nextSnapshot.m && nextSnapshot.m.mtime; 139 | return mtime > timestamp; 140 | }; 141 | } 142 | -------------------------------------------------------------------------------- /lib/milestone-db/no-op.js: -------------------------------------------------------------------------------- 1 | var MilestoneDB = require('./index'); 2 | var util = require('../util'); 3 | 4 | /** 5 | * A no-op implementation of the MilestoneDB class. 6 | * 7 | * This class exists as a simple, silent default drop-in for ShareDB, which allows the backend to call its methods with 8 | * no effect. 9 | */ 10 | module.exports = NoOpMilestoneDB; 11 | function NoOpMilestoneDB(options) { 12 | MilestoneDB.call(this, options); 13 | } 14 | 15 | NoOpMilestoneDB.prototype = Object.create(MilestoneDB.prototype); 16 | 17 | NoOpMilestoneDB.prototype.getMilestoneSnapshot = function(collection, id, version, callback) { 18 | var snapshot = undefined; 19 | util.nextTick(callback, null, snapshot); 20 | }; 21 | 22 | NoOpMilestoneDB.prototype.saveMilestoneSnapshot = function(collection, snapshot, callback) { 23 | if (callback) return util.nextTick(callback, null); 24 | this.emit('save', collection, snapshot); 25 | }; 26 | 27 | NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrBeforeTime = function(collection, id, timestamp, callback) { 28 | var snapshot = undefined; 29 | util.nextTick(callback, null, snapshot); 30 | }; 31 | 32 | NoOpMilestoneDB.prototype.getMilestoneSnapshotAtOrAfterTime = function(collection, id, timestamp, callback) { 33 | var snapshot = undefined; 34 | util.nextTick(callback, null, snapshot); 35 | }; 36 | -------------------------------------------------------------------------------- /lib/next-tick.js: -------------------------------------------------------------------------------- 1 | exports.messageChannel = function() { 2 | var triggerCallback = createNextTickTrigger(arguments); 3 | var channel = new MessageChannel(); 4 | channel.port1.onmessage = function() { 5 | triggerCallback(); 6 | channel.port1.close(); 7 | }; 8 | channel.port2.postMessage(''); 9 | }; 10 | 11 | exports.setTimeout = function() { 12 | var triggerCallback = createNextTickTrigger(arguments); 13 | setTimeout(triggerCallback); 14 | }; 15 | 16 | function createNextTickTrigger(args) { 17 | var callback = args[0]; 18 | var _args = []; 19 | for (var i = 1; i < args.length; i++) { 20 | _args[i - 1] = args[i]; 21 | } 22 | 23 | return function triggerCallback() { 24 | callback.apply(null, _args); 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /lib/op-stream.js: -------------------------------------------------------------------------------- 1 | var Readable = require('stream').Readable; 2 | var util = require('./util'); 3 | 4 | // Stream of operations. Subscribe returns one of these 5 | function OpStream() { 6 | Readable.call(this, {objectMode: true}); 7 | this.id = null; 8 | this.open = true; 9 | } 10 | module.exports = OpStream; 11 | 12 | util.inherits(OpStream, Readable); 13 | 14 | // This function is for notifying us that the stream is empty and needs data. 15 | // For now, we'll just ignore the signal and assume the reader reads as fast 16 | // as we fill it. I could add a buffer in this function, but really I don't 17 | // think that is any better than the buffer implementation in nodejs streams 18 | // themselves. 19 | OpStream.prototype._read = util.doNothing; 20 | 21 | OpStream.prototype.pushData = function(data) { 22 | // Ignore any messages after unsubscribe 23 | if (!this.open) return; 24 | // This data gets consumed in Agent#_subscribeToStream 25 | this.push(data); 26 | }; 27 | 28 | OpStream.prototype.destroy = function() { 29 | // Only close stream once 30 | if (!this.open) return; 31 | this.open = false; 32 | 33 | this.push(null); 34 | this.emit('close'); 35 | }; 36 | -------------------------------------------------------------------------------- /lib/projections.js: -------------------------------------------------------------------------------- 1 | var json0 = require('ot-json0').type; 2 | var ShareDBError = require('./error'); 3 | var util = require('./util'); 4 | 5 | var ERROR_CODE = ShareDBError.CODES; 6 | 7 | exports.projectSnapshot = projectSnapshot; 8 | exports.projectSnapshots = projectSnapshots; 9 | exports.projectOp = projectOp; 10 | exports.isSnapshotAllowed = isSnapshotAllowed; 11 | exports.isOpAllowed = isOpAllowed; 12 | 13 | 14 | // Project a snapshot in place to only include specified fields 15 | function projectSnapshot(fields, snapshot) { 16 | // Only json0 supported right now 17 | if (snapshot.type && snapshot.type !== json0.uri) { 18 | throw new Error(ERROR_CODE.ERR_TYPE_CANNOT_BE_PROJECTED, 'Cannot project snapshots of type ' + snapshot.type); 19 | } 20 | snapshot.data = projectData(fields, snapshot.data); 21 | } 22 | 23 | function projectSnapshots(fields, snapshots) { 24 | for (var i = 0; i < snapshots.length; i++) { 25 | var snapshot = snapshots[i]; 26 | projectSnapshot(fields, snapshot); 27 | } 28 | } 29 | 30 | function projectOp(fields, op) { 31 | if (op.create) { 32 | projectSnapshot(fields, op.create); 33 | } 34 | if ('op' in op) { 35 | op.op = projectEdit(fields, op.op); 36 | } 37 | } 38 | 39 | function projectEdit(fields, op) { 40 | // So, we know the op is a JSON op 41 | var result = []; 42 | 43 | for (var i = 0; i < op.length; i++) { 44 | var c = op[i]; 45 | var path = c.p; 46 | 47 | if (path.length === 0) { 48 | var newC = {p: []}; 49 | 50 | if (c.od !== undefined || c.oi !== undefined) { 51 | if (c.od !== undefined) { 52 | newC.od = projectData(fields, c.od); 53 | } 54 | if (c.oi !== undefined) { 55 | newC.oi = projectData(fields, c.oi); 56 | } 57 | result.push(newC); 58 | } 59 | } else { 60 | // The path has a first element. Just check it against the fields. 61 | if (fields[path[0]]) { 62 | result.push(c); 63 | } 64 | } 65 | } 66 | return result; 67 | } 68 | 69 | function isOpAllowed(knownType, fields, op) { 70 | if (op.create) { 71 | return isSnapshotAllowed(fields, op.create); 72 | } 73 | if ('op' in op) { 74 | if (knownType && knownType !== json0.uri) return false; 75 | return isEditAllowed(fields, op.op); 76 | } 77 | // Noop and del are both ok. 78 | return true; 79 | } 80 | 81 | // Basically, would the projected version of this data be the same as the original? 82 | function isSnapshotAllowed(fields, snapshot) { 83 | if (snapshot.type && snapshot.type !== json0.uri) { 84 | return false; 85 | } 86 | if (snapshot.data == null) { 87 | return true; 88 | } 89 | // Data must be an object if not null 90 | if (typeof snapshot.data !== 'object' || Array.isArray(snapshot.data)) { 91 | return false; 92 | } 93 | for (var k in snapshot.data) { 94 | if (!fields[k]) return false; 95 | } 96 | return true; 97 | } 98 | 99 | function isEditAllowed(fields, op) { 100 | for (var i = 0; i < op.length; i++) { 101 | var c = op[i]; 102 | if (c.p.length === 0) { 103 | return false; 104 | } else if (!fields[c.p[0]]) { 105 | return false; 106 | } 107 | } 108 | return true; 109 | } 110 | 111 | function projectData(fields, data) { 112 | // Return back null or undefined 113 | if (data == null) { 114 | return data; 115 | } 116 | // If data is not an object, the projected version just looks like null. 117 | if (typeof data !== 'object' || Array.isArray(data)) { 118 | return null; 119 | } 120 | // Shallow copy of each field 121 | var result = {}; 122 | for (var key in fields) { 123 | if (util.hasOwn(data, key)) { 124 | result[key] = data[key]; 125 | } 126 | } 127 | return result; 128 | } 129 | -------------------------------------------------------------------------------- /lib/protocol.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | major: 1, 3 | minor: 2, 4 | checkAtLeast: checkAtLeast 5 | }; 6 | 7 | function checkAtLeast(toCheck, checkAgainst) { 8 | toCheck = normalizedProtocol(toCheck); 9 | checkAgainst = normalizedProtocol(checkAgainst); 10 | if (toCheck.major > checkAgainst.major) return true; 11 | return toCheck.major === checkAgainst.major && 12 | toCheck.minor >= checkAgainst.minor; 13 | } 14 | 15 | function normalizedProtocol(protocol) { 16 | if (typeof protocol === 'string') { 17 | var segments = protocol.split('.'); 18 | protocol = { 19 | major: segments[0], 20 | minor: segments[1] 21 | }; 22 | } 23 | 24 | return { 25 | major: +(protocol.protocol || protocol.major || 0), 26 | minor: +(protocol.protocolMinor || protocol.minor || 0) 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /lib/pubsub/index.js: -------------------------------------------------------------------------------- 1 | var emitter = require('../emitter'); 2 | var OpStream = require('../op-stream'); 3 | var ShareDBError = require('../error'); 4 | var util = require('../util'); 5 | 6 | var ERROR_CODE = ShareDBError.CODES; 7 | 8 | function PubSub(options) { 9 | if (!(this instanceof PubSub)) return new PubSub(options); 10 | emitter.EventEmitter.call(this); 11 | 12 | this.prefix = options && options.prefix; 13 | this.nextStreamId = 1; 14 | this.streamsCount = 0; 15 | // Maps channel -> id -> stream 16 | this.streams = Object.create(null); 17 | // State for tracking subscriptions. We track this.subscribed separately from 18 | // the streams, since the stream gets added synchronously, and the subscribe 19 | // isn't complete until the callback returns from Redis 20 | // Maps channel -> true 21 | this.subscribed = Object.create(null); 22 | 23 | var pubsub = this; 24 | this._defaultCallback = function(err) { 25 | if (err) return pubsub.emit('error', err); 26 | }; 27 | } 28 | module.exports = PubSub; 29 | emitter.mixin(PubSub); 30 | 31 | PubSub.prototype.close = function(callback) { 32 | for (var channel in this.streams) { 33 | var map = this.streams[channel]; 34 | for (var id in map) { 35 | map[id].destroy(); 36 | } 37 | } 38 | if (callback) util.nextTick(callback); 39 | }; 40 | 41 | PubSub.prototype._subscribe = function(channel, callback) { 42 | util.nextTick(function() { 43 | callback(new ShareDBError( 44 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 45 | '_subscribe PubSub method unimplemented' 46 | )); 47 | }); 48 | }; 49 | 50 | PubSub.prototype._unsubscribe = function(channel, callback) { 51 | util.nextTick(function() { 52 | callback(new ShareDBError( 53 | ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, 54 | '_unsubscribe PubSub method unimplemented' 55 | )); 56 | }); 57 | }; 58 | 59 | PubSub.prototype._publish = function(channels, data, callback) { 60 | util.nextTick(function() { 61 | callback(new ShareDBError(ERROR_CODE.ERR_DATABASE_METHOD_NOT_IMPLEMENTED, '_publish PubSub method unimplemented')); 62 | }); 63 | }; 64 | 65 | PubSub.prototype.subscribe = function(channel, callback) { 66 | if (!callback) callback = this._defaultCallback; 67 | if (this.prefix) { 68 | channel = this.prefix + ' ' + channel; 69 | } 70 | 71 | var pubsub = this; 72 | if (this.subscribed[channel]) { 73 | util.nextTick(function() { 74 | var stream = pubsub._createStream(channel); 75 | callback(null, stream); 76 | }); 77 | return; 78 | } 79 | this._subscribe(channel, function(err) { 80 | if (err) return callback(err); 81 | pubsub.subscribed[channel] = true; 82 | var stream = pubsub._createStream(channel); 83 | callback(null, stream); 84 | }); 85 | }; 86 | 87 | PubSub.prototype.publish = function(channels, data, callback) { 88 | if (!callback) callback = this._defaultCallback; 89 | if (this.prefix) { 90 | for (var i = 0; i < channels.length; i++) { 91 | channels[i] = this.prefix + ' ' + channels[i]; 92 | } 93 | } 94 | this._publish(channels, data, callback); 95 | }; 96 | 97 | PubSub.prototype._emit = function(channel, data) { 98 | var channelStreams = this.streams[channel]; 99 | if (channelStreams) { 100 | for (var id in channelStreams) { 101 | channelStreams[id].pushData(data); 102 | } 103 | } 104 | }; 105 | 106 | PubSub.prototype._createStream = function(channel) { 107 | var stream = new OpStream(); 108 | var pubsub = this; 109 | stream.once('close', function() { 110 | pubsub._removeStream(channel, stream); 111 | }); 112 | 113 | this.streamsCount++; 114 | var map = this.streams[channel] || (this.streams[channel] = Object.create(null)); 115 | stream.id = this.nextStreamId++; 116 | map[stream.id] = stream; 117 | 118 | return stream; 119 | }; 120 | 121 | PubSub.prototype._removeStream = function(channel, stream) { 122 | var map = this.streams[channel]; 123 | if (!map) return; 124 | 125 | this.streamsCount--; 126 | delete map[stream.id]; 127 | 128 | // Cleanup if this was the last subscribed stream for the channel 129 | if (util.hasKeys(map)) return; 130 | delete this.streams[channel]; 131 | // Synchronously clear subscribed state. We won't actually be unsubscribed 132 | // until some unknown time in the future. If subscribe is called in this 133 | // period, we want to send a subscription message and wait for it to 134 | // complete before we can count on being subscribed again 135 | delete this.subscribed[channel]; 136 | 137 | this._unsubscribe(channel, this._defaultCallback); 138 | }; 139 | -------------------------------------------------------------------------------- /lib/pubsub/memory.js: -------------------------------------------------------------------------------- 1 | var PubSub = require('./index'); 2 | var util = require('../util'); 3 | 4 | // In-memory ShareDB pub/sub 5 | // 6 | // This is a fully functional implementation. Since ShareDB does not require 7 | // persistence of pub/sub state, it may be used in production environments 8 | // requiring only a single stand alone server process. Additionally, it is 9 | // easy to swap in an external pub/sub adapter if/when additional server 10 | // processes are desired. No pub/sub APIs are adapter specific. 11 | 12 | function MemoryPubSub(options) { 13 | if (!(this instanceof MemoryPubSub)) return new MemoryPubSub(options); 14 | PubSub.call(this, options); 15 | } 16 | module.exports = MemoryPubSub; 17 | 18 | MemoryPubSub.prototype = Object.create(PubSub.prototype); 19 | 20 | MemoryPubSub.prototype._subscribe = function(channel, callback) { 21 | util.nextTick(callback); 22 | }; 23 | 24 | MemoryPubSub.prototype._unsubscribe = function(channel, callback) { 25 | util.nextTick(callback); 26 | }; 27 | 28 | MemoryPubSub.prototype._publish = function(channels, data, callback) { 29 | var pubsub = this; 30 | util.nextTick(function() { 31 | for (var i = 0; i < channels.length; i++) { 32 | var channel = channels[i]; 33 | if (pubsub.subscribed[channel]) { 34 | pubsub._emit(channel, data); 35 | } 36 | } 37 | callback(); 38 | }); 39 | }; 40 | -------------------------------------------------------------------------------- /lib/read-snapshots-request.js: -------------------------------------------------------------------------------- 1 | var ShareDBError = require('./error'); 2 | 3 | module.exports = ReadSnapshotsRequest; 4 | 5 | /** 6 | * Context object passed to "readSnapshots" middleware functions 7 | * 8 | * @param {string} collection 9 | * @param {Snapshot[]} snapshots - snapshots being read 10 | * @param {keyof Backend.prototype.SNAPSHOT_TYPES} snapshotType - the type of snapshot read being 11 | * performed 12 | */ 13 | function ReadSnapshotsRequest(collection, snapshots, snapshotType) { 14 | this.collection = collection; 15 | this.snapshots = snapshots; 16 | this.snapshotType = snapshotType; 17 | 18 | // Added by Backend#trigger 19 | this.action = null; 20 | this.agent = null; 21 | this.backend = null; 22 | 23 | /** 24 | * Map of doc id to error: `{[docId: string]: string | Error}` 25 | */ 26 | this._idToError = null; 27 | } 28 | 29 | /** 30 | * Rejects the read of a specific snapshot. A rejected snapshot read will not have that snapshot's 31 | * data sent down to the client. 32 | * 33 | * If the error has a `code` property of `"ERR_SNAPSHOT_READ_SILENT_REJECTION"`, then the Share 34 | * client will not pass the error to user code, but will still do things like cancel subscriptions. 35 | * The `#rejectSnapshotReadSilent(snapshot, errorMessage)` method can also be used for convenience. 36 | * 37 | * @param {Snapshot} snapshot 38 | * @param {string | Error} error 39 | * 40 | * @see #rejectSnapshotReadSilent 41 | * @see ShareDBError.CODES.ERR_SNAPSHOT_READ_SILENT_REJECTION 42 | * @see ShareDBError.CODES.ERR_SNAPSHOT_READS_REJECTED 43 | */ 44 | ReadSnapshotsRequest.prototype.rejectSnapshotRead = function(snapshot, error) { 45 | if (!this._idToError) { 46 | this._idToError = Object.create(null); 47 | } 48 | this._idToError[snapshot.id] = error; 49 | }; 50 | 51 | /** 52 | * Rejects the read of a specific snapshot. A rejected snapshot read will not have that snapshot's 53 | * data sent down to the client. 54 | * 55 | * This method will set a special error code that causes the Share client to not pass the error to 56 | * user code, though it will still do things like cancel subscriptions. 57 | * 58 | * @param {Snapshot} snapshot 59 | * @param {string} errorMessage 60 | */ 61 | ReadSnapshotsRequest.prototype.rejectSnapshotReadSilent = function(snapshot, errorMessage) { 62 | this.rejectSnapshotRead(snapshot, this.silentRejectionError(errorMessage)); 63 | }; 64 | 65 | ReadSnapshotsRequest.prototype.silentRejectionError = function(errorMessage) { 66 | return new ShareDBError(ShareDBError.CODES.ERR_SNAPSHOT_READ_SILENT_REJECTION, errorMessage); 67 | }; 68 | 69 | /** 70 | * Returns whether this trigger of "readSnapshots" has had a snapshot read rejected. 71 | */ 72 | ReadSnapshotsRequest.prototype.hasSnapshotRejection = function() { 73 | return this._idToError != null; 74 | }; 75 | 76 | /** 77 | * Returns an overall error from "readSnapshots" based on the snapshot-specific errors. 78 | * 79 | * - If there's exactly one snapshot and it has an error, then that error is returned. 80 | * - If there's more than one snapshot and at least one has an error, then an overall 81 | * "ERR_SNAPSHOT_READS_REJECTED" is returned, with an `idToError` property. 82 | */ 83 | ReadSnapshotsRequest.prototype.getReadSnapshotsError = function() { 84 | var snapshots = this.snapshots; 85 | var idToError = this._idToError; 86 | // If there are 0 snapshots, there can't be any snapshot-specific errors. 87 | if (snapshots.length === 0) { 88 | return; 89 | } 90 | 91 | // Single snapshot with error is treated as a full error. 92 | if (snapshots.length === 1) { 93 | var snapshotError = idToError[snapshots[0].id]; 94 | if (snapshotError) { 95 | return snapshotError; 96 | } else { 97 | return; 98 | } 99 | } 100 | 101 | // Errors in specific snapshots result in an overall ERR_SNAPSHOT_READS_REJECTED. 102 | // 103 | // fetchBulk and subscribeBulk know how to handle that special error by sending a doc-by-doc 104 | // success/failure to the client. Other methods that don't or can't handle partial failures 105 | // will treat it as a full rejection. 106 | var err = new ShareDBError(ShareDBError.CODES.ERR_SNAPSHOT_READS_REJECTED); 107 | err.idToError = idToError; 108 | return err; 109 | }; 110 | -------------------------------------------------------------------------------- /lib/snapshot.js: -------------------------------------------------------------------------------- 1 | module.exports = Snapshot; 2 | function Snapshot(id, version, type, data, meta) { 3 | this.id = id; 4 | this.v = version; 5 | this.type = type; 6 | this.data = data; 7 | this.m = meta; 8 | } 9 | -------------------------------------------------------------------------------- /lib/stream-socket.js: -------------------------------------------------------------------------------- 1 | var Duplex = require('stream').Duplex; 2 | var logger = require('./logger'); 3 | var util = require('./util'); 4 | 5 | function StreamSocket() { 6 | this.readyState = 0; 7 | this.stream = new ServerStream(this); 8 | } 9 | module.exports = StreamSocket; 10 | 11 | StreamSocket.prototype._open = function() { 12 | if (this.readyState !== 0) return; 13 | this.readyState = 1; 14 | this.onopen(); 15 | }; 16 | StreamSocket.prototype.close = function(reason) { 17 | if (this.readyState === 3) return; 18 | this.readyState = 3; 19 | // Signal data writing is complete. Emits the 'end' event 20 | this.stream.push(null); 21 | this.onclose(reason || 'closed'); 22 | }; 23 | StreamSocket.prototype.send = function(data) { 24 | // Data is an object 25 | this.stream.push(JSON.parse(data)); 26 | }; 27 | StreamSocket.prototype.onmessage = util.doNothing; 28 | StreamSocket.prototype.onclose = util.doNothing; 29 | StreamSocket.prototype.onerror = util.doNothing; 30 | StreamSocket.prototype.onopen = util.doNothing; 31 | 32 | 33 | function ServerStream(socket) { 34 | Duplex.call(this, {objectMode: true}); 35 | 36 | this.socket = socket; 37 | 38 | this.on('error', function(error) { 39 | logger.warn('ShareDB client message stream error', error); 40 | socket.close('stopped'); 41 | }); 42 | 43 | // The server ended the writable stream. Triggered by calling stream.end() 44 | // in agent.close() 45 | this.on('finish', function() { 46 | socket.close('stopped'); 47 | }); 48 | } 49 | util.inherits(ServerStream, Duplex); 50 | 51 | ServerStream.prototype.isServer = true; 52 | 53 | ServerStream.prototype._read = util.doNothing; 54 | 55 | ServerStream.prototype._write = function(chunk, encoding, callback) { 56 | var socket = this.socket; 57 | util.nextTick(function() { 58 | if (socket.readyState !== 1) return; 59 | socket.onmessage({data: JSON.stringify(chunk)}); 60 | callback(); 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /lib/types.js: -------------------------------------------------------------------------------- 1 | 2 | exports.defaultType = require('ot-json0').type; 3 | 4 | exports.map = Object.create(null); 5 | 6 | exports.register = function(type) { 7 | if (type.name) exports.map[type.name] = type; 8 | if (type.uri) exports.map[type.uri] = type; 9 | }; 10 | 11 | exports.register(exports.defaultType); 12 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | var nextTickImpl = require('./next-tick'); 2 | 3 | exports.doNothing = doNothing; 4 | function doNothing() {} 5 | 6 | exports.hasKeys = function(object) { 7 | for (var key in object) return true; 8 | return false; 9 | }; 10 | 11 | var hasOwn; 12 | exports.hasOwn = hasOwn = Object.hasOwn || function(obj, prop) { 13 | return Object.prototype.hasOwnProperty.call(obj, prop); 14 | }; 15 | 16 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill 17 | exports.isInteger = Number.isInteger || function(value) { 18 | return typeof value === 'number' && 19 | isFinite(value) && 20 | Math.floor(value) === value; 21 | }; 22 | 23 | exports.isValidVersion = function(version) { 24 | if (version === null) return true; 25 | return exports.isInteger(version) && version >= 0; 26 | }; 27 | 28 | exports.isValidTimestamp = function(timestamp) { 29 | return exports.isValidVersion(timestamp); 30 | }; 31 | 32 | exports.MAX_SAFE_INTEGER = 9007199254740991; 33 | 34 | exports.dig = function() { 35 | var obj = arguments[0]; 36 | for (var i = 1; i < arguments.length; i++) { 37 | var key = arguments[i]; 38 | obj = hasOwn(obj, key) ? obj[key] : (i === arguments.length - 1 ? undefined : Object.create(null)); 39 | } 40 | return obj; 41 | }; 42 | 43 | exports.digOrCreate = function() { 44 | var obj = arguments[0]; 45 | var createCallback = arguments[arguments.length - 1]; 46 | for (var i = 1; i < arguments.length - 1; i++) { 47 | var key = arguments[i]; 48 | obj = hasOwn(obj, key) ? obj[key] : 49 | (obj[key] = i === arguments.length - 2 ? createCallback() : Object.create(null)); 50 | } 51 | return obj; 52 | }; 53 | 54 | exports.digAndRemove = function() { 55 | var obj = arguments[0]; 56 | var objects = [obj]; 57 | for (var i = 1; i < arguments.length - 1; i++) { 58 | var key = arguments[i]; 59 | if (!hasOwn(obj, key)) break; 60 | obj = obj[key]; 61 | objects.push(obj); 62 | }; 63 | 64 | for (var i = objects.length - 1; i >= 0; i--) { 65 | var parent = objects[i]; 66 | var key = arguments[i + 1]; 67 | var child = parent[key]; 68 | if (i === objects.length - 1 || !exports.hasKeys(child)) delete parent[key]; 69 | } 70 | }; 71 | 72 | exports.supportsPresence = function(type) { 73 | return type && typeof type.transformPresence === 'function'; 74 | }; 75 | 76 | exports.callEach = function(callbacks, error) { 77 | var called = false; 78 | callbacks.forEach(function(callback) { 79 | if (callback) { 80 | callback(error); 81 | called = true; 82 | } 83 | }); 84 | return called; 85 | }; 86 | 87 | exports.truthy = function(arg) { 88 | return !!arg; 89 | }; 90 | 91 | if (typeof process !== 'undefined' && typeof process.nextTick === 'function') { 92 | exports.nextTick = process.nextTick; 93 | } else if (typeof MessageChannel !== 'undefined') { 94 | exports.nextTick = nextTickImpl.messageChannel; 95 | } else { 96 | exports.nextTick = nextTickImpl.setTimeout; 97 | } 98 | 99 | exports.clone = function(obj) { 100 | return (obj === undefined) ? undefined : JSON.parse(JSON.stringify(obj)); 101 | }; 102 | 103 | var objectProtoPropNames = Object.create(null); 104 | Object.getOwnPropertyNames(Object.prototype).forEach(function(prop) { 105 | if (prop !== '__proto__') { 106 | objectProtoPropNames[prop] = true; 107 | } 108 | }); 109 | exports.isDangerousProperty = function(propName) { 110 | return propName === '__proto__' || objectProtoPropNames[propName]; 111 | }; 112 | 113 | try { 114 | var util = require('util'); 115 | if (typeof util.inherits !== 'function') throw new Error('Could not find util.inherits()'); 116 | exports.inherits = util.inherits; 117 | } catch (e) { 118 | try { 119 | exports.inherits = require('inherits'); 120 | } catch (e) { 121 | throw new Error('If running sharedb in a browser, please install the "inherits" or "util" package'); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sharedb", 3 | "version": "5.2.2", 4 | "description": "JSON OT database backend", 5 | "main": "lib/index.js", 6 | "dependencies": { 7 | "arraydiff": "^0.1.3", 8 | "async": "^3.2.4", 9 | "fast-deep-equal": "^3.1.3", 10 | "hat": "0.0.3", 11 | "ot-json0": "^1.1.0" 12 | }, 13 | "devDependencies": { 14 | "chai": "^4.3.7", 15 | "coveralls": "^3.1.1", 16 | "eslint": "^8.47.0", 17 | "eslint-config-google": "^0.14.0", 18 | "mocha": "^10.2.0", 19 | "nyc": "^15.1.0", 20 | "ot-json0-v2": "https://github.com/ottypes/json0#90a3ae26364c4fa3b19b6df34dad46707a704421", 21 | "ot-json1": "^1.0.2", 22 | "rich-text": "^4.1.0", 23 | "sharedb-legacy": "npm:sharedb@1.1.0", 24 | "sinon": "^15.2.0", 25 | "sinon-chai": "^3.7.0" 26 | }, 27 | "files": [ 28 | "lib/", 29 | "test/" 30 | ], 31 | "scripts": { 32 | "docs:install": "cd docs && bundle install", 33 | "docs:build": "cd docs && bundle exec jekyll build", 34 | "docs:start": "cd docs && bundle exec jekyll serve --livereload", 35 | "test": "mocha", 36 | "test-cover": "nyc --temp-dir=coverage -r text -r lcov npm test", 37 | "lint": "./node_modules/.bin/eslint --ignore-path .gitignore '**/*.js'", 38 | "lint:fix": "npm run lint -- --fix" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git://github.com/share/sharedb.git" 43 | }, 44 | "author": "Nate Smith and Joseph Gentle", 45 | "license": "MIT" 46 | } 47 | -------------------------------------------------------------------------------- /scripts/test-sharedb-mongo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd ../ 4 | git clone https://github.com/share/sharedb-mongo.git 5 | cd sharedb-mongo 6 | npm install ../sharedb 7 | npm install 8 | npm test 9 | -------------------------------------------------------------------------------- /test/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "laxcomma": true, 4 | "eqnull": true, 5 | "eqeqeq": true, 6 | "indent": 2, 7 | "newcap": true, 8 | "quotmark": "single", 9 | "undef": true, 10 | "trailing": true, 11 | "shadow": true, 12 | "expr": true, 13 | "boss": true, 14 | "globals": { 15 | "describe": false, 16 | "it": false, 17 | "before": false, 18 | "after": false, 19 | "beforeEach": false, 20 | "afterEach": false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/BasicQueryableMemoryDB.js: -------------------------------------------------------------------------------- 1 | var MemoryDB = require('../lib/db/memory'); 2 | 3 | module.exports = BasicQueryableMemoryDB; 4 | 5 | // Extension of MemoryDB that supports query filters and sorts on simple 6 | // top-level properties, which is enough for the core ShareDB tests on 7 | // query subscription updating. 8 | function BasicQueryableMemoryDB() { 9 | MemoryDB.apply(this, arguments); 10 | } 11 | BasicQueryableMemoryDB.prototype = Object.create(MemoryDB.prototype); 12 | BasicQueryableMemoryDB.prototype.constructor = BasicQueryableMemoryDB; 13 | 14 | BasicQueryableMemoryDB.prototype._querySync = function(snapshots, query) { 15 | if (query.filter) { 16 | snapshots = snapshots.filter(function(snapshot) { 17 | return querySnapshot(snapshot, query); 18 | }); 19 | } 20 | 21 | if (query.sort) { 22 | if (!Array.isArray(query.sort)) { 23 | throw new Error('query.sort must be an array'); 24 | } 25 | if (query.sort.length) { 26 | snapshots.sort(snapshotComparator(query.sort)); 27 | } 28 | } 29 | 30 | return {snapshots: snapshots}; 31 | }; 32 | 33 | BasicQueryableMemoryDB.prototype.queryPollDoc = function(collection, id, query, options, callback) { 34 | var db = this; 35 | process.nextTick(function() { 36 | var snapshot = db._getSnapshotSync(collection, id); 37 | try { 38 | var matches = querySnapshot(snapshot, query); 39 | } catch (err) { 40 | return callback(err); 41 | } 42 | callback(null, matches); 43 | }); 44 | }; 45 | 46 | BasicQueryableMemoryDB.prototype.canPollDoc = function(collection, query) { 47 | return !query.sort; 48 | }; 49 | 50 | function querySnapshot(snapshot, query) { 51 | // Never match uncreated or deleted snapshots 52 | if (snapshot.type == null) return false; 53 | // Match any snapshot when there is no query filter 54 | if (!query.filter) return true; 55 | // Check that each property in the filter equals the snapshot data 56 | for (var queryKey in query.filter) { 57 | // This fake only supports simple property equality filters, so 58 | // throw an error on Mongo-like filter properties with dots. 59 | if (queryKey.includes('.')) { 60 | throw new Error('Only simple property filters are supported, got:', queryKey); 61 | } 62 | if (snapshot.data[queryKey] !== query.filter[queryKey]) { 63 | return false; 64 | } 65 | } 66 | return true; 67 | } 68 | 69 | // sortProperties is an array whose items are each [propertyName, direction]. 70 | function snapshotComparator(sortProperties) { 71 | return function(snapshotA, snapshotB) { 72 | for (var i = 0; i < sortProperties.length; i++) { 73 | var sortProperty = sortProperties[i]; 74 | var sortKey = sortProperty[0]; 75 | var sortDirection = sortProperty[1]; 76 | 77 | var aPropVal = snapshotA.data[sortKey]; 78 | var bPropVal = snapshotB.data[sortKey]; 79 | if (aPropVal < bPropVal) { 80 | return -1 * sortDirection; 81 | } else if (aPropVal > bPropVal) { 82 | return sortDirection; 83 | } else if (aPropVal === bPropVal) { 84 | continue; 85 | } else { 86 | throw new Error('Could not compare ' + aPropVal + ' and ' + bPropVal); 87 | } 88 | } 89 | return 0; 90 | }; 91 | } 92 | -------------------------------------------------------------------------------- /test/agent.js: -------------------------------------------------------------------------------- 1 | var Backend = require('../lib/backend'); 2 | var logger = require('../lib/logger'); 3 | var sinon = require('sinon'); 4 | var StreamSocket = require('../lib/stream-socket'); 5 | var expect = require('chai').expect; 6 | var ACTIONS = require('../lib/message-actions').ACTIONS; 7 | var Connection = require('../lib/client/connection'); 8 | var protocol = require('../lib/protocol'); 9 | var LegacyConnection = require('sharedb-legacy/lib/client').Connection; 10 | 11 | describe('Agent', function() { 12 | var backend; 13 | 14 | beforeEach(function() { 15 | backend = new Backend(); 16 | }); 17 | 18 | afterEach(function(done) { 19 | backend.close(done); 20 | }); 21 | 22 | describe('handshake', function() { 23 | it('warns when messages are sent before the handshake', function(done) { 24 | var socket = new StreamSocket(); 25 | var stream = socket.stream; 26 | backend.listen(stream); 27 | sinon.spy(logger, 'warn'); 28 | socket.send(JSON.stringify({a: ACTIONS.subscribe, c: 'dogs', d: 'fido'})); 29 | var connection = new Connection(socket); 30 | socket._open(); 31 | connection.once('connected', function() { 32 | expect(logger.warn).to.have.been.calledOnceWithExactly( 33 | 'Unexpected message received before handshake', 34 | {a: ACTIONS.subscribe, c: 'dogs', d: 'fido'} 35 | ); 36 | done(); 37 | }); 38 | }); 39 | 40 | it('does not warn when messages are sent after the handshake', function(done) { 41 | var socket = new StreamSocket(); 42 | var stream = socket.stream; 43 | var agent = backend.listen(stream); 44 | sinon.spy(logger, 'warn'); 45 | var connection = new Connection(socket); 46 | socket._open(); 47 | connection.once('connected', function() { 48 | socket.send(JSON.stringify({a: ACTIONS.subscribe, c: 'dogs', d: 'fido'})); 49 | expect(logger.warn).not.to.have.been.called; 50 | expect(agent._firstReceivedMessage).to.be.null; 51 | done(); 52 | }); 53 | }); 54 | 55 | it('does not warn for clients on protocol v1.0', function(done) { 56 | backend.use('receive', function(request, next) { 57 | var error = null; 58 | if (request.data.a === ACTIONS.handshake) error = new Error('Unexpected handshake'); 59 | next(error); 60 | }); 61 | var socket = new StreamSocket(); 62 | var stream = socket.stream; 63 | backend.listen(stream); 64 | sinon.spy(logger, 'warn'); 65 | socket.send(JSON.stringify({a: ACTIONS.subscribe, c: 'dogs', d: 'fido'})); 66 | var connection = new LegacyConnection(socket); 67 | socket._open(); 68 | connection.get('dogs', 'fido').fetch(function(error) { 69 | if (error) return done(error); 70 | expect(logger.warn).not.to.have.been.called; 71 | done(); 72 | }); 73 | }); 74 | 75 | it('records the client protocol on the agent', function(done) { 76 | var connection = backend.connect(); 77 | connection.once('connected', function() { 78 | expect(connection.agent.protocol).to.eql({ 79 | major: protocol.major, 80 | minor: protocol.minor 81 | }); 82 | done(); 83 | }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/client/deserialized-type.js: -------------------------------------------------------------------------------- 1 | 2 | // A basic type that uses a custom linked list object type as its snapshot 3 | // data and therefore requires custom serialization into JSON 4 | exports.type = { 5 | name: 'test-deserialized-type', 6 | uri: 'http://sharejs.org/types/test-deserialized-type', 7 | create: create, 8 | deserialize: deserialize, 9 | apply: apply, 10 | transform: transform 11 | }; 12 | 13 | // Type that additionally defines createDeserialized and supports passing 14 | // deserialized data to doc.create() 15 | exports.type2 = { 16 | name: 'test-deserialized-type2', 17 | uri: 'http://sharejs.org/types/test-deserialized-type2', 18 | create: create, 19 | createDeserialized: createDeserialized, 20 | deserialize: deserialize, 21 | apply: apply, 22 | transform: transform 23 | }; 24 | 25 | // A node of a singly linked list to demonstrate use of a non-JSON type as 26 | // snapshot data 27 | exports.Node = Node; 28 | 29 | // When type.createDeserialized is defined, it will be called on the client 30 | // instead of type.create, and type.create will be called on the server. Only 31 | // serialized data should be passed to type.create 32 | function create(array) { 33 | return array || []; 34 | } 35 | 36 | // If type.deserialize is defined and type.createDeserialized is not, 37 | // type.create + type.deserialize will be called in the client Doc class when 38 | // a create operation is created locally or received from the server. The type 39 | // may implement this method to support creating doc data in the deserialized 40 | // format directly in addition to creating from the serialized form 41 | function createDeserialized(data) { 42 | if (data instanceof Node) { 43 | return data; 44 | } 45 | if (data == null) { 46 | return null; 47 | } 48 | return deserialize(data); 49 | } 50 | 51 | // Method called when a snapshot is ingested to cast it into deserialized type 52 | // before setting on doc.data 53 | function deserialize(array) { 54 | var node = null; 55 | for (var i = array.length; i--;) { 56 | var value = array[i]; 57 | node = new Node(value, node); 58 | } 59 | return node; 60 | } 61 | 62 | // When deserialized is defined, apply must do type checking on the input and 63 | // return deserialized data when passed deserialized data or serialized data 64 | // when passed serialized data 65 | function apply(data, op) { 66 | return (data instanceof Node) ? 67 | deserializedApply(data, op) : 68 | serializedApply(data, op); 69 | } 70 | 71 | // Deserialized apply is used in the client for all client submitted and 72 | // incoming ops. It should apply with the snapshot in the deserialized format 73 | function deserializedApply(node, op) { 74 | if (typeof op.insert === 'number') { 75 | return node.insert(op.insert, op.value); 76 | } 77 | throw new Error('Op not recognized'); 78 | } 79 | 80 | // Serialized apply is needed for applying ops on the server to the snapshot 81 | // data stored in the database. For maximum efficiency, the serialized apply 82 | // can implement the equivalent apply method on JSON data directly, though for 83 | // a simpler implementation, it can also call deserialize its input, use the 84 | // same deserialized apply, and serialize again before returning 85 | function serializedApply(array, op) { 86 | if (typeof op.insert === 'number') { 87 | array.splice(array.insert, 0, op.value); 88 | return array; 89 | } 90 | throw new Error('Op not recognized'); 91 | } 92 | 93 | function transform(op1, op2, side) { 94 | if ( 95 | typeof op1.insert === 'number' && 96 | typeof op2.insert === 'number' 97 | ) { 98 | var index = op1.insert; 99 | if (op2.insert < index || (op2.insert === index && side === 'left')) { 100 | index++; 101 | } 102 | return { 103 | insert: index, 104 | value: op1.value 105 | }; 106 | } 107 | throw new Error('Op not recognized'); 108 | } 109 | 110 | // A custom linked list object type to demonstrate custom deserialization 111 | function Node(value, next) { 112 | this.value = value; 113 | this.next = next || null; 114 | } 115 | Node.prototype.at = function(index) { 116 | var node = this; 117 | while (index--) { 118 | node = node.next; 119 | } 120 | return node; 121 | }; 122 | Node.prototype.insert = function(index, value) { 123 | if (index === 0) { 124 | return new Node(value, this); 125 | } 126 | var previous = this.at(index - 1); 127 | var node = new Node(value, previous.next); 128 | previous.next = node; 129 | return this; 130 | }; 131 | // Implementing a toJSON serialization method for the doc.data object is 132 | // needed if doc.create() is called with deserialized data 133 | Node.prototype.toJSON = function() { 134 | var out = []; 135 | for (var node = this; node; node = node.next) { 136 | out.push(node.value); 137 | } 138 | return out; 139 | }; 140 | -------------------------------------------------------------------------------- /test/client/number-type.js: -------------------------------------------------------------------------------- 1 | // A simple number type, where: 2 | // 3 | // - snapshot is an integer 4 | // - operation is an integer 5 | exports.type = { 6 | name: 'number-type', 7 | uri: 'http://sharejs.org/types/number-type', 8 | create: create, 9 | apply: apply, 10 | transform: transform 11 | }; 12 | 13 | function create(data) { 14 | return data | 0; 15 | } 16 | 17 | function apply(snapshot, op) { 18 | return snapshot + op; 19 | } 20 | 21 | function transform(op1) { 22 | return op1; 23 | } 24 | -------------------------------------------------------------------------------- /test/client/pending.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var Backend = require('../../lib/backend'); 3 | 4 | describe('client connection', function() { 5 | beforeEach(function() { 6 | this.backend = new Backend(); 7 | }); 8 | 9 | it('new connection.hasPending() returns false', function() { 10 | var connection = this.backend.connect(); 11 | expect(connection.hasPending()).equal(false); 12 | }); 13 | it('new connection.hasWritePending() returns false', function() { 14 | var connection = this.backend.connect(); 15 | expect(connection.hasWritePending()).equal(false); 16 | }); 17 | it('new connection.whenNothingPending() calls back', function(done) { 18 | var connection = this.backend.connect(); 19 | connection.whenNothingPending(done); 20 | }); 21 | 22 | it('connection.hasPending() returns true after op', function() { 23 | var connection = this.backend.connect(); 24 | connection.get('dogs', 'fido').create(); 25 | expect(connection.hasPending()).equal(true); 26 | }); 27 | it('connection.hasWritePending() returns true after op', function() { 28 | var connection = this.backend.connect(); 29 | connection.get('dogs', 'fido').create(); 30 | expect(connection.hasWritePending()).equal(true); 31 | }); 32 | ['fetch', 'subscribe'].forEach(function(method) { 33 | it('connection.hasPending() returns true after doc ' + method, function() { 34 | var connection = this.backend.connect(); 35 | connection.get('dogs', 'fido')[method](); 36 | expect(connection.hasPending()).equal(true); 37 | }); 38 | it('connection.hasWritePending() returns false after doc ' + method, function() { 39 | var connection = this.backend.connect(); 40 | connection.get('dogs', 'fido')[method](); 41 | expect(connection.hasWritePending()).equal(false); 42 | }); 43 | }); 44 | ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { 45 | it('connection.hasPending() returns true after ' + method, function() { 46 | var connection = this.backend.connect(); 47 | connection[method]('dogs', {}); 48 | expect(connection.hasPending()).equal(true); 49 | }); 50 | it('connection.hasWritePending() returns false after ' + method, function() { 51 | var connection = this.backend.connect(); 52 | connection[method]('dogs', {}); 53 | expect(connection.hasWritePending()).equal(false); 54 | }); 55 | }); 56 | 57 | it('connection.whenNothingPending() calls back after op', function(done) { 58 | var connection = this.backend.connect(); 59 | var doc = connection.get('dogs', 'fido'); 60 | doc.create(); 61 | expect(doc.version).equal(null); 62 | connection.whenNothingPending(function() { 63 | expect(doc.version).equal(1); 64 | done(); 65 | }); 66 | }); 67 | ['fetch', 'subscribe'].forEach(function(method) { 68 | it('connection.whenNothingPending() calls back after doc ' + method, function(done) { 69 | var connection = this.backend.connect(); 70 | var doc = connection.get('dogs', 'fido'); 71 | doc[method](); 72 | expect(doc.version).equal(null); 73 | connection.whenNothingPending(function() { 74 | expect(doc.version).equal(0); 75 | done(); 76 | }); 77 | }); 78 | }); 79 | ['createFetchQuery', 'createSubscribeQuery'].forEach(function(method) { 80 | it('connection.whenNothingPending() calls back after query fetch', function(done) { 81 | var connection = this.backend.connect(); 82 | connection.get('dogs', 'fido').create({age: 3}, function() { 83 | var query = connection[method]('dogs', {}); 84 | connection.whenNothingPending(function() { 85 | expect(query.results[0].id).equal('fido'); 86 | done(); 87 | }); 88 | }); 89 | }); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/client/presence/doc-presence-emitter.js: -------------------------------------------------------------------------------- 1 | var Backend = require('../../../lib/backend'); 2 | var errorHandler = require('../../util').errorHandler; 3 | var expect = require('chai').expect; 4 | 5 | describe('DocPresenceEmitter', function() { 6 | var backend; 7 | var connection; 8 | var doc; 9 | var emitter; 10 | 11 | beforeEach(function(done) { 12 | backend = new Backend(); 13 | connection = backend.connect(); 14 | doc = connection.get('books', 'northern-lights'); 15 | doc.create({title: 'Northern Lights'}, done); 16 | emitter = connection._docPresenceEmitter; 17 | }); 18 | 19 | it('listens to an op event', function(done) { 20 | emitter.addEventListener(doc, 'op', function(op) { 21 | expect(op).to.eql([{p: ['author'], oi: 'Philip Pullman'}]); 22 | done(); 23 | }); 24 | 25 | doc.submitOp([{p: ['author'], oi: 'Philip Pullman'}], errorHandler(done)); 26 | }); 27 | 28 | it('stops listening to events', function(done) { 29 | var listener = function() { 30 | done(new Error('should not reach')); 31 | }; 32 | 33 | emitter.addEventListener(doc, 'op', listener); 34 | emitter.removeEventListener(doc, 'op', listener); 35 | 36 | doc.submitOp([{p: ['author'], oi: 'Philip Pullman'}], done); 37 | }); 38 | 39 | it('removes the listener from the doc if there are no more listeners', function() { 40 | expect(doc._eventsCount).to.equal(0); 41 | var listener = function() {}; 42 | 43 | emitter.addEventListener(doc, 'op', listener); 44 | 45 | expect(doc._eventsCount).to.be.greaterThan(0); 46 | expect(emitter._docs).not.to.be.empty; 47 | expect(emitter._emitters).not.to.be.empty; 48 | expect(emitter._forwarders).not.to.be.empty; 49 | 50 | emitter.removeEventListener(doc, 'op', listener); 51 | 52 | expect(doc._eventsCount).to.equal(0); 53 | expect(emitter._docs).to.be.empty; 54 | expect(emitter._emitters).to.be.empty; 55 | expect(emitter._forwarders).to.be.empty; 56 | }); 57 | 58 | it('only registers a single listener on the doc', function() { 59 | expect(doc._eventsCount).to.equal(0); 60 | var listener = function() { }; 61 | emitter.addEventListener(doc, 'op', listener); 62 | var count = doc._eventsCount; 63 | emitter.addEventListener(doc, 'op', listener); 64 | expect(doc._eventsCount).to.equal(count); 65 | }); 66 | 67 | it('only triggers the given event', function(done) { 68 | emitter.addEventListener(doc, 'op', function(op) { 69 | expect(op).to.eql([{p: ['author'], oi: 'Philip Pullman'}]); 70 | done(); 71 | }); 72 | 73 | emitter.addEventListener(doc, 'del', function() { 74 | done(new Error('should not reach')); 75 | }); 76 | 77 | doc.submitOp([{p: ['author'], oi: 'Philip Pullman'}], errorHandler(done)); 78 | }); 79 | 80 | it('removes listeners on destroy', function(done) { 81 | expect(doc._eventsCount).to.equal(0); 82 | var listener = function() { }; 83 | 84 | emitter.addEventListener(doc, 'op', listener); 85 | 86 | expect(doc._eventsCount).to.be.greaterThan(0); 87 | expect(emitter._docs).not.to.be.empty; 88 | expect(emitter._emitters).not.to.be.empty; 89 | expect(emitter._forwarders).not.to.be.empty; 90 | 91 | doc.destroy(function(error) { 92 | if (error) return done(error); 93 | expect(doc._eventsCount).to.equal(0); 94 | expect(emitter._docs).to.be.empty; 95 | expect(emitter._emitters).to.be.empty; 96 | expect(emitter._forwarders).to.be.empty; 97 | done(); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/client/presence/presence-pauser.js: -------------------------------------------------------------------------------- 1 | // Helper middleware for precise control over when clients receive 2 | // presence updates 3 | module.exports = PresencePauser; 4 | function PresencePauser() { 5 | // Handler that can be set to be called when a message 6 | // is paused 7 | this.onPause = null; 8 | this._shouldPause = false; 9 | this._pendingBroadcasts = []; 10 | 11 | // Main middleware method 12 | this.sendPresence = function(request, callback) { 13 | if (!this._isPaused(request)) return callback(); 14 | this._pendingBroadcasts.push([request, callback]); 15 | if (typeof this.onPause === 'function') { 16 | this.onPause(request); 17 | } 18 | }; 19 | 20 | // If called without an argument, will pause all broadcasts. 21 | // If called with a function, the returned result will determine 22 | // whether the request is paused 23 | this.pause = function(predicate) { 24 | this._shouldPause = typeof predicate === 'function' ? predicate : true; 25 | }; 26 | 27 | // Send all paused broadcasts, and unpause. Also unsets the onPause 28 | // handler 29 | this.resume = function() { 30 | this._shouldPause = false; 31 | this._pendingBroadcasts.forEach(function(broadcast) { 32 | var callback = broadcast[1]; 33 | callback(); 34 | }); 35 | this._pendingBroadcasts = []; 36 | this.onPause = null; 37 | }; 38 | 39 | this._isPaused = function(request) { 40 | return this._shouldPause === true || 41 | typeof this._shouldPause === 'function' && this._shouldPause(request); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /test/client/presence/presence-test-type.js: -------------------------------------------------------------------------------- 1 | exports.type = { 2 | name: 'presence-test-type', 3 | uri: 'http://sharejs.org/types/presence-test-type', 4 | create: create, 5 | apply: apply, 6 | transformPresence: transformPresence 7 | }; 8 | 9 | function create(data) { 10 | return typeof data === 'string' ? data : ''; 11 | } 12 | 13 | function apply(snapshot, op) { 14 | if (op.value) { 15 | return snapshot.substring(0, op.index) + op.value + snapshot.substring(op.index); 16 | } else if (op.del) { 17 | return snapshot.substring(0, op.index) + snapshot.substring(op.index + op.del); 18 | } 19 | 20 | throw new Error('Invalid op'); 21 | } 22 | 23 | function transformPresence(presence, op, isOwnOperation) { 24 | if (!presence || presence.index < op.index || (presence.index === op.index && !isOwnOperation)) { 25 | return presence; 26 | } 27 | 28 | if (typeof presence.index !== 'number') throw new Error('Presence index is not a number'); 29 | 30 | if (op.value) { 31 | return {index: presence.index + op.value.length}; 32 | } else if (op.del) { 33 | return {index: presence.index - op.del}; 34 | } 35 | 36 | throw new Error('Invalid op'); 37 | } 38 | -------------------------------------------------------------------------------- /test/client/submit-json1.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var types = require('../../lib/types'); 3 | var json1Type = require('ot-json1'); 4 | types.register(json1Type.type); 5 | 6 | module.exports = function() { 7 | describe('with json1 and composition returns null', function() { 8 | var firstOp = json1Type.insertOp(['color'], 'gold'); 9 | var secondOp = json1Type.removeOp(['color']); 10 | 11 | it('uses composed ops that returns null', function() { 12 | var firstOp = json1Type.insertOp(['color'], 'gold'); 13 | var secondOp = json1Type.removeOp(['color']); 14 | expect(json1Type.type.compose(firstOp, secondOp)).eql(null); 15 | }); 16 | 17 | it('composes server operations', function(done) { 18 | var doc = this.backend.connect().get('dogs', 'fido'); 19 | var doc2 = this.backend.connect().get('dogs', 'fido'); 20 | doc.create({age: 3}, json1Type.type.uri, function(err) { 21 | if (err) return done(err); 22 | doc2.fetch(function(err) { 23 | if (err) return done(err); 24 | doc.submitOp(json1Type.removeOp(['age']), function(err) { 25 | if (err) return done(err); 26 | doc2.submitOp(json1Type.removeOp(['age']), function(err) { 27 | if (err) return done(err); 28 | expect(doc.data).eql({}); 29 | expect(doc.version).eql(2); 30 | done(); 31 | }); 32 | }); 33 | }); 34 | }); 35 | }); 36 | 37 | it('create and ops submitted sync get composed even if the composition returns null', function(done) { 38 | var doc = this.backend.connect().get('dogs', 'fido'); 39 | doc.create({age: 3}, json1Type.type.uri); 40 | doc.submitOp(firstOp); 41 | doc.submitOp(secondOp, function(err) { 42 | if (err) return done(err); 43 | expect(doc.data).eql({age: 3}); 44 | // Version is 1 instead of 3, because the create and ops got composed 45 | expect(doc.version).eql(1); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('ops submitted sync get composed even if the composition returns null', function(done) { 51 | var doc = this.backend.connect().get('dogs', 'fido'); 52 | doc.create({age: 3}, json1Type.type.uri, function(err) { 53 | if (err) return done(err); 54 | 55 | doc.submitOp(firstOp); 56 | doc.submitOp(secondOp, function(err) { 57 | if (err) return done(err); 58 | expect(doc.data).eql({age: 3}); 59 | // Ops get composed 60 | expect(doc.version).eql(2); 61 | done(); 62 | }); 63 | }); 64 | }); 65 | 66 | it('delete ops submitted sync does not get composed', function(done) { 67 | var doc = this.backend.connect().get('dogs', 'fido'); 68 | doc.create({age: 3}, json1Type.type.uri, function(err) { 69 | if (err) return done(err); 70 | 71 | doc.submitOp(firstOp); 72 | doc.del(function(err) { 73 | if (err) return done(err); 74 | expect(doc.data).eql(undefined); 75 | // del DOES NOT get composed 76 | expect(doc.version).eql(3); 77 | done(); 78 | }); 79 | }); 80 | }); 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /test/db-memory.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | var Backend = require('../lib/backend'); 3 | var DB = require('../lib/db'); 4 | var BasicQueryableMemoryDB = require('./BasicQueryableMemoryDB'); 5 | var async = require('async'); 6 | 7 | describe('DB base class', function() { 8 | it('can call db.close() without callback', function() { 9 | var db = new DB(); 10 | db.close(); 11 | }); 12 | 13 | it('can call db.close() with callback', function(done) { 14 | var db = new DB(); 15 | db.close(done); 16 | }); 17 | 18 | it('returns an error if db.commit() is unimplemented', function(done) { 19 | var db = new DB(); 20 | db.commit('testcollection', 'test', {}, {}, null, function(err) { 21 | expect(err).instanceOf(Error); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('returns an error if db.getSnapshot() is unimplemented', function(done) { 27 | var db = new DB(); 28 | db.getSnapshot('testcollection', 'foo', null, null, function(err) { 29 | expect(err).instanceOf(Error); 30 | done(); 31 | }); 32 | }); 33 | 34 | it('returns an error if db.getOps() is unimplemented', function(done) { 35 | var db = new DB(); 36 | db.getOps('testcollection', 'foo', 0, null, null, function(err) { 37 | expect(err).instanceOf(Error); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('returns an error if db.query() is unimplemented', function(done) { 43 | var db = new DB(); 44 | db.query('testcollection', {x: 5}, null, null, function(err) { 45 | expect(err).instanceOf(Error); 46 | done(); 47 | }); 48 | }); 49 | 50 | it('returns an error if db.queryPollDoc() is unimplemented', function(done) { 51 | var db = new DB(); 52 | db.queryPollDoc('testcollection', 'foo', {x: 5}, null, function(err) { 53 | expect(err).instanceOf(Error); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | 59 | describe('MemoryDB', function() { 60 | // Run all the DB-based tests against the BasicQueryableMemoryDB. 61 | require('./db')({ 62 | create: function(options, callback) { 63 | if (typeof options === 'function') { 64 | callback = options; 65 | options = null; 66 | } 67 | var db = new BasicQueryableMemoryDB(options); 68 | callback(null, db); 69 | }, 70 | getQuery: function(options) { 71 | return {filter: options.query, sort: options.sort}; 72 | } 73 | }); 74 | 75 | describe('deleteOps', function() { 76 | describe('with some ops', function() { 77 | var backend; 78 | var db; 79 | var connection; 80 | var doc; 81 | 82 | beforeEach(function(done) { 83 | backend = new Backend(); 84 | db = backend.db; 85 | connection = backend.connect(); 86 | doc = connection.get('dogs', 'fido'); 87 | 88 | async.waterfall([ 89 | doc.create.bind(doc, {name: 'Fido'}), 90 | doc.submitOp.bind(doc, [{p: ['tricks'], oi: ['fetch']}]), 91 | db.getOps.bind(db, 'dogs', 'fido', null, null, null), 92 | function(ops, next) { 93 | expect(ops).to.have.length(2); 94 | next(); 95 | } 96 | ], done); 97 | }); 98 | 99 | it('deletes all ops', function(done) { 100 | async.waterfall([ 101 | db.deleteOps.bind(db, 'dogs', 'fido', null, null, null), 102 | function(next) { 103 | db.getOps('dogs', 'fido', null, null, null, function(error) { 104 | expect(error.message).to.equal('Missing ops'); 105 | next(); 106 | }); 107 | } 108 | ], done); 109 | }); 110 | 111 | it('deletes some ops', function(done) { 112 | async.waterfall([ 113 | db.deleteOps.bind(db, 'dogs', 'fido', 0, 1, null), 114 | db.getOps.bind(db, 'dogs', 'fido', 1, 2, null), 115 | function(ops, next) { 116 | expect(ops).to.have.length(1); 117 | expect(ops[0].op).to.eql([{p: ['tricks'], oi: ['fetch']}]); 118 | db.getOps('dogs', 'fido', 0, 1, null, function(error) { 119 | expect(error.message).to.equal('Missing ops'); 120 | next(); 121 | }); 122 | } 123 | ], done); 124 | }); 125 | 126 | it('submits more ops after deleting ops', function(done) { 127 | async.series([ 128 | db.deleteOps.bind(db, 'dogs', 'fido', null, null, null), 129 | doc.submitOp.bind(doc, [{p: ['tricks', 1], li: 'sit'}]), 130 | function(next) { 131 | expect(doc.data.tricks).to.eql(['fetch', 'sit']); 132 | next(); 133 | } 134 | ], done); 135 | }); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/error.js: -------------------------------------------------------------------------------- 1 | var ShareDBError = require('../lib/error'); 2 | var expect = require('chai').expect; 3 | 4 | describe('ShareDBError', function() { 5 | it('can be identified by instanceof', function() { 6 | var error = new ShareDBError(); 7 | expect(error).to.be.instanceof(ShareDBError); 8 | }); 9 | 10 | it('has a code', function() { 11 | var error = new ShareDBError('ERROR_CODE'); 12 | expect(error.code).to.equal('ERROR_CODE'); 13 | }); 14 | 15 | it('has a message', function() { 16 | var error = new ShareDBError(null, 'Detailed message'); 17 | expect(error.message).to.equal('Detailed message'); 18 | }); 19 | 20 | it('has a stack trace', function() { 21 | function badFunction() { 22 | throw new ShareDBError(); 23 | } 24 | 25 | try { 26 | badFunction(); 27 | } catch (error) { 28 | expect(error.stack).to.contain('badFunction'); 29 | } 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/logger.js: -------------------------------------------------------------------------------- 1 | var Logger = require('../lib/logger/logger'); 2 | var expect = require('chai').expect; 3 | var sinon = require('sinon'); 4 | 5 | describe('Logger', function() { 6 | describe('Stubbing console.warn', function() { 7 | beforeEach(function() { 8 | sinon.stub(console, 'warn'); 9 | }); 10 | 11 | it('logs to console by default', function() { 12 | var logger = new Logger(); 13 | logger.warn('warning'); 14 | expect(console.warn.calledOnceWithExactly('warning')).to.equal(true); 15 | }); 16 | 17 | it('overrides console', function() { 18 | var customWarn = sinon.stub(); 19 | var logger = new Logger(); 20 | logger.setMethods({ 21 | warn: customWarn 22 | }); 23 | 24 | logger.warn('warning'); 25 | 26 | expect(console.warn.notCalled).to.equal(true); 27 | expect(customWarn.calledOnceWithExactly('warning')).to.equal(true); 28 | }); 29 | 30 | it('only overrides if provided with a method', function() { 31 | var badWarn = 'not a function'; 32 | var logger = new Logger(); 33 | logger.setMethods({ 34 | warn: badWarn 35 | }); 36 | 37 | logger.warn('warning'); 38 | 39 | expect(console.warn.calledOnceWithExactly('warning')).to.equal(true); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /test/milestone-db-memory.js: -------------------------------------------------------------------------------- 1 | var MemoryMilestoneDB = require('./../lib/milestone-db/memory'); 2 | 3 | require('./milestone-db')({ 4 | create: function(options, callback) { 5 | if (typeof options === 'function') { 6 | callback = options; 7 | options = null; 8 | } 9 | 10 | var db = new MemoryMilestoneDB(options); 11 | callback(null, db); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /test/next-tick.js: -------------------------------------------------------------------------------- 1 | var nextTickImpl = require('../lib/next-tick'); 2 | var expect = require('chai').expect; 3 | 4 | describe('nextTick', function() { 5 | ['messageChannel', 'setTimeout'].forEach(function(name) { 6 | var tick = nextTickImpl[name]; 7 | 8 | it('passes args', function(done) { 9 | tick(function(arg1, arg2, arg3) { 10 | expect(arg1).to.equal('foo'); 11 | expect(arg2).to.equal(123); 12 | expect(arg3).to.be.undefined; 13 | done(); 14 | }, 'foo', 123); 15 | }); 16 | 17 | it('calls asynchronously', function(done) { 18 | var called = false; 19 | tick(function() { 20 | called = true; 21 | done(); 22 | }); 23 | expect(called).to.be.false; 24 | }); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/protocol.js: -------------------------------------------------------------------------------- 1 | var protocol = require('../lib/protocol'); 2 | var expect = require('chai').expect; 3 | 4 | describe('protocol', function() { 5 | describe('checkAtLeast', function() { 6 | var FIXTURES = [ 7 | ['1.0', '1.0', true], 8 | ['1.1', '1.0', true], 9 | ['1.0', '1.1', false], 10 | ['1.0', '1', true], 11 | ['1.10', '1.3', true], 12 | ['2.0', '1.3', true], 13 | [{major: 1, minor: 0}, {major: 1, minor: 0}, true], 14 | [{major: 1, minor: 1}, {major: 1, minor: 0}, true], 15 | [{major: 1, minor: 0}, {major: 1, minor: 1}, false], 16 | [{protocol: 1, protocolMinor: 0}, {protocol: 1, protocolMinor: 0}, true], 17 | [{protocol: 1, protocolMinor: 1}, {protocol: 1, protocolMinor: 0}, true], 18 | [{protocol: 1, protocolMinor: 0}, {protocol: 1, protocolMinor: 1}, false], 19 | [{}, '1.0', false], 20 | ['', '1.0', false] 21 | ]; 22 | 23 | FIXTURES.forEach(function(fixture) { 24 | var is = fixture[2] ? ' is ' : ' is not '; 25 | var name = 'checks ' + JSON.stringify(fixture[0]) + is + 'at least ' + JSON.stringify(fixture[1]); 26 | it(name, function() { 27 | expect(protocol.checkAtLeast(fixture[0], fixture[1])).to.equal(fixture[2]); 28 | }); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/pubsub-memory.js: -------------------------------------------------------------------------------- 1 | var MemoryPubSub = require('../lib/pubsub/memory'); 2 | var PubSub = require('../lib/pubsub'); 3 | var expect = require('chai').expect; 4 | 5 | require('./pubsub')(function(callback) { 6 | callback(null, new MemoryPubSub()); 7 | }); 8 | require('./pubsub')(function(callback) { 9 | callback(null, new MemoryPubSub({prefix: 'foo'})); 10 | }); 11 | 12 | describe('PubSub base class', function() { 13 | it('returns an error if _subscribe is unimplemented', function(done) { 14 | var pubsub = new PubSub(); 15 | pubsub.subscribe('x', function(err) { 16 | expect(err).instanceOf(Error); 17 | expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED'); 18 | done(); 19 | }); 20 | }); 21 | 22 | it('emits an error if _subscribe is unimplemented and callback is not provided', function(done) { 23 | var pubsub = new PubSub(); 24 | pubsub.on('error', function(err) { 25 | expect(err).instanceOf(Error); 26 | expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED'); 27 | done(); 28 | }); 29 | pubsub.subscribe('x'); 30 | }); 31 | 32 | it('emits an error if _unsubscribe is unimplemented', function(done) { 33 | var pubsub = new PubSub(); 34 | pubsub._subscribe = function(channel, callback) { 35 | callback(); 36 | }; 37 | pubsub.subscribe('x', function(err, stream) { 38 | if (err) return done(err); 39 | pubsub.on('error', function(err) { 40 | expect(err).instanceOf(Error); 41 | expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED'); 42 | done(); 43 | }); 44 | stream.destroy(); 45 | }); 46 | }); 47 | 48 | it('returns an error if _publish is unimplemented', function(done) { 49 | var pubsub = new PubSub(); 50 | pubsub.on('error', done); 51 | pubsub.publish(['x', 'y'], {test: true}, function(err) { 52 | expect(err).instanceOf(Error); 53 | expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED'); 54 | done(); 55 | }); 56 | }); 57 | 58 | it('emits an error if _publish is unimplemented and callback is not provided', function(done) { 59 | var pubsub = new PubSub(); 60 | pubsub.on('error', function(err) { 61 | expect(err).instanceOf(Error); 62 | expect(err.code).to.equal('ERR_DATABASE_METHOD_NOT_IMPLEMENTED'); 63 | done(); 64 | }); 65 | pubsub.publish(['x', 'y'], {test: true}); 66 | }); 67 | 68 | it('can emit events', function(done) { 69 | var pubsub = new PubSub(); 70 | pubsub.on('error', function(err) { 71 | expect(err).instanceOf(Error); 72 | expect(err.message).equal('test error'); 73 | done(); 74 | }); 75 | pubsub.emit('error', new Error('test error')); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/pubsub.js: -------------------------------------------------------------------------------- 1 | var expect = require('chai').expect; 2 | 3 | module.exports = function(create) { 4 | describe('pubsub', function() { 5 | beforeEach(function(done) { 6 | var self = this; 7 | create(function(err, pubsub) { 8 | if (err) done(err); 9 | self.pubsub = pubsub; 10 | done(); 11 | }); 12 | }); 13 | 14 | afterEach(function(done) { 15 | this.pubsub.close(done); 16 | }); 17 | 18 | it('can call pubsub.close() without callback', function(done) { 19 | create(function(err, pubsub) { 20 | if (err) done(err); 21 | pubsub.close(); 22 | done(); 23 | }); 24 | }); 25 | 26 | it('can subscribe to a channel', function(done) { 27 | var pubsub = this.pubsub; 28 | pubsub.subscribe('x', function(err, stream) { 29 | if (err) done(err); 30 | stream.on('data', function(data) { 31 | expect(data).eql({test: true}); 32 | done(); 33 | }); 34 | expect(pubsub.streamsCount).equal(1); 35 | pubsub.publish(['x'], {test: true}); 36 | }); 37 | }); 38 | 39 | it('publish optional callback returns', function(done) { 40 | var pubsub = this.pubsub; 41 | pubsub.subscribe('x', function(err) { 42 | if (err) done(err); 43 | pubsub.publish(['x'], {test: true}, done); 44 | }); 45 | }); 46 | 47 | it('can subscribe to a channel twice', function(done) { 48 | var pubsub = this.pubsub; 49 | pubsub.subscribe('y', function(err) { 50 | if (err) done(err); 51 | pubsub.subscribe('y', function(err, stream) { 52 | if (err) done(err); 53 | stream.on('data', function(data) { 54 | expect(data).eql({test: true}); 55 | done(); 56 | }); 57 | expect(pubsub.streamsCount).equal(2); 58 | pubsub.publish(['x', 'y'], {test: true}); 59 | }); 60 | }); 61 | }); 62 | 63 | it('stream.destroy() unsubscribes from a channel', function(done) { 64 | var pubsub = this.pubsub; 65 | pubsub.subscribe('x', function(err, stream) { 66 | if (err) done(err); 67 | expect(pubsub.streamsCount).equal(1); 68 | stream.on('data', function() { 69 | // Will error if done is called twice 70 | done(); 71 | }); 72 | stream.destroy(); 73 | expect(pubsub.streamsCount).equal(0); 74 | pubsub.publish(['x', 'y'], {test: true}); 75 | done(); 76 | }); 77 | }); 78 | 79 | it('can emit events', function(done) { 80 | this.pubsub.on('error', function(err) { 81 | expect(err).instanceOf(Error); 82 | expect(err.message).equal('test error'); 83 | done(); 84 | }); 85 | this.pubsub.emit('error', new Error('test error')); 86 | }); 87 | }); 88 | }; 89 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | var logger = require('../lib/logger'); 2 | var sinon = require('sinon'); 3 | var sinonChai = require('sinon-chai'); 4 | var chai = require('chai'); 5 | 6 | chai.use(sinonChai); 7 | 8 | if (process.env.LOGGING !== 'true') { 9 | // Silence the logger for tests by setting all its methods to no-ops 10 | logger.setMethods({ 11 | info: function() {}, 12 | warn: function() {}, 13 | error: function() {} 14 | }); 15 | } 16 | 17 | afterEach(function() { 18 | sinon.restore(); 19 | }); 20 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | 2 | exports.sortById = function(docs) { 3 | return docs.slice().sort(function(a, b) { 4 | if (a.id > b.id) return 1; 5 | if (b.id > a.id) return -1; 6 | return 0; 7 | }); 8 | }; 9 | 10 | exports.pluck = function(docs, key) { 11 | var values = []; 12 | for (var i = 0; i < docs.length; i++) { 13 | values.push(docs[i][key]); 14 | } 15 | return values; 16 | }; 17 | 18 | // Wrap a done function to call back only after a specified number of calls. 19 | // For example, `var callbackAfter = callAfter(1, callback)` means that if 20 | // `callbackAfter` is called once, it won't call back. If it is called twice 21 | // or more, it won't call back for the first time, but it will call back for 22 | // each following time. Calls back immediately if called with an error. 23 | // 24 | // Return argument is a function with the property `calls`. This property 25 | // starts at zero and increments for each call. 26 | exports.callAfter = function(calls, callback) { 27 | if (typeof calls !== 'number') { 28 | throw new Error('Required `calls` argument must be a number'); 29 | } 30 | if (typeof callback !== 'function') { 31 | throw new Error('Required `callback` argument must be a function'); 32 | } 33 | var callbackAfter = function(err) { 34 | callbackAfter.called++; 35 | if (err) return callback(err); 36 | if (callbackAfter.called <= calls) return; 37 | callback(); 38 | }; 39 | callbackAfter.called = 0; 40 | return callbackAfter; 41 | }; 42 | 43 | exports.errorHandler = function(callback) { 44 | return function(error) { 45 | if (error) callback(error); 46 | }; 47 | }; 48 | 49 | exports.errorHandler = function(callback) { 50 | return function(error) { 51 | if (error) callback(error); 52 | }; 53 | }; 54 | --------------------------------------------------------------------------------