browserchannel
Version:
Google BrowserChannel server for NodeJS
368 lines (299 loc) • 16 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.
# - **crossDomainXhr**: Set to true to enable the cross-origin credential
# flags in XHR requests. The server must send the
# Access-Control-Allow-Credentials header and can't use wildcard access
# control hostnames. This is needed if you're using host prefixes. See:
# http://www.html5rocks.com/en/tutorials/cors/#toc-withcredentials
# - **extraParams**: Extra query parameters to be sent with requests. If
# present, this should be a map of query parameter / value pairs. Note that
# these parameters are resent with every request, so you might want to think
# twice before putting a lot of stuff in here.
# - **extraHeaders**: Extra headers to add to requests. Be advised that not
# all headers are allowed by the XHR spec. Headers from NodeJS clients are
# unrestricted.
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'
goog.require 'goog.string'
# Uncomment for extra debugging information in the console. This currently breaks nodejs support
# unfortunately.
#
# goog.require 'goog.debug.Console'
# goog.debug.Console.instance = new goog.debug.Console()
# goog.debug.Console.instance.setCapturing(true
# 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) ->
return new BCSocket(url, options) unless this instanceof BCSocket
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
# Extra headers. Not all headers can be set, and the headers that can be set
# changes depending on whether we're connecting from nodejs or from the
# browser.
extraHeaders = options['extraHeaders'] or null
# Extra GET parameters
extraParams = options['extraParams'] or null
# Generate a session affinity token to send with all requests.
# For use with a load balancer that parses GET variables.
unless options['affinity'] is null
extraParams ||= {}
options['affinityParam'] ||= 'a'
@['affinity'] = options['affinity'] || goog.string.getRandomString()
extraParams[options['affinityParam']] = @['affinity']
# 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.
#console?.log "state from #{self.readyState} to #{state}"
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. This ghosts the previous session if the server
# thinks its still around. If you aren't using BCSocket's reconnection
# support, pass the old BCSocket object in to options.prev.
#
# It might be more correct here to use lastSession instead (which would mean
# we would only use a session after it has been opened).
lastSession = options['prev']?.session
# Closure has an annoyingly complicated logging system which by default will
# silently capture & discard any errors thrown in callbacks. I could enable
# the logging infrastructure (above), but I prefer to just log errors as
# needed.
#
# The callback takes three arguments because thats the max any event needs to
# pass into its callback.
fireCallback = (name, shouldThrow, a, b, c) ->
try
self[name]? a, b, c
catch e
console?.error e.stack
throw e
# 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
fireCallback 'onopen', true
# 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 "channelError #{errCode} : #{message} in state #{self.readyState}"
lastErrorCode = errCode
# If your network connection is down, you'll get General Network Errors
# passing through here even when you're not connected.
setState BCSocket.CLOSING unless self.readyState is BCSocket.CLOSED
# 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.
# If fireCallback throws, channelClosed (below) never gets called, which in
# turn causes the connection to never reconnect. We'll eat the exceptions so
# that doesn't happen.
fireCallback 'onerror', false, message, errCode
# When HTTP connection with session goes down because of network errors,
# handler uses this URL to make and HTTP request. If it succeeds, handler
# understands, that network is ok, it's just backend went down.
# It if does not succeed, user has probably disconnected from network at all.
# the URL should point to a tiny image.
handler.getNetworkTestImageUri = (obj) ->
return options['testImageUri']
reconnectTimer = null
# This will be called whenever the client disconnects or fails to connect for
# any reason. When we fail to connect, I'll also fire 'onclose' (even though
# onopen is never called!) for two reasons:
#
# - The state machine goes from CLOSED -> CONNECTING -> CLOSING -> CLOSED, so
# technically we did enter the 'close' state.
# - Thats what websockets do (onclose() is called on a websocket if it fails
# to connect).
handler.channelClosed = (channel, pendingMaps, undeliveredMaps) ->
#console.trace '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
# 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
# This happens after the reconnect timer is set so the callback can call
# close() to cancel reconnection.
fireCallback 'onclose', false, message, pendingMaps, undeliveredMaps
# make sure we don't reuse an old error message later
lastErrorCode = null
# Messages from the server are passed directly.
handler.channelHandleArray = (channel, data) ->
# Websocket onmessage handlers accept a MessageEvent object, which contains
# all sorts of other stuff unrelated to the message itself.
message =
type: 'message'
data: data
fireCallback 'onmessage', true, message
# 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
fireCallback 'onconnecting', true
clearTimeout reconnectTimer
self.session = session = new goog.net.BrowserChannel options['appVersion'], lastSession?.getFirstTestResults()
session.setSupportsCrossDomainXhrs true if options['crossDomainXhr']
session.setHandler handler
session.setExtraHeaders extraHeaders if extraHeaders
lastErrorCode = null
session.setFailFast yes if options['failFast']
# Only needed for debugging..
#session.setChannelDebug(new goog.net.ChannelDebug())
session.connect "#{url}/test", "#{url}/bind", extraParams,
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 a
# *GOOD IDEA* - any messages sent then should be sent with the initial
# payload.
@['sendMap'] = sendMap = (map) ->
# This is the raw way to send messages. We'll silently consume messages sent
# after the connection closes. This is the logic all consumers of the API
# end up implementing anyway.
if self.readyState in [BCSocket.CLOSING, BCSocket.CLOSED]
#console?.warn 'Cannot send to a closed connection'
return
session.sendMap map
# This sends a map of {JSON:"..."} or {_S:"..."}. It is interpreted as a native message by the server.
@['send'] = (message) ->
if typeof message is 'string'
sendMap '_S': message
else
sendMap 'JSON': goog.json.serialize message
# Websocket connections are automatically opened.
reconnect()
return
# Flag to tell clients they can cheat and send while the session is being
# established. Its good practice with browserchannel to send messages while
# the session is being set up - its faster for your users. But websockets
# don't support that. We could pretend that connections open immediately (for
# api compatibility), but if people bound UI to the connection state, it would
# look wrong.
#
# If you want a fast start, look for this flag.
BCSocket.prototype['canSendWhileConnecting'] = BCSocket['canSendWhileConnecting'] = true
# Flag to indicate native JSON support. The advantage of using browserchannel's
# own JSON support is that it uses the closure library's JSON.stringify /
# JSON.parse shims. These shims support old browsers.
BCSocket.prototype['canSendJSON'] = BCSocket['canSendJSON'] = true
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