unless WEB? types = require '../types' if WEB? exports.extendDoc = (name, fn) -> Doc::[name] = fn # A Doc is a client's view on a sharejs document. # # Documents are created by calling Connection.open(). # # Documents are event emitters - use doc.on(eventname, fn) to subscribe. # # Documents get mixed in with their type's API methods. So, you can .insert('foo', 0) into # a text document and stuff like that. # # Events: # - remoteop (op) # - changed (op) # - acknowledge (op) # - error # - open, closing, closed. 'closing' is not guaranteed to fire before closed. class Doc # connection is a Connection object. # name is the documents' docName. # data can optionally contain known document data, and initial open() call arguments: # {v[erson], snapshot={...}, type, create=true/false/undefined} # callback will be called once the document is first opened. constructor: (@connection, @name, openData) -> # Any of these can be null / undefined at this stage. openData ||= {} @version = openData.v @snapshot = openData.snaphot @_setType openData.type if openData.type @state = 'closed' @autoOpen = false # Has the document already been created? @_create = openData.create # The op that is currently roundtripping to the server, or null. # # When the connection reconnects, the inflight op is resubmitted. @inflightOp = null @inflightCallbacks = [] # The auth ids which the client has previously used to attempt to send inflightOp. This is # usually empty. @inflightSubmittedIds = [] # All ops that are waiting for the server to acknowledge @inflightOp @pendingOp = null @pendingCallbacks = [] # Some recent ops, incase submitOp is called with an old op version number. @serverOps = {} # Transform a server op by a client op, and vice versa. _xf: (client, server) -> if @type.transformX @type.transformX(client, server) else client_ = @type.transform client, server, 'left' server_ = @type.transform server, client, 'right' return [client_, server_] _otApply: (docOp, isRemote) -> oldSnapshot = @snapshot @snapshot = @type.apply(@snapshot, docOp) # Its important that these event handlers are called with oldSnapshot. # The reason is that the OT type APIs might need to access the snapshots to # determine information about the received op. @emit 'change', docOp, oldSnapshot @emit 'remoteop', docOp, oldSnapshot if isRemote _connectionStateChanged: (state, data) -> switch state when 'disconnected' @state = 'closed' # This is used by the server to make sure that when an op is resubmitted it # doesn't end up getting applied twice. @inflightSubmittedIds.push @connection.id if @inflightOp @emit 'closed' when 'ok' # Might be able to do this when we're connecting... that would save a roundtrip. @open() if @autoOpen when 'stopped' @_openCallback? data @emit state, data _setType: (type) -> if typeof type is 'string' type = types[type] throw new Error 'Support for types without compose() is not implemented' unless type and type.compose @type = type if type.api this[k] = v for k, v of type.api @_register?() else @provides = {} _onMessage: (msg) -> #console.warn 's->c', msg if msg.open == true # The document has been successfully opened. @state = 'open' @_create = false # Don't try and create the document again next time open() is called. unless @created? @created = !!msg.create @_setType msg.type if msg.type if msg.create @created = true @snapshot = @type.create() else @created = false unless @created is true @snapshot = msg.snapshot if msg.snapshot isnt undefined @version = msg.v if msg.v? # Resend any previously queued operation. if @inflightOp response = doc: @name op: @inflightOp v: @version response.dupIfSource = @inflightSubmittedIds if @inflightSubmittedIds.length @connection.send response else @flush() @emit 'open' @_openCallback? null else if msg.open == false # The document has either been closed, or an open request has failed. if msg.error # An error occurred opening the document. console?.error "Could not open document: #{msg.error}" @emit 'error', msg.error @_openCallback? msg.error @state = 'closed' @emit 'closed' @_closeCallback?() @_closeCallback = null else if msg.op is null and error is 'Op already submitted' # We've tried to resend an op to the server, which has already been received successfully. Do nothing. # The op will be confirmed normally when we get the op itself was echoed back from the server # (handled below). else if (msg.op is undefined and msg.v isnt undefined) or (msg.op and msg.meta.source in @inflightSubmittedIds) # Our inflight op has been acknowledged. oldInflightOp = @inflightOp @inflightOp = null @inflightSubmittedIds.length = 0 error = msg.error if error # The server has rejected an op from the client for some reason. # We'll send the error message to the user and roll back the change. # # If the server isn't going to allow edits anyway, we should probably # figure out some way to flag that (readonly:true in the open request?) if @type.invert undo = @type.invert oldInflightOp # Now we have to transform the undo operation by any server ops & pending ops if @pendingOp [@pendingOp, undo] = @_xf @pendingOp, undo # ... and apply it locally, reverting the changes. # # This call will also call @emit 'remoteop'. I'm still not 100% sure about this # functionality, because its really a local op. Basically, the problem is that # if the client's op is rejected by the server, the editor window should update # to reflect the undo. @_otApply undo, true else @emit 'error', "Op apply failed (#{error}) and the op could not be reverted" callback error for callback in @inflightCallbacks else # The op applied successfully. throw new Error('Invalid version from server') unless msg.v == @version @serverOps[@version] = oldInflightOp @version++ @emit 'acknowledge', oldInflightOp callback null, oldInflightOp for callback in @inflightCallbacks # Send the next op. @flush() else if msg.op # We got a new op from the server. # msg is {doc:, op:, v:} # There is a bug in socket.io (produced on firefox 3.6) which causes messages # to be duplicated sometimes. # We'll just silently drop subsequent messages. return if msg.v < @version return @emit 'error', "Expected docName '#{@name}' but got #{msg.doc}" unless msg.doc == @name return @emit 'error', "Expected version #{@version} but got #{msg.v}" unless msg.v == @version # p "if: #{i @inflightOp} pending: #{i @pendingOp} doc '#{@snapshot}' op: #{i msg.op}" op = msg.op @serverOps[@version] = op docOp = op if @inflightOp != null [@inflightOp, docOp] = @_xf @inflightOp, docOp if @pendingOp != null [@pendingOp, docOp] = @_xf @pendingOp, docOp @version++ # Finally, apply the op to @snapshot and trigger any event listeners @_otApply docOp, true else if msg.meta {path, value} = msg.meta switch path?[0] when 'shout' return @emit 'shout', value else console?.warn 'Unhandled meta op:', msg else console?.warn 'Unhandled document message:', msg # Send ops to the server, if appropriate. # # Only one op can be in-flight at a time, so if an op is already on its way then # this method does nothing. flush: => return unless @connection.state == 'ok' and @inflightOp == null and @pendingOp != null # Rotate null -> pending -> inflight @inflightOp = @pendingOp @inflightCallbacks = @pendingCallbacks @pendingOp = null @pendingCallbacks = [] @connection.send {doc:@name, op:@inflightOp, v:@version} # Submit an op to the server. The op maybe held for a little while before being sent, as only one # op can be inflight at any time. submitOp: (op, callback) -> op = @type.normalize(op) if @type.normalize? # If this throws an exception, no changes should have been made to the doc @snapshot = @type.apply @snapshot, op if @pendingOp != null @pendingOp = @type.compose(@pendingOp, op) else @pendingOp = op @pendingCallbacks.push callback if callback @emit 'change', op # A timeout is used so if the user sends multiple ops at the same time, they'll be composed # & sent together. setTimeout @flush, 0 shout: (msg) => # Meta ops don't have to queue, they can go direct. Good/bad idea? @connection.send {doc:@name, meta: { path: ['shout'], value: msg } } # Open a document. The document starts closed. open: (callback) -> @autoOpen = true return unless @state is 'closed' message = doc: @name open: true message.snapshot = null if @snapshot is undefined message.type = @type.name if @type message.v = @version if @version? message.create = true if @_create @connection.send message @state = 'opening' @_openCallback = (error) => @_openCallback = null callback? error # Close a document. close: (callback) -> @autoOpen = false return callback?() if @state is 'closed' @connection.send {doc:@name, open:false} # Should this happen immediately or when we get open:false back from the server? @state = 'closed' @emit 'closing' @_closeCallback = callback # Make documents event emitters unless WEB? MicroEvent = require './microevent' MicroEvent.mixin Doc exports.Doc = Doc