Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Added browserchannel-based server frontend

  • Loading branch information...
commit 35ab3d1cbbd7776e7ca198341bd4186c18f483f7 1 parent c2c2d74
@josephg josephg authored
View
19 src/server/browserchannel.coffee
@@ -34,7 +34,7 @@ module.exports = (createClient, options) ->
options or= {}
browserChannel options, (session) ->
- console.log "New BC session from #{session.address} with id #{session.id}"
+ #console.log "New BC session from #{session.address} with id #{session.id}"
data =
headers: session.headers
remoteAddress: session.address
@@ -45,15 +45,15 @@ module.exports = (createClient, options) ->
# To save on network traffic, the client & server can leave out the docName with each message to mean
# 'same as the last message'
- lastSentDocName = null
- lastReceivedDocName = null
+ lastSentDoc = null
+ lastReceivedDoc = null
# Map from docName -> {queue, listener if open}
docState = {}
# We'll only handle one message from each client at a time.
handleMessage = (query) ->
- console.log "Message from #{session.id}", query
+ #console.log "Message from #{session.id}", query
# The client can specify null as the docName to get a random doc name.
if query.doc is null
@@ -111,7 +111,7 @@ module.exports = (createClient, options) ->
# Its invalid to send a message to a closed session. We'll silently drop messages if the
# session has closed.
if session.state isnt 'closed'
- console.log "Sending", response
+ #console.log "Sending", response
session.send response
# Open the given document name, at the requested version.
@@ -296,7 +296,7 @@ module.exports = (createClient, options) ->
# ...
#throw new Error 'No version specified' unless query.v?
- opData = {v:query.v, op:query.op}
+ opData = {v:query.v, op:query.op, meta:query.meta}
client.submitOp query.doc, opData, (error, appliedVersion) ->
msg = if error
@@ -316,9 +316,11 @@ module.exports = (createClient, options) ->
createClient data, (error, client_) ->
if error
# The client is not authorized, so they shouldn't try and reconnect.
- client.stop()
+ session.send {auth:null, error}
+ session.stop()
else
client = client_
+ session.send auth:client.id
# Ok. Now we can handle all the messages in the buffer. They'll go straight to
# handleMessage from now on.
@@ -328,7 +330,8 @@ module.exports = (createClient, options) ->
session.on 'message', handleMessage
session.on 'close', ->
- console.log "Client #{client.id} disconnected"
+ return unless client
+ #console.log "Client #{client.id} disconnected"
for docName, {listener} of docState
client.removeListener docName if listener
docState = null
View
6 src/server/index.coffee
@@ -43,7 +43,11 @@ create.attach = attach = (server, options, model = createModel(options)) ->
# done properly.
server.use rest(createClient, options.rest) if options.rest != null
socketio.attach(server, createClient, options.socketio or {}) if options.socketio != null
- server.use browserChannel(createClient, options.browserChannel) if options.browserChannel != null
+
+ if options.browserChannel != null
+ options.browserChannel ?= {}
+ options.browserChannel.server = server
+ server.use browserChannel(createClient, options.browserChannel)
server
View
1  src/server/rest.coffee
@@ -4,7 +4,6 @@
http = require 'http'
sys = require 'sys'
-util = require 'util'
url = require 'url'
connect = require 'connect'
View
387 test/browserchannel.coffee
@@ -0,0 +1,387 @@
+# Tests for the server's browserchannel frontend. The protocol is documented here:
+#
+# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
+
+testCase = require('nodeunit').testCase
+assert = require 'assert'
+{BCSocket} = require 'browserchannel'
+
+server = require '../src/server'
+types = require '../src/types'
+
+helpers = require './helpers'
+newDocName = helpers.newDocName
+applyOps = helpers.applyOps
+makePassPart = helpers.makePassPart
+
+ANYOBJECT = new Object
+
+# Helper method to check that subsequent data received by the callback is a particular
+# set of values.
+expectData = (socket, expectedData, callback) ->
+ expectedData = [expectedData] unless Array.isArray expectedData
+
+ socket.onmessage = (data) ->
+ expected = expectedData.shift()
+ if expected.meta == ANYOBJECT
+ assert.strictEqual typeof data.meta, 'object'
+ delete data.meta
+ delete expected.meta
+ assert.deepEqual expected, data
+
+ if expectedData.length == 0
+ socket.onmessage = (data) -> console.warn 'xxxx', data
+ callback()
+
+module.exports = testCase
+ setUp: (callback) ->
+ @auth = (client, action) -> action.accept()
+
+ options =
+ browserchannel: {}
+ rest: null
+ socketio: null
+ db: {type: 'none'}
+ auth: (client, action) => @auth client, action
+
+ try
+ @model = server.createModel options
+ @server = server options, @model
+
+ @server.listen =>
+ @name = 'testingdoc'
+
+ # Open a new browserchannel session to the server
+ @socket = new BCSocket "http://localhost:#{@server.address().port}/channel"
+ @socket.onmessage = (data) =>
+ @id = data.auth
+ assert.ok @id
+ callback()
+
+ @socket.onerror = (e) -> console.warn 'eeee', e
+
+ @expect = (data, callback) =>
+ expectData @socket, data, callback
+ catch e
+ console.log e.stack
+ throw e
+
+ tearDown: (callback) ->
+ @socket.close()
+
+ # Its important the port has closed before the next test is run.
+ @server.on 'close', callback
+ @server.close()
+
+ 'open an existing document with no version specified opens the document': (test) ->
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, open:true}
+ @expect {doc:@name, v:0, open:true}, =>
+ @model.applyOp @name, {op:{position:0, text:'hi'}, v:0}, =>
+ @expect {v:0, op:{position:0, text:'hi'}, meta:ANYOBJECT}, ->
+ test.done()
+
+ 'open an existing document with version specified opens the document': (test) ->
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, open:true, v:0}
+ @expect {doc:@name, v:0, open:true}, =>
+ @model.applyOp @name, {op:{position:0, text:'hi'}, v:0}, =>
+ @expect {v:0, op:{position:0, text:'hi'}, meta:ANYOBJECT}, ->
+ test.done()
+
+ 'open a nonexistant document with create:true creates the document': (test) ->
+ @socket.send {doc:@name, open:true, create:true, type:'simple'}
+ @expect {doc:@name, open:true, create:true, v:0}, =>
+ @model.getSnapshot @name, (error, docData) ->
+ test.deepEqual docData, {snapshot:{str:''}, v:0, type:types.simple, meta:{}}
+ test.done()
+
+ 'open a nonexistant document without create fails': (test) ->
+ @socket.send {doc:@name, open:true}
+ @expect {doc:@name, open:false, error:'Document does not exist'}, =>
+ test.done()
+
+ 'open a nonexistant document at a particular version without create fails': (test) ->
+ @socket.send {doc:@name, open:true, v:0}
+ @expect {doc:@name, open:false, error:'Document does not exist'}, =>
+ test.done()
+
+ 'open a nonexistant document with snapshot:null fails normally': (test) ->
+ @socket.send {doc:@name, open:true, snapshot:null}
+ @expect {doc:@name, open:false, snapshot:null, error:'Document does not exist'}, =>
+ test.done()
+
+ 'get a snapshot of a nonexistant document fails normally': (test) ->
+ @socket.send {doc:@name, snapshot:null}
+ @expect {doc:@name, snapshot:null, error:'Document does not exist'}, =>
+ test.done()
+
+ 'open a nonexistant document with create:true and snapshot:null does not return the snapshot': (test) ->
+ # The snapshot can be inferred.
+ @socket.send {doc:@name, open:true, create:true, type:'text', snapshot:null}
+ @expect {doc:@name, open:true, create:true, v:0}, =>
+ test.done()
+
+ 'open a document with a different type fails': (test) ->
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, open:true, type:'text'}
+ @expect {doc:@name, open:false, error:'Type mismatch'}, =>
+ test.done()
+
+ 'open an existing document with create:true opens the current document': (test) ->
+ @model.create @name, 'simple', =>
+ @model.applyOp @name, {op:{position:0, text:'hi'}, v:0}, =>
+ @socket.send {doc:@name, open:true, create:true, type:'simple', snapshot:null}
+ # The type isn't sent if it can be inferred.
+ @expect {doc:@name, create:false, open:true, v:1, snapshot:{str:'hi'}}, ->
+ test.done()
+
+ 'open a document at a previous version and get ops since': (test) ->
+ @model.create @name, 'simple', =>
+ @model.applyOp @name, {op:{position:0, text:'hi'}, v:0}, =>
+ @socket.send {doc:@name, v:0, open:true, type:'simple'}
+
+ @expect [{doc:@name, v:0, open:true}, {v:0, op:{position:0, text:'hi'}, meta:ANYOBJECT}], ->
+ test.done()
+
+ 'create a document without opening it': (test) ->
+ @socket.send {doc:@name, create:true, type:'simple'}
+ @expect {doc:@name, create:true}, =>
+ @model.getSnapshot @name, (error, docData) ->
+ test.deepEqual docData, {snapshot:{str:''}, v:0, type:types.simple, meta:{}}
+ test.done()
+
+ 'create a document that already exists returns create:false': (test) ->
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, create:true, type:'simple'}
+ @expect {doc:@name, create:false}, =>
+ test.done()
+
+ 'create a document with snapshot:null returns create:true and no snapshot': (test) ->
+ @socket.send {doc:@name, create:true, type:'simple', snapshot:null}
+ @expect {doc:@name, create:true}, =>
+ test.done()
+
+ 'receive ops through an open document': (test) ->
+ @socket.send {doc:@name, v:0, open:true, create:true, type:'simple'}
+ @expect {doc:@name, v:0, open:true, create:true}, =>
+ @model.applyOp @name, {op:{position:0, text:'hi'}, v:0}
+
+ @expect {v:0, op:{position:0, text:'hi'}, meta:ANYOBJECT}, ->
+ test.done()
+
+ 'send an op': (test) ->
+ @model.create @name, 'simple', =>
+ listener = (opData) ->
+ test.strictEqual opData.v, 0
+ test.deepEqual opData.op, {position:0, text:'hi'}
+ test.done()
+ @model.listen @name, listener, (error, v) -> test.strictEqual v, 0
+
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}}
+
+ 'send an op with metadata': (test) ->
+ @model.create @name, 'simple', =>
+ listener = (opData) ->
+ test.strictEqual opData.v, 0
+ test.strictEqual opData.meta.x, 5
+ test.deepEqual opData.op, {position:0, text:'hi'}
+ test.done()
+ @model.listen @name, listener, (error, v) -> test.strictEqual v, 0
+
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}, meta:{x:5}}
+
+ 'receive confirmation when an op is sent': (test) ->
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}, meta:{x:5}}
+
+ @expect {doc:@name, v:0}, ->
+ test.done()
+
+ 'not be sent your own ops back': (test) ->
+# @socket.onmessage = (data) ->
+# test.notDeepEqual data.op, {position:0, text:'hi'} if data.op?
+#
+ @socket.send {doc:@name, open:true, create:true, type:'simple'}
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}}
+
+ @expect [{doc:@name, v:0, open:true, create:true}, {v:0}], =>
+ # Gonna do this a dodgy way. Because I don't want to wait an undefined amount of time
+ # to make sure the op doesn't come, I'll trigger another op and make sure it recieves that.
+ # The second op should come after the first.
+ @expect {v:1, op:{position:0, text:'yo '}, meta:ANYOBJECT}, ->
+ test.done()
+
+ @model.applyOp @name, {v:1, op:{position:0, text:'yo '}}
+
+ 'get a document snapshot': (test) ->
+ @model.create @name, 'simple', =>
+ @model.applyOp @name, {v:0, op:{position:0, text:'internet'}}, (error, _) =>
+ test.ifError(error)
+
+ @socket.send {doc:@name, snapshot:null}
+ @expect {doc:@name, snapshot:{str:'internet'}, v:1, type:'simple'}, ->
+ test.done()
+
+ 'be able to close a document': (test) ->
+ name1 = newDocName()
+ name2 = newDocName()
+
+ @socket.send {doc:name1, open:true, create:true, type:'simple'}
+ @socket.send {open:false}
+ @socket.send {doc:name2, open:true, create:true, type:'text'}
+
+ @expect [{doc:name1, open:true, create:true, v:0}, {open:false}, {doc:name2, open:true, create:true, v:0}], =>
+ # name1 should be closed, and name2 should be open.
+ # We should only get the op for name2.
+ @model.applyOp name1, {v:0, op:{position:0, text:'Blargh!'}}, (error, appliedVersion) ->
+ test.fail error if error
+ @model.applyOp name2, {v:0, op:[{i:'hi', p:0}]}, (error, appliedVersion) ->
+ test.fail error if error
+
+ @expect {v:0, op:[{i:'hi', p:0}], meta:ANYOBJECT}, ->
+ test.done()
+
+ 'doc names are sent in ops when necessary': (test) ->
+ name1 = newDocName()
+ name2 = newDocName()
+
+ @socket.send {doc:name1, open:true, create:true, type:'simple'}
+ @socket.send {doc:name2, open:true, create:true, type:'simple'}
+
+ passPart = makePassPart test, 3
+
+ @expect [{doc:name1, open:true, create:true, v:0}, {doc:name2, open:true, create:true, v:0}], =>
+ @model.applyOp name1, {v:0, op:{position:0, text:'a'}}, (error) =>
+ test.fail error if error
+ @model.applyOp name2, {v:0, op:{position:0, text:'b'}}, (error) =>
+ test.fail error if error
+ @model.applyOp name1, {v:1, op:{position:0, text:'c'}}, (error) =>
+ test.fail error if error
+
+ # All the ops that come through the socket should have the doc name set.
+ @socket.onmessage = (data) =>
+ test.strictEqual data.doc?, true
+ passPart()
+
+ "don't repeat document names": (test) ->
+ passPart = makePassPart test, 3
+ @socket.send {doc:@name, open:true, create:true, type:'simple'}
+ @expect {doc:@name, open:true, create:true, v:0}, =>
+ @socket.onmessage = (data) =>
+ # This time, none of the ops should have the document name set.
+ test.strictEqual data.doc?, false
+ passPart()
+
+ @socket.send {doc:@name, op:{position: 0, text:'a'}, v:0}
+ @socket.send {doc:@name, op:{position: 0, text:'b'}, v:1}
+ @socket.send {doc:@name, op:{position: 0, text:'c'}, v:2}
+
+ 'an error message is sent through the socket if the operation is invalid': (test) ->
+ @model.create @name, 'simple', =>
+ # This might cause the model code to print out an error stack trace
+ @socket.send {doc:@name, v:0, op:{position:-100, text:'asdf'}}
+ @expect {doc:@name, v:null, error:'Invalid position'}, ->
+ test.done()
+
+ 'creating a document with a null doc name creates a new doc': (test) ->
+ @socket.send {doc:null, create:true, type:'simple'}
+ @socket.onmessage = (data) =>
+ test.strictEqual data.create, true
+ test.equal typeof data.doc, 'string'
+ test.ok data.doc.length > 8
+
+ @model.getSnapshot data.doc, (error, docData) ->
+ test.deepEqual docData, {snapshot:{str:''}, v:0, type:types.simple, meta:{}}
+ test.done()
+
+# ---- Auth-related tests
+ 'The auth client object is persisted across requests': (test) ->
+ c = null
+
+ @auth = (client, action) =>
+ if c
+ test.strictEqual c, client
+ else
+ c = client
+ action.accept()
+
+ @socket.send {doc:@name, open:true, create:true, type:'simple'}
+ @expect {doc:@name, open:true, create:true, v:0}, =>
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}, meta:{x:5}}
+ @expect {v:0}, ->
+ test.expect 3
+ test.done()
+
+ 'Cannot connect if auth rejects you': (test) ->
+ @auth = (client, action) ->
+ test.strictEqual action.type, 'connect'
+ test.ok client.remoteAddress in ['localhost', '127.0.0.1'] # Is there a nicer way to do this?
+ test.strictEqual typeof client.id, 'string'
+ test.ok client.id.length > 5
+ test.ok client.connectTime
+
+ test.strictEqual typeof client.headers, 'object'
+
+ # I can't edit the headers using socket.io-client's API. I'd test the default headers in this
+ # object, but the default XHR headers aren't part of socket.io's API, so they could change between
+ # versions and break the test.
+ test.strictEqual client.headers['user-agent'], 'node.js'
+
+ action.reject()
+
+ socket = new BCSocket "http://localhost:#{@server.address().port}/channel"
+
+ expectData socket, {auth:null, error:'forbidden'}, ->
+ socket.onclose = ->
+ test.expect 7
+ test.done()
+
+ 'Cannot open a document if auth rejects you': (test) ->
+ @auth = (client, action) =>
+ if action.name == 'open'
+ action.reject()
+ else
+ action.accept()
+
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, open:true}
+ @expect {doc:@name, open:false, error:'forbidden'}, ->
+ test.done()
+
+ 'Cannot open a document if you cannot get a snapshot': (test) ->
+ @auth = (client, action) =>
+ if action.name == 'get snapshot'
+ action.reject()
+ else
+ action.accept()
+
+ @model.create @name, 'simple', =>
+ @socket.send {doc:@name, open:true, snapshot:null}
+ @expect {doc:@name, open:false, snapshot:null, error:'forbidden'}, ->
+ test.done()
+
+ 'Cannot create a document if youre not allowed to create': (test) ->
+ @auth = (client, action) =>
+ if action.name == 'create'
+ action.reject()
+ else
+ action.accept()
+
+ @socket.send {doc:@name, open:true, create:true, type:'simple'}
+ @expect {doc:@name, open:false, error:'forbidden'}, ->
+ test.done()
+
+ 'Cannot submit an op if auth rejects you': (test) ->
+ @auth = (client, action) ->
+ if action.type == 'update'
+ action.reject()
+ else
+ action.accept()
+
+ @socket.send {doc:@name, open:true, create:true, type:'simple', snapshot:null}
+ @expect {doc:@name, open:true, create:true, v:0}, =>
+ @socket.send {doc:@name, v:0, op:{position:0, text:'hi'}, meta:{}}
+ @expect {v:null, error:'forbidden'}, ->
+ test.done()
+
View
3  test/socketio.coffee
@@ -51,12 +51,11 @@ module.exports = testCase
setUp: (callback) ->
@auth = (client, action) -> action.accept()
- options = {
+ options =
socketio: {}
rest: null
db: {type: 'none'}
auth: (client, action) => @auth client, action
- }
try
@model = server.createModel options
View
1  tests.coffee
@@ -22,6 +22,7 @@ modules = [
'events'
'rest'
# 'socketio'
+ 'browserchannel'
'microevent'
# 'client'
Please sign in to comment.
Something went wrong with that request. Please try again.