nowpad
Version:
Realtime Text Collaboration
454 lines (364 loc) • 9.75 kB
text/coffeescript
# Require
fs = require 'fs'
now = require 'now'
path = require 'path'
buildr = require 'buildr'
nowpadCommon = require(__dirname+'/public/common.coffee').nowpadCommon
List = nowpadCommon.List
# -------------------------------------
# Classes
class Client
# Required
id: null
nowpad: null
# Optional
documentIds: []
# Constructor
constructor: (id,nowpad) ->
# Reset
@documentIds = []
# Apply
@id = id
@nowpad = nowpad
# Destroy
destroy: ->
# Prepare
clientId = @id
# Remove
@nowpad.clients.remove clientId
# Clear Documents
documentIds = @documentIds
@documentIds = []
# Destroy Documents
for documentId in documentIds
document = @nowpad.documents.get documentId
if document and typeof document.clientIds[clientId] is not 'undefined'
delete document.clientIds[clientId]
if document.clientIds.length is 0
document.destroy()
# Log
console.log 'Destroyed Client', clientId
class Document
# Required
id: null
nowpad: null
# Optional
value: ''
state: false
delay: 200
states: []
offset: 0
locked: false
clientIds: []
# Constructor
constructor: (id,value,nowpad) ->
# Reset
@states = []
@clientIds = []
# Apply
@id = id
@value = value if value
@nowpad = nowpad
# Lock
lock: (clientId) ->
success = false
if !@locked
@locked = clientId
success = true
console.log 'Client '+clientId+' locked the document '+@id
else
console.log 'Client '+clientId+' failed to lock the document '+@id
console.log 'it\'s currently locked by client '+@locked
return success
# Unlock
unlock: (clientId) ->
success = false
if @locked is clientId
@locked = false
success = true
console.log 'Client '+clientId+' unlocked the document '+@id
else
console.log 'Client '+clientId+' failed to unlock the document '+@id
console.log 'it\'s currently locked by client '+@locked
return success
# Destroy
destroy: ->
# Prepare
documentId = @id
# Remvoe
@nowpad.clients.remove documentId
# Clear Clients
clientIds = @clientIds
@clientIds = []
# Destroy Clients
for clientId in clientIds
client = @nowpad.clients.get clientId
if client and typeof document.documentIds[documentId] is not 'undefined'
delete document.documentIds[documentId]
if client.documentIds.length is 0
client.destroy()
# Log
console.log 'Destroyed Document', documentId
class DocumentList extends List
# Init
constructor: (nowpad) ->
@nowpad = nowpad
# Get a document, or create it if it doesn't exist
# next(err,document)
fetch: (id,next) ->
document = @get id
if document
next false, document
else if @nowpad.requestHandler
@nowpad.requestHandler id, =>
document = @get id
if document
next false, document
else
next Error 'Could not fetch the document '+id
else
document = new Document id, false, @nowpad
@nowpad.documents.add document
next false, document
# -------------------------------------
# Server
class Nowpad
# Server
server: null
everyone: null
port: null
buildrConfig:
# Paths
srcPath: __dirname+'/public'
outPath: __dirname+'/public'
# Checking
checkScripts: false
# Compression (requires outPath)
compressScripts: false # Array or true or false
# Order
scriptsOrder: [
'diff_match_patch.js'
'common.coffee'
'client.coffee'
]
# Bundling
bundleScriptPath: __dirname+'/public/out.js'
# Nowpad
documents: null
clients: null
requestHandler: null
# Events
events:
sync: []
disconnected: []
# Initialise
constructor: ({server,everyone}={}) ->
# Prepare
@server = server
@everyone = everyone || now.initialize @server, {clientWrite: false}
# Clean
@documents = new DocumentList(@)
@clients = new List()
# Now
@nowBind()
# Cache
@cacheClientScript()
# Routes
@server.get '/nowpad/nowpad.js', (req,res) =>
@serveClientScript(req,res)
# Clean
@documents = new DocumentList(@)
@clients = new List()
# Cache the client script
cacheClientScript: ->
# Build
mercuryBuildr = buildr.createInstance @buildrConfig
mercuryBuildr.process (err) =>
throw err if err
console.log 'Building completed'
fs.readFile @buildrConfig.bundleScriptPath, (err,data) =>
throw err if err
@fileString = data.toString()
# Server the client script
serveClientScript: (req,res) ->
console.log @fileString
res.writeHead 200, 'content-type': 'text/javascript'
res.write @fileString
res.end()
# Log
log: ->
console.log(
clients: @clients
documents: @documents
)
# Add document
addDocument: (documentId,value) ->
unless @documents.has documentId
document = new Document documentId, value, @
@documents.add document
# Delete document
delDocument: (documentId) ->
document = @documents.get documentId
if document
document.destroy()
# Request document
requestDocument: (requestHandler) ->
if @requestHandler
throw new Error 'Request handler already defined'
@requestHandler = requestHandler
# Initialise Now.js
nowBind: ->
nowpad = @
everyone = @everyone
# A client has connected
everyone.on 'join', ->
# Create the new client
clientId = nowpad.clients.generateId()
client = new Client(clientId,nowpad)
nowpad.clients.add(client)
# Associate it with now
@now.clientId = clientId
# Log
console.log 'New Client:', clientId
# A client has disconnected
everyone.on 'leave', ->
# Fetch
clientId = @now.clientId
nowpad.clients.destroy @now.clientId
# Log
console.log 'Bye Client:', clientId
# A client is shaking hands with the server
everyone.now.nowpad_handshake = (notifySync,notifyDelay,callback) ->
# Check the user isn't evil
if (typeof notifySync isnt 'function') or (typeof notifyDelay isnt 'function')
console.log 'Evil client'
return false
# Apply the client-side functions used to notify the client to the now session
@now.nowpad_notifySync = notifySync
@now.nowpad_notifyDelay = notifyDelay
# Trigger the callback
if callback then callback(@now.clientId)
# Create a timer to ensure locks don't last forever
lockTimer = false
lockTimerDelay = 1500
# Lock a document
everyone.now.nowpad_lockDocument = (documentId, callback) ->
# Fetch Document
nowpad.documents.fetch documentId, (err,document) =>
# Error
if err
console.log err
return
# Attempt document lock
result = document.lock @now.clientId
# If success, set a timeout
if result
lockTimerCallback = =>
clearTimeout lockTimer
lockTimer = false
console.log '\n!!! A lock has lasted too long... !!!\n'
document.unlock @now.clientId
lockTimer = setTimeout lockTimerCallback, lockTimerDelay
# Send result back to client
if callback then callback result
# Unlock
everyone.now.nowpad_unlockDocument = (documentId, callback) ->
# Fetch Document
nowpad.documents.fetch documentId, (err,document) =>
# Error
if err
console.log err
return
# Attempt document unlock
result = document.unlock @now.clientId
# Clear timeout
clearTimeout lockTimer
lockTimer = false
# Send result back to client
if callback then callback result
# Log
everyone.now.nowpad_log = ->
nowpad.log()
# A document is preparing for sync
everyone.now.nowpad_valueSyncDocument = (documentId, callback) ->
# Fetch document
nowpad.documents.fetch documentId, (err,document) =>
# Error
if err
console.log err
return
# Fetch values
state = document.state
value = document.value
delay = document.delay
# Send back
callback state, value, delay
# Log
console.log 'Valuing', @now.clientId, 'for document', documentId
# Sync
everyone.now.nowpad_patchSyncDocument = (documentId,clientState,patch,callback) ->
# Fetch document
nowpad.documents.fetch documentId, (err,document) =>
# Error
if err
console.log err
return
# Prepare
stateQueue = []
document.state = document.state || 0
# Log
console.log '\nSyncing ['+@now.clientId+'/'+documentId+']'
#console.log document
console.log ''
# Update Client
if clientState isnt document.state
# Requires Updates
# Add patches
stateQueue = document.states.slice clientState
# Log
console.log 'Syncing from', clientState, 'to', document.state
console.log stateQueue
console.log ''
# Update Server
if patch
# Update
document.state++
# Log
console.log 'Received patch:', @now.clientId, 'from', clientState, 'to', document.state
console.log patch
console.log ''
# State
State =
id: document.state
patch: patch
clientId: @now.clientId
# Add
stateQueue.push State
document.states.push State
# Apply
result = nowpadCommon.applyPatch patch, document.value
document.value = result.value
# Return updates to client
callback(stateQueue,document.state)
# Notify other clients
if patch
# Notify nowpad clients
everyone.now.nowpad_notifySync document.id, document.state
# Notify application
nowpad.trigger 'sync', [document.id, document.value, document.state]
# Bind
bind: (event,callback) ->
if typeof @events[event] is 'undefined'
throw new Error 'Unauthorised event: '+event
else
@events[event].push callback
# Trigger
trigger: (event,args) ->
for callback in @events[event]
callback.apply(callback,args)
# API
nowpad =
createInstance: (config) ->
return new Nowpad(config)
# Export
module.exports = nowpad