browserchannel
Version:
Google BrowserChannel server for NodeJS
250 lines (196 loc) • 10.6 kB
text/coffeescript
# This is a little wrapper around browserchannels which exposes something thats compatible
# with the websocket API. It also supports automatic reconnecting, and some other goodies.
#
# You can use it just like websockets:
#
# var socket = new BCSocket '/foo'
# socket.onopen = ->
# socket.send 'hi mum!'
# socket.onmessage = (message) ->
# console.log 'got message', message
#
# ... etc. See here for specs:
# http://dev.w3.org/html5/websockets/
#
# I've also added:
#
# - You can reconnect a disconnected socket using .open().
# - .send() transparently works with JSON objects.
# - .sendMap() works as a lower level sending mechanism.
# - The second argument can be an options argument. Valid options:
# - **appVersion**: Your application's protocol version. This is passed to the server-side
# browserchannel code, in through your session handler as `session.appVersion`
# - **prev**: The previous BCSocket object, if one exists. When the socket is established,
# the previous bcsocket session will be disconnected as we reconnect.
# - **reconnect**: Tell the socket to automatically reconnect when its been disconnected.
# - **failFast**: Make the socket report errors immediately, rather than trying a few times
# first.
goog.provide 'bc.BCSocket'
goog.require 'goog.net.BrowserChannel'
goog.require 'goog.net.BrowserChannel.Handler'
goog.require 'goog.net.BrowserChannel.Error'
goog.require 'goog.net.BrowserChannel.State'
# Closure uses numerical error codes. We'll translate them into strings for the user.
errorMessages = {}
errorMessages[goog.net.BrowserChannel.Error.OK] = 'Ok'
errorMessages[goog.net.BrowserChannel.Error.LOGGED_OUT] = 'User is logging out'
errorMessages[goog.net.BrowserChannel.Error.UNKNOWN_SESSION_ID] = 'Unknown session ID'
errorMessages[goog.net.BrowserChannel.Error.STOP] = 'Stopped by server'
# All of these error messages basically boil down to "Something went wrong - try again". I can't
# imagine using different logic on the client based on the error here - just keep reconnecting.
# The client's internet is down (ping to google failed)
errorMessages[goog.net.BrowserChannel.Error.NETWORK] = 'General network error'
# The server could not be contacted
errorMessages[goog.net.BrowserChannel.Error.REQUEST_FAILED] = 'Request failed'
# This error happens when the client can't connect to the special test domain. In my experience,
# this error happens normally sometimes as well - if one particular connection doesn't
# make it through during the channel test. This will never happen with node-browserchannel anyway
# because we don't support the network admin blocking channel.
errorMessages[goog.net.BrowserChannel.Error.BLOCKED] = 'Blocked by a network administrator'
# We got an invalid response from the server
errorMessages[goog.net.BrowserChannel.Error.NO_DATA] = 'No data from server'
errorMessages[goog.net.BrowserChannel.Error.BAD_DATA] = 'Got bad data from the server'
errorMessages[goog.net.BrowserChannel.Error.BAD_RESPONSE] = 'Got a bad response from the server'
`/** @constructor */`
BCSocket = (url, options) ->
self = this
# Url can be relative or absolute. (Though an absolute URL in the browser will have to match
# same origin policy)
url ||= 'channel'
# Websocket urls are specified as ws:// or wss://. Replace the leading ws with http.
url.replace /^ws/, 'http' if url.match /:\/\//
options ||= {}
# Using websockets you can specify an array of protocol versions or a protocol version string.
# All that stuff is ignored.
options = {} if goog.isArray options or typeof options is 'string'
reconnectTime = options['reconnectTime'] or 3000
# The channel starts CLOSED. When connect() is called, the channel moves into the CONNECTING
# state. If it connects, it moves to OPEN. If an error occurs (or an error occurs while the
# connection is connected), the socket moves to 'CLOSED' again.
#
# At any time, you can call close(), which disconnects the socket.
setState = (state) -> # This is convenient for logging state changes, and increases compression.
self.readyState = self['readyState'] = state
setState @CLOSED
# The current browserchannel session we're connected through.
session = null
# When we reconnect, we'll pass the SID and AID from the previous time we successfully connected.
lastSession = options.prev
# A handler is used to receive events back out of the session.
handler = new goog.net.BrowserChannel.Handler()
handler.channelOpened = (channel) ->
lastSession = session
setState BCSocket.OPEN
self['onopen']?()
# If there's an error, the handler's channelError() method is called right before channelClosed().
# We'll cache the error so a 'disconnect' handler knows the disconnect reason.
lastErrorCode = null
# This is called when the session has the final error explaining why its closing. It is
# called only once, just before channelClosed(). It is not called if the session is manually
# disconnected.
handler.channelError = (channel, errCode) ->
message = errorMessages[errCode]
#console?.error 'channel error', errCode, message
lastErrorCode = errCode
setState BCSocket.CLOSING
# I'm not 100% sure what websockets do if there's an error like this. I'm going to assume it has the
# same behaviour as browserchannel - that is, onclose() is always called if a connection closes, and
# onerror is called whenever an error occurs.
self['onerror']? message, errCode
reconnectTimer = null
handler.channelClosed = (channel, pendingMaps, undeliveredMaps) ->
#console.error 'channelClosed', self.readyState
# Hm.
#
# I'm not sure what to do with this potentially-undelivered data. I think I'll toss it
# to the emitter and let that deal with it.
#
# I'd rather call a callback on send(), like the server does. But I can't, because
# browserchannel's API isn't rich enough.
# Should handle server stop
return if self.readyState is BCSocket.CLOSED
# And once channelClosed is called, we won't get any more events from the session. So things like send()
# should throw exceptions.
session = null
message = if lastErrorCode then errorMessages[lastErrorCode] else 'Closed'
setState BCSocket.CLOSED
# This whole method is surrounded in a try-catch block which silently discards exceptions.
# Thats really annoying for debugging. I'll make sure errors get logged here, at least.
try
self['onclose']? message, pendingMaps, undeliveredMaps
catch e
console?.error e.stack
# If the error message is STOP, we won't reconnect. That means the server has explicitly requested
# the client give up trying to reconnect due to some error.
#
# The error code will be 'OK' if close() was called on the client.
if options['reconnect'] and lastErrorCode not in [goog.net.BrowserChannel.Error.STOP, goog.net.BrowserChannel.Error.OK]
#console.warn 'rc'
# If the session ID is unknown, that means the session has timed out. We can reconnect immediately.
time = if lastErrorCode is goog.net.BrowserChannel.Error.UNKNOWN_SESSION_ID then 0 else reconnectTime
clearTimeout reconnectTimer
reconnectTimer = setTimeout reconnect, time
# make sure we don't reuse an old error message later
lastErrorCode = null
# Messages from the server are passed directly.
handler.channelHandleArray = (channel, message) ->
try
self['onmessage']? message
catch e
console?.error e.stack
throw e
# This reconnects if the current session is null.
reconnect = ->
# It should be impossible for this function to be reentrant - the only places it
# can be called from are open() below and from the setTimeout above (which is disabled
# when reconnect is called). I'll just check it anyway though, because its sort of important.
throw new Error 'Reconnect() called from invalid state' if session
setState BCSocket.CONNECTING
self['onconnecting']?()
clearTimeout reconnectTimer
session = new goog.net.BrowserChannel options['appVersion']
session.setHandler handler
lastErrorCode = null
session.setFailFast yes if options['failFast']
# Only needed for debugging..
#session.setChannelDebug(new goog.net.ChannelDebug())
session.connect "#{url}/test", "#{url}/bind", null,
lastSession?.getSessionId(), lastSession?.getLastArrayId()
# This isn't in the normal websocket interface. It reopens a previously closed websocket
# connection by reconnecting.
@['open'] = ->
# If the session is already open, you should call close() first.
throw new Error 'Already open' unless self.readyState is self.CLOSED
reconnect()
# This closes the connection and stops it from reconnecting.
@['close'] = ->
clearTimeout reconnectTimer
# I'm abusing lastErrorCode here so in the channelClosed handler I can make sure we don't
# try to reconnect.
lastErrorCode = goog.net.BrowserChannel.Error.OK
return if self.readyState is BCSocket.CLOSED
setState BCSocket.CLOSING
# In theory, we don't transition to the CLOSED state until the server has received the disconnect
# message. But in practice, disconnect() results in channelClosed() being called immediately.
# The server is still notified, but only really as an afterthought.
session.disconnect()
# TODO: Make @send to take a callback which is called when the message is either confirmed
# received or failed. The closure library has recently added a mechanism to do this.
#
# Note that you *can* send messages while the channel is connecting. Thats fine - any messages sent
# then should be sent with the initial payload.
@['sendMap'] = (map) ->
# This is the raw way to send messages. This will die if the session isn't connected.
throw new Error 'Cannot send to a closed connection' if self.readyState in [BCSocket.CLOSING, BCSocket.CLOSED]
session.sendMap map
# This sends a map of {JSON:"..."}. It is interpretted as a native message by the server.
@['send'] = (message) ->
@['sendMap'] 'JSON': goog.json.serialize message
# Websocket connections are automatically opened.
reconnect()
this
BCSocket.prototype['CONNECTING'] = BCSocket['CONNECTING'] = BCSocket.CONNECTING = 0
BCSocket.prototype['OPEN'] = BCSocket['OPEN'] = BCSocket.OPEN = 1
BCSocket.prototype['CLOSING'] = BCSocket['CLOSING'] = BCSocket.CLOSING = 2
BCSocket.prototype['CLOSED'] = BCSocket['CLOSED'] = BCSocket.CLOSED = 3
(exports ? window)['BCSocket'] = BCSocket