UNPKG

browserchannel

Version:
1,148 lines (995 loc) 46.2 kB
# # A BrowserChannel server. # # - Its still pretty young, so there's probably bugs lurking around and the API # will still change quickly. # - Its missing integration tests # # It works in all the browsers I've tried. # # I've written this using the literate programming style to try it out. So, thats why # there's a million comments everywhere. # # The server is implemented an express middleware. Its intended to be used like this: # # ``` # server = express(); # server.use(browserChannel(function(client) { client.send('hi'); })); # ``` # ## Dependancies, helper methods and constant data # `parse` helps us decode URLs in requests {parse} = require 'url' # `querystring` will help decode the URL-encoded forward channel data querystring = require 'querystring' # `fs` is used to read & serve the client library fs = require 'fs' # Client sessions are `EventEmitters` {EventEmitter} = require 'events' # Client session Ids are generated using `node-hat` hat = require('hat').rack(40, 36) # When sending messages to IE using a hidden iframe, UTF8-encoded characters # don't get processed correctly. This encodes unicode characters using the # \u2028 encoding style, which (thankfully) makes it through. asciijson = require 'ascii-json' # `randomInt(n)` generates and returns a random int smaller than n (0 <= k < n) randomInt = (n) -> Math.floor(Math.random() * n) # `randomArrayElement(array)` Selects and returns a random element from *array* randomArrayElement = (array) -> array[randomInt(array.length)] # For testing we'll override `setInterval`, etc with special testing stub versions (so # we don't have to actually wait for actual *time*. To do that, we need local variable # versions (I don't want to edit the global versions). ... and they'll just point to the # normal versions anyway. {setInterval, clearInterval, setTimeout, clearTimeout, Date} = global # The module is configurable defaultOptions = # An optional array of host prefixes. Each browserchannel client will # randomly pick from the list of host prefixes when it connects. This reduces # the impact of per-host connection limits. # # All host prefixes should point to the same server. Ie, if your server's # hostname is *example.com* and your hostPrefixes contains ['a', 'b', 'c'], # a.example.com, b.example.com and c.example.com should all point to the same # host as example.com. hostPrefixes: null # You can specify the base URL which browserchannel connects to. Change this # if you want to scope browserchannel in part of your app, or if you want # /channel to mean something else, or whatever. # # I really want to remove this parameter - express 4.0's router is now good # enough that you can just install the middleware anywhere using express. For # example: # app.use('/mycoolpath', browserchannel({base:''}, ...)); # # Unfortunately you have to force the base option to '' to do that (since it # defaults to /channel otherwise). What a pain. TODO browserchannel 3.0 base: '/channel' # We'll send keepalives every so often to make sure the http connection isn't # closed by eagar clients. The standard timeout is 30 seconds, so we'll # default to sending them every 20 seconds or so. keepAliveInterval: 20 * 1000 # After awhile (30 seconds or so) of not having a backchannel connected, # we'll evict the session completely. This will happen whenever a user closes # their browser. sessionTimeoutInterval: 30 * 1000 # By default, browsers don't allow access via javascript to foreign sites. # You can use the cors: option to set the Access-Control-Allow-Origin header # in responses, which tells browsers whether or not to allow cross domain # requests to be sent. # # See https://developer.mozilla.org/en/http_access_control for more information. # # Setting cors:'*' will enable javascript from any domain to access your # application. BE CAREFUL! If your application uses cookies to manage user # sessions, javascript on a foreign site could make requests as if it were # acting on behalf of one of your users. # # Setting cors:'X' is equivalent to adding # {headers: {'Access-Control-Allow-Origin':X}}. # # You may also set cors to a function receiving (request, response) # and returning desired header value. cors: null # Even with Access-Control-Allow-Origin enabled, browsers don't send their # cookies to different domains. You can set corsAllowCredentials to be true # to add the `Access-Control-Allow-Credentials: true` header to responses. # This tells browsers they are allowed to send credentialed requests (ie, # requests with cookies) to a foreign domain. If you do this, you must *also* # set {crossDomainXhr:true} in your BCSocket browser options to tell XHR # requests to send credentials. # # Also note that credentialed requests require explicitly mentioned domains # to work. You cannot use a wildcard cors header (`cors:*`) if you want # credentials. # # See: https://developer.mozilla.org/en-US/docs/HTTP/Access_control_CORS#Requests_with_credentials # # Setting corsAllowCredentials:true is equivalent to adding: # {headers: {'Access-Control-Allow-Credentials':true}}. corsAllowCredentials: false # A user can override all the headers if they want by setting the headers # option to an object. headers: null # All server responses set some standard HTTP headers. To be honest, I don't # know how many of these are necessary. I just copied them from google. # # The nocache headers in particular seem unnecessary since each client request # includes a randomized `zx=junk` query parameter. standardHeaders = 'Content-Type': 'text/plain' 'Cache-Control': 'no-cache, no-store, max-age=0, must-revalidate' 'Pragma': 'no-cache' 'Expires': 'Fri, 01 Jan 1990 00:00:00 GMT' 'X-Content-Type-Options': 'nosniff' # Gmail also sends this, though I'm not really sure what it does... # 'X-Xss-Protection': '1; mode=block' # The one exception to that is requests destined for iframes. They need to have # content-type: text/html set for IE to process the juicy JS inside. ieHeaders = {} ieHeaders[k] = v for k, v of standardHeaders ieHeaders['Content-Type'] = 'text/html' # Google's browserchannel server adds some junk after the first message data is # sent. I assume this stops some whole-page buffering in IE. I assume the data # used is noise so it doesn't compress. # # I don't really know why google does this. I'm assuming there's a good reason # to it though. ieJunk = "7cca69475363026330a0d99468e88d23ce95e222591126443015f5f462d9a177186c8701fb45a6ffe\ e0daf1a178fc0f58cd309308fba7e6f011ac38c9cdd4580760f1d4560a84d5ca0355ecbbed2ab715a3350fe0c47\ 9050640bd0e77acec90c58c4d3dd0f5cf8d4510e68c8b12e087bd88cad349aafd2ab16b07b0b1b8276091217a44\ a9fe92fedacffff48092ee693af\n" # If the user is using IE, instead of using XHR backchannel loaded using a # forever iframe. When data is sent, it is wrapped in <script></script> tags # which call functions in the browserchannel library. # # This method wraps the normal `.writeHead()`, `.write()` and `.end()` methods # by special versions which produce output based on the request's type. # # This **is not used** for: # # - The first channel test # - The first *bind* connection a client makes. The server sends arrays there, # but the connection is a POST and it returns immediately. So that request # happens using XHR/Trident like regular forward channel requests. messagingMethods = (options, query, res) -> type = query.TYPE if type == 'html' # IE encoding using messaging via a slowly loading script file junkSent = false methods = writeHead: -> res.writeHead 200, 'OK', ieHeaders res.write '<html><body>' domain = query.DOMAIN # If the iframe is making the request using a secondary domain, I think # we need to set the `domain` to the original domain so that we can # call the response methods. if domain and domain != '' # Make sure the domain doesn't contain anything by naughty by # `JSON.stringify()`-ing it before passing it to the client. There # are XSS vulnerabilities otherwise. res.write "<script>try{document.domain=#{asciijson.stringify domain};}catch(e){}</script>\n" write: (data) -> # The data is passed to `m()`, which is bound to *onTridentRpcMessage_* in the client. res.write "<script>try {parent.m(#{asciijson.stringify data})} catch(e) {}</script>\n" unless junkSent res.write ieJunk junkSent = true end: -> # Once the data has been received, the client needs to call `d()`, # which is bound to *onTridentDone_* with success=*true*. The weird # spacing of this is copied from browserchannel. Its really not # necessary. res.end "<script>try {parent.d(); }catch (e){}</script>\n" # This is a helper method for signalling an error in the request back to the client. writeError: (statusCode, message) -> # The HTML (iframe) handler has no way to discover that the embedded # script tag didn't complete successfully. To signal errors, we return # **200 OK** and call an exposed rpcClose() method on the page. methods.writeHead() res.end "<script>try {parent.rpcClose(#{asciijson.stringify message})} catch(e){}</script>\n" # For some reason, sending data during the second test (111112) works # slightly differently for XHR, but its identical for html encoding. We'll # use a writeRaw() method in that case, which is copied in the case of # html. methods.writeRaw = methods.write methods else # Encoding for modern browsers # For normal XHR requests, we send data normally. writeHead: -> res.writeHead 200, 'OK', options.headers write: (data) -> res.write "#{data.length}\n#{data}" writeRaw: (data) -> res.write data end: -> res.end() writeError: (statusCode, message) -> res.writeHead statusCode, options.headers res.end message # For telling the client its done bad. # # It turns out google's server isn't particularly fussy about signalling errors # using the proper html RPC stuff, so this is useful for html connections too. sendError = (res, statusCode, message, options) -> res.writeHead statusCode, message, options.headers res.end "<html><body><h1>#{message}</h1></body></html>" return # ## Parsing client maps from the forward channel # # The client sends data in a series of url-encoded maps. The data is encoded # like this: # # ``` # count=2&ofs=0&req0_x=3&req0_y=10&req1_abc=def # ``` # # First, we need to buffer up the request response and query string decode it. bufferPostData = (req, callback) -> data = [] req.on 'data', (chunk) -> data.push chunk.toString 'utf8' req.on 'end', -> data = data.join '' callback data # Next, we'll need to decode the incoming client data into an array of objects. # # The data could be in two different forms: # # - Classical browserchannel format, which is a bunch of string->string url-encoded maps # - A JSON object # # We can tell what format the data is in by inspecting the content-type header # # ## URL Encoded data # # Essentially, url encoded the data looks like this: # # ``` # { count: '2', # ofs: '0', # req0_x: '3', # req0_y: '10', # req1_abc: 'def' # } # ``` # # ... and we will return an object in the form of `[{x:'3', y:'10'}, {abc: 'def'}, ...]` # # ## JSON Encoded data # # JSON encoded the data looks like: # # ``` # { ofs: 0 # , data: [null, {...}, 1000.4, 'hi', ...] # } # ``` # # or `null` if there's no data. # # This function returns null if there's no data or {ofs, json:[...]} or {ofs, maps:[...]} transformData = (req, data) -> if req.headers['content-type'] == 'application/json' # We'll restructure it slightly to mark the data as JSON rather than maps. {ofs, data} = data {ofs, json:data} else count = parseInt data.count return null if count is 0 # ofs will be missing if count is zero ofs = parseInt data.ofs throw new Error 'invalid map data' if isNaN count or isNaN ofs throw new Error 'Invalid maps' unless count == 0 or (count > 0 and data.ofs?) maps = new Array count # Scan through all the keys in the data. Every key of the form: # `req123_xxx` will be used to populate its map. regex = /^req(\d+)_(.+)$/ for key, val of data match = regex.exec key if match id = match[1] mapKey = match[2] map = (maps[id] ||= {}) # The client uses `mapX_type=_badmap` to signify an error encoding a map. continue if id == 'type' and mapKey == '_badmap' map[mapKey] = val {ofs, maps} # Decode data string body and get an object back # Either a query string format or JSON depending on content type decodeData = (req, data) -> if req.headers['content-type'] == 'application/json' JSON.parse data else # Maps. Ugh. # # By default, querystring.parse only parses out the first 1000 keys from the data. # maxKeys:0 removes this restriction. querystring.parse data, '&', '=', maxKeys:0 # This is a helper method to order the handling of messages / requests / whatever. # # Use it like this: # inOrder = order 0 # # inOrder 1, -> console.log 'second' # inOrder 0, -> console.log 'first' # # Start is the ID of the first element we expect to receive. If we get data for # earlier elements, we'll play them anyway if playOld is truthy. order = (start, playOld) -> # Base is the ID of the (missing) element at the start of the queue base = start # The queue will start with about 10 elements. Elements of the queue are # undefined if we don't have data for that queue element. queue = new Array 10 (seq, callback) -> # Its important that all the cells of the array are truthy if we have data. # We'll use an empty function instead of null. callback or= -> # Ignore old messages, or play them back immediately if playOld=true if seq < base callback() if playOld else queue[seq - base] = callback while queue[0] callback = queue.shift() base++ callback() return # Host prefixes provide a way to skirt around connection limits. They're only # really important for old browsers. getHostPrefix = (options) -> if options.hostPrefixes randomArrayElement options.hostPrefixes else null # We need access to the client's sourcecode. I'm going to get it using a # synchronous file call (it'll be fast anyway, and only happen once). # # I'm also going to set an etag on the client data so the browser client will # be cached. I'm kind of uncomfortable about adding complexity here because its # not like this code hasn't been written before, but.. I think a lot of people # will use this API. # # I should probably look into hosting the client code as a javascript module # using that client-side npm thing. clientFile = "#{__dirname}/../dist/bcsocket.js" clientStats = fs.statSync clientFile try clientCode = fs.readFileSync clientFile, 'utf8' catch e console.error 'Could not load the client javascript. Run `cake client` to generate it.' throw e # This is mostly to help development, but if the client is recompiled, I'll # pull in a new version. This isn't tested by the unit tests - but its not a # big deal. # # The `readFileSync` call here will stop the whole server while the client is # reloaded. This will only happen during development so its not a big deal. if process.env.NODE_ENV != 'production' if process.platform is "win32" # Windows doesn't support watchFile. See: # https://github.com/josephg/node-browserchannel/pull/6 fs.watch clientFile, persistent: false, (event, filename) -> if event is "change" console.log "Reloading client JS" clientCode = fs.readFileSync clientFile, 'utf8' clientStats = curr else fs.watchFile clientFile, persistent: false, (curr, prev) -> if curr.mtime.getTime() isnt prev.mtime.getTime() console.log "Reloading client JS" clientCode = fs.readFileSync clientFile, 'utf8' clientStats = curr # This code was rewritten from closure-style to class style to make heap dumps # clearer and make the code run faster in V8 (v8 loves this code style). BCSession = (address, query, headers, options) -> EventEmitter.call this # The session's unique ID for this connection @id = hat() # The client stores its IP address and headers from when it first opened # the session. The handler can use this information for authentication or # something. @address = address @headers = headers # Add a reference to the query as users can send extra query string # information using the `extraParams` option on the Socket. @query = query # Options are passed in when creating the BrowserChannel middleware @options = options # The session is a little state machine. It has the following states: # # - **init**: The session has been created and its sessionId hasn't been # sent yet. The session moves to the **ok** state when the first data # chunk is sent to the client. # # - **ok**: The session is sitting pretty and ready to send and receive # data. The session will spend most of its time in this state. # # - **closed**: The session has been removed from the session list. It can # no longer be used for any reason. @state = 'init' # The client's reported application version, or null. This is sent when the # connection is first requested, so you can use it to make your application die / stay # compatible with people who don't close their browsers. @appVersion = query.CVER or null # The server sends messages to the client via a hanging GET request. Of course, # the client has to be the one to open that request. # # This is a handle to null, or {res, methods, chunk} # # - **res** is the http response object # - **methods** is a map of send(), etc methods for communicating properly with the backchannel - # this will be different if the request comes from IE or not. # - **chunk** specifies whether or not we're going to keep the connection open across multiple # messages. If there's a buffering proxy in the way of the connection, we can't respond a bit at # a time, so we close the backchannel after each data chunk. The client decides this during # testing and passes a CI= parameter to the server when the backchannel connection is established. # - **bytesSent** specifies how many bytes of data have been sent through the backchannel. We periodically # close the backchannel and let the client reopen it, so things like the chrome web inspector stay # usable. @_backChannel = null # The server sends data to the client by sending *arrays*. It seems a bit silly that # client->server messages are maps and server->client messages are arrays, but there it is. # # Each entry in this array is of the form [id, data]. @_outgoingArrays = [] # `lastArrayId` is the array ID of the last queued array @_lastArrayId = -1 # Every request from the client has an *AID* parameter which tells the server the ID # of the last request the client has received. We won't remove arrays from the outgoingArrays # list until the client has confirmed its received them. # # In `lastSentArrayId` we store the ID of the last array which we actually sent. @_lastSentArrayId = -1 # If we haven't sent anything for 15 seconds, we'll send a little `['noop']` to the # client so it knows we haven't forgotten it. (And to make sure the backchannel # connection doesn't time out.) @_heartbeat = null # The session will close if there's been no backchannel for awhile. @_sessionTimeout = null # Since the session doesn't start with a backchannel, we'll kick off the timeout timer as soon as its # created. @_refreshSessionTimeout() # The session has just been created. The first thing it needs to tell the client # is its session id and host prefix and stuff. # # It would be pretty easy to add a callback here setting the client status to 'ok' or # something, but its not really necessary. The client has already connected once the first # POST /bind has been received. @_queueArray ['c', @id, getHostPrefix(options), 8] # ### Maps # # The client sends maps to the server using POST requests. Its possible for the requests # to come in out of order, so sometimes we need to buffer up incoming maps and reorder them # before emitting them to the user. # # Each map has an ID (which starts at 0 when the session is first created). # We'll emit received data to the user immediately if they're in order, but if they're out of order # we'll use the little order helper above to order them. The order helper is instructed to not # emit any old messages twice. # # There's a potential DOS attack here whereby a client could just spam the server with # out-of-order maps until it runs out of memory. We should dump a session if there are # too many entries in this dictionary. @_mapBuffer = order 0, false # This method is called whenever we get maps from the client. Offset is the ID of the first # map. The data could either be maps or JSON data. If its maps, data contains {maps} and if its # JSON data, maps contains {JSON}. # # Browserchannel has 2 different mechanisms for consistantly ordering messages in the forward channel: # # - Each forward channel request contains a request ID (RID=X), which start at a random value # (set with the first session create packet). These increment by 1 with each request. # # If a request fails, it might be retried with the same RID as the previous message, and with extra # maps tacked on the end. We need to handle the maps in this case. # # - Each map has an ID, counting from 0. ofs= in the POST data tells the server the ID of the first # map in a request. # # As far as I can tell, the RID stuff can mostly be ignored. The one place it is important is in # handling disconnect messages. The session should only be disconnected by a disconnect message when # the preceeding messages have been received. # All requests are handled in order too, though if not for disconnecting I don't think it would matter. # Because of the funky retry-has-extra-maps logic, we'll allow processing requests twice. @_ridBuffer = order query.RID, true return # Sessions extend node's [EventEmitter][] so they # have access to goodies like `session.on(event, handler)`, # `session.emit('paarty')`, etc. # [EventEmitter]: http://nodejs.org/docs/v0.4.12/api/events.html do -> for name, method of EventEmitter:: BCSession::[name] = method return # The state is modified through this method. It emits events when the state # changes. (yay) BCSession::_changeState = (newState) -> oldState = @state @state = newState @emit 'state changed', @state, oldState BackChannel = (session, res, query) -> @res = res @methods = messagingMethods session.options, query, res @chunk = query.CI == '0' @bytesSent = 0 @listener = -> session._backChannel.listener = null session._clearBackChannel res return # I would like this method to be private or something, but it needs to be accessed from # the HTTP request code below. The _ at the start will hopefully make people think twice # before using it. BCSession::_setBackChannel = (res, query) -> @_clearBackChannel() @_backChannel = new BackChannel this, res, query # When the TCP connection underlying the backchannel request is closed, we'll stop using the # backchannel and start the session timeout clock. The listener is kept so the event handler # removed once the backchannel is closed. res.connection.once 'close', @_backChannel.listener # We'll start the heartbeat interval and clear out the session timeout. # The session timeout will be started again if the backchannel connection closes for # any reason. @_refreshHeartbeat() clearTimeout @_sessionTimeout # When a new backchannel is created, its possible that the old backchannel is dead. # In this case, its possible that previously sent arrays haven't been received. # By resetting lastSentArrayId, we're effectively rolling back the status of sent arrays # to only those arrays which have been acknowledged. if @_outgoingArrays.length > 0 @_lastSentArrayId = @_outgoingArrays[0].id - 1 # Send any arrays we've buffered now that we have a backchannel @flush() # This method removes the back channel and any state associated with it. It'll get called # when the backchannel closes naturally, is replaced or when the connection closes. BCSession::_clearBackChannel = (res) -> # clearBackChannel doesn't do anything if we call it repeatedly. return unless @_backChannel # Its important that we only delete the backchannel if the closed connection is actually # the backchannel we're currently using. return if res? and res != @_backChannel.res if @_backChannel.listener # The backchannel listener has been attached to the 'close' event of the underlying TCP # stream. We don't care about that anymore @_backChannel.res.connection.removeListener 'close', @_backChannel.listener @_backChannel.listener = null # Conveniently, clearTimeout has no effect if the argument is null. clearTimeout @_heartbeat @_backChannel.methods.end() @_backChannel = null # Whenever we don't have a backchannel, we run the session timeout timer. @_refreshSessionTimeout() # This method sets / resets the heartbeat timeout to the full 15 seconds. BCSession::_refreshHeartbeat = -> clearTimeout @_heartbeat session = this @_heartbeat = setInterval -> session.send ['noop'] , @options.keepAliveInterval BCSession::_refreshSessionTimeout = -> clearTimeout @_sessionTimeout session = this @_sessionTimeout = setTimeout -> session.close 'Timed out' , @options.sessionTimeoutInterval # The arrays get removed once they've been acknowledged BCSession::_acknowledgeArrays = (id) -> id = parseInt id if typeof id is 'string' while @_outgoingArrays.length > 0 and @_outgoingArrays[0].id <= id {confirmcallback} = @_outgoingArrays.shift() # I've got no idea what to do if we get an exception thrown here. The session will end up # in an inconsistant state... confirmcallback?() return OutgoingArray = (@id, @data, @sendcallback, @confirmcallback) -> # Queue an array to be sent. The optional callbacks notifies a caller when the array has been # sent, and then received by the client. # # If the session is already closed, we'll call the confirmation callback immediately with the # error. # # queueArray returns the ID of the queued data chunk. BCSession::_queueArray = (data, sendcallback, confirmcallback) -> return confirmcallback? new Error 'closed' if @state is 'closed' id = ++@_lastArrayId @_outgoingArrays.push new OutgoingArray(id, data, sendcallback, confirmcallback) return @_lastArrayId # Send the array data through the backchannel. This takes an optional callback which # will be called with no arguments when the client acknowledges the array, or called with an # error object if the client disconnects before the array is sent. # # queueArray can also take a callback argument which is called when the session sends the message # in the first place. I'm not sure if I should expose this through send - I can't tell if its # useful beyond the server code. BCSession::send = (arr, callback) -> id = @_queueArray arr, null, callback @flush() return id BCSession::_receivedData = (rid, data) -> session = this @_ridBuffer rid, -> return if data is null throw new Error 'Invalid data' unless data.maps? or data.json? session._ridBuffer rid id = data.ofs # First, classic browserchannel maps. if data.maps # If an exception is thrown during this loop, I'm not really sure what the behaviour should be. for map in data.maps # The funky do expression here is used to pass the map into the closure. # Another way to do it is to index into the data.maps array inside the function, but then I'd # need to pass the index to the closure anyway. session._mapBuffer id++, do (map) -> -> return if session.state is 'closed' session.emit 'map', map # If you specify the key as JSON, the server will try to decode JSON data from the map and emit # 'message'. This is a much nicer way to message the server. if map.JSON? try message = JSON.parse map.JSON catch e session.close 'Invalid JSON' return session.emit 'message', message # Raw string messages are embedded in a _S: property in a map. else if map._S? session.emit 'message', map._S else # We have data.json. We'll just emit it directly. for message in data.json session._mapBuffer id++, do (map) -> -> return if session.state is 'closed' session.emit 'message', message return BCSession::_disconnectAt = (rid) -> session = this @_ridBuffer rid, -> session.close 'Disconnected' # When we receive forwardchannel data, we reply with a special little 3-variable array to tell the # client if it should reopen the backchannel. # # This method returns what the forward channel should reply with. BCSession::_backChannelStatus = -> # Find the arrays have been sent over the wire but haven't been acknowledged yet numUnsentArrays = @_lastArrayId - @_lastSentArrayId unacknowledgedArrays = @_outgoingArrays[... @_outgoingArrays.length - numUnsentArrays] outstandingBytes = if unacknowledgedArrays.length == 0 0 else # We don't care about the length of the array IDs or callback functions. # I'm actually not sure what data the client expects here - the value is just used in a rough # heuristic to determine if the backchannel should be reopened. data = (a.data for a in unacknowledgedArrays) JSON.stringify(data).length return [ (if @_backChannel then 1 else 0) @_lastSentArrayId outstandingBytes ] # ## Encoding server arrays for the back channel # # The server sends data to the client in **chunks**. Each chunk is a *JSON* array prefixed # by its length in bytes. # # The array looks like this: # # ``` # [ # [100, ['message', 'one']], # [101, ['message', 'two']], # [102, ['message', 'three']] # ] # ``` # # Each individial message is prefixed by its *array id*, which is a counter starting at 0 # when the session is first created and incremented with each array. # This will actually send the arrays to the backchannel on the next tick if the backchannel # is alive. BCSession::flush = -> session = this process.nextTick -> session._flush() BCSession::_flush = -> return unless @_backChannel numUnsentArrays = @_lastArrayId - @_lastSentArrayId if numUnsentArrays > 0 arrays = @_outgoingArrays[@_outgoingArrays.length - numUnsentArrays ...] # I've abused outgoingArrays to also contain some callbacks. We only send [id, data] to # the client. data = ([id, data] for {id, data} in arrays) bytes = JSON.stringify(data) + "\n" # Stand back, pro hax! Ideally there is a general solution for escaping these characters # when converting to JSON. bytes = bytes.replace(/\u2028/g, "\\u2028") bytes = bytes.replace(/\u2029/g, "\\u2029") # **Away!** @_backChannel.methods.write bytes @_backChannel.bytesSent += bytes.length @_lastSentArrayId = @_lastArrayId # Fire any send callbacks on the messages. These callbacks should only be called once. # Again, not sure what to do if there are exceptions here. for a in arrays if a.sendcallback? a.sendcallback?() delete a.sendcallback # The send callback could have cleared the backchannel by calling close. if @_backChannel and (!@_backChannel.chunk or @_backChannel.bytesSent > 10 * 1024) @_clearBackChannel() # The first backchannel is the client's initial connection. Once we've sent the first # data chunk to the client, we've officially opened the connection. @_changeState 'ok' if @state == 'init' # Signal to a client that it should stop trying to connect. This has no other effect # on the server session. # # `stop` takes a callback which will be called once the message has been *sent* by the server. # Typically, you should call it like this: # # ``` # session.stop -> # session.close() # ``` # # I considered making this automatically close the connection after you've called it, or after # you've sent the stop message or something, but if I did that it wouldn't be obvious that you # can still receive messages after stop() has been called. (Because you can!). That would never # come up when you're testing locally, but it *would* come up in production. This is more obvious. BCSession::stop = (callback) -> return if @state is 'closed' @_queueArray ['stop'], callback, null @flush() # This closes a session and makes the server forget about it. # # The client might try and reconnect if you only call `close()`. It'll get a new session if it does so. # # close takes an optional message argument, which is passed to the send event handlers. BCSession::close = (message) -> # You can't double-close. return if @state == 'closed' @_changeState 'closed' @emit 'close', message @_clearBackChannel() clearTimeout @_sessionTimeout for {confirmcallback} in @_outgoingArrays confirmcallback? new Error(message || 'closed') return # --- # # # The server middleware # # The server module returns a function, which you can call with your # configuration options. It returns your configured connect middleware, which # is actually another function. module.exports = browserChannel = (options, onConnect) -> if typeof onConnect == 'undefined' onConnect = options options = {} options ||= {} options[option] ?= value for option, value of defaultOptions options.headers = {} unless options.headers options.headers[h] ||= v for h, v of standardHeaders options.headers['Access-Control-Allow-Origin'] = options.cors if options.cors and typeof options.cors == 'string' options.headers['Access-Control-Allow-Credentials'] = true if options.corsAllowCredentials # Strip off a trailing slash in base. base = options.base base = base[... base.length - 1] if base.match /\/$/ # Add a leading slash back on base base = "/#{base}" if base.length > 0 and !base.match /^\// # map from sessionId -> session sessions = {} # # Create a new client session. # # This method will start a new client session. # # Session ids are generated by [node-hat]. They are guaranteed to be unique. # [node-hat]: https://github.com/substack/node-hat # # This method is synchronous, because a database will never be involved in # browserchannel session management. Browserchannel sessions only last as # long as the user's browser is open. If there's any connection turbulence, # the client will reconnect and get a new session id. # # Sometimes a client will specify an old session ID and old array ID. In this # case, the client is reconnecting and we should evict the named session (if # it exists). createSession = (address, query, headers) -> {OSID: oldSessionId, OAID: oldArrayId} = query if oldSessionId? and (oldSession = sessions[oldSessionId]) oldSession._acknowledgeArrays oldArrayId oldSession.close 'Reconnected' session = new BCSession address, query, headers, options sessions[session.id] = session session.on 'close', -> delete sessions[session.id] # console.log "closed #{@id}" return session # This is the returned middleware. Connect middleware is a function which # takes in an http request, an http response and a next method. # # The middleware can do one of two things: # # - Handle the request, sending data back to the server via the response # - Call `next()`, which allows the next middleware in the stack a chance to # handle the request. middleware = (req, res, next) -> {query, pathname} = parse req.url, true #console.warn req.method, req.url # If base is /foo, we don't match /foobar. (Currently no unit tests for this) return next() if pathname.substring(0, base.length + 1) != "#{base}/" {writeHead, write, writeRaw, end, writeError} = messagingMethods options, query, res # # Serving the client # # The browserchannel server hosts a usable web client library at /CHANNEL/bcsocket.js. # This library wraps the google closure library client implementation. # # If I have time, I would like to write my own version of the client to add a few features # (websockets, message acknowledgement callbacks) and do some manual optimisations for speed. # However, the current version works ok. if pathname is "#{base}/bcsocket.js" etag = "\"#{clientStats.size}-#{clientStats.mtime.getTime()}\"" res.writeHead 200, 'OK', 'Content-Type': 'application/javascript', 'ETag': etag, 'Content-Length': clientCode.length # This code is manually tested because it looks like its impossible to send HEAD requests # using nodejs's HTTP library at time of writing (0.4.12). (Yeah, I know, rite?) if req.method is 'HEAD' res.end() else res.end clientCode # # Connection testing # # Before the browserchannel client connects, it tests the connection to make # sure its working, and to look for buffering proxies. # # The server-side code for connection testing is completely stateless. else if pathname is "#{base}/test" # This server only supports browserchannel protocol version **8**. # I have no idea if 400 is the right error here. return sendError res, 400, 'Version 8 required', options unless query.VER is '8' #### Phase 1: Server info # The client is requests host prefixes. The server responds with an array of # ['hostprefix' or null, 'blockedprefix' or null]. # # > Actually, I think you might be able to return [] if neither hostPrefix nor blockedPrefix # > is defined. (Thats what google wave seems to do) # # - **hostprefix** is subdomain prepended onto the hostname of each request. # This gets around browser connection limits. Using this requires a bank of # configured DNS entries and SSL certificates if you're using HTTPS. # # - **blockedprefix** provides network admins a way to blacklist browserchannel # requests. It is not supported by node-browserchannel. if query.MODE == 'init' and req.method == 'GET' hostPrefix = getHostPrefix options blockedPrefix = null # Blocked prefixes aren't supported. # We add an extra special header to tell the client that this server likes # json-encoded forward channel data over form urlencoded channel data. # # It might be easier to put these headers in the response body or increment the # version, but that might conflict with future browserchannel versions. headers = {} headers[k] = v for k, v of options.headers if options.cors and typeof options.cors == 'function' headers['Access-Control-Allow-Origin'] = options.cors req, res headers['X-Accept'] = 'application/json; application/x-www-form-urlencoded' # This is a straight-up normal HTTP request like the forward channel requests. # We don't use the funny iframe write methods. res.writeHead 200, 'OK', headers res.end(JSON.stringify [hostPrefix, blockedPrefix]) else #### Phase 2: Buffering proxy detection # The client is trying to determine if their connection is buffered or unbuffered. # We reply with '11111', then 2 seconds later '2'. # # The client should get the data in 2 chunks - but they won't if there's a misbehaving # corporate proxy in the way or something. writeHead() writeRaw '11111' setTimeout (-> writeRaw '2'; end()), 2000 # # BrowserChannel connection # # Once a client has finished testing its connection, it connects. # # BrowserChannel communicates through two connections: # # - The **forward channel** is used for the client to send data to the server. # It uses a **POST** request for each message. # - The **back channel** is used to get data back from the server. This uses a # hanging **GET** request. If chunking is disallowed (ie, if the proxy buffers) # then the back channel is closed after each server message. else if pathname == "#{base}/bind" # I'm copying the behaviour of unknown SIDs below. I don't know how the client # is supposed to detect this error, but, eh. The other choice is to `return writeError ...` return sendError res, 400, 'Version 8 required', options unless query.VER is '8' # All browserchannel connections have an associated client object. A client # is created immediately if the connection is new. if query.SID session = sessions[query.SID] # This is a special error code for the client. It tells the client to abandon its # connection request and reconnect. # # For some reason, google replies with the same response on HTTP and HTML requests here. # I'll follow suit, though its a little weird. Maybe I should do the same with all client # errors? return sendError res, 400, 'Unknown SID', options unless session session._acknowledgeArrays query.AID if query.AID? and session # ### Forward Channel if req.method == 'POST' if session == undefined # The session is new! Make them a new session object and let the # application know. session = createSession req.connection.remoteAddress, query, req.headers onConnect? session, req # TODO Emit 'req' for subsequent requests associated with session session.emit 'req', req dataError = (e) -> console.warn 'Error parsing forward channel', e.stack return sendError res, 400, 'Bad data', options processData = (data) -> try data = transformData req, data session._receivedData query.RID, data catch e return dataError e if session.state is 'init' # The initial forward channel request is also used as a backchannel # for the server's initial data (session id, etc). This connection # is a little bit special - it is always encoded using # length-prefixed json encoding and it is closed as soon as the # first chunk is sent. res.writeHead 200, 'OK', options.headers session._setBackChannel res, CI:1, TYPE:'xmlhttp', RID:'rpc' session.flush() else if session.state is 'closed' # If the onConnect handler called close() immediately, # session.state can be already closed at this point. I'll assume # there was an authentication problem and treat this as a forbidden # connection attempt. sendError res, 403, 'Forbidden', options else # On normal forward channels, we reply to the request by telling # the session if our backchannel is still live and telling it how # many unconfirmed arrays we have. response = JSON.stringify session._backChannelStatus() res.writeHead 200, 'OK', options.headers res.end "#{response.length}\n#{response}" if req.body processData req.body else bufferPostData req, (data) -> try data = decodeData req, data catch e return dataError e processData data else if req.method is 'GET' # ### Back channel # # GET messages are usually backchannel requests (server->client). # Backchannel messages are handled by the session object. if query.TYPE in ['xmlhttp', 'html'] return sendError res, 400, 'Invalid SID', options if typeof query.SID != 'string' && query.SID.length < 5 return sendError res, 400, 'Expected RPC', options unless query.RID is 'rpc' writeHead() session._setBackChannel res, query # The client can manually disconnect by making a GET request with TYPE='terminate' else if query.TYPE is 'terminate' # We don't send any data in the response to the disconnect message. # # The client implements this using an img= appended to the page. session?._disconnectAt query.RID res.writeHead 200, 'OK', options.headers res.end() else res.writeHead 405, 'Method Not Allowed', options.headers res.end "Method not allowed" else # We'll 404 the user instead of letting another handler take care of it. # Users shouldn't be using the specified URL prefix for anything else. res.writeHead 404, 'Not Found', options.headers res.end "Not found" middleware.close = -> for id, session of sessions session.close() return # This is an undocumented, untested treat - if you pass the HTTP server / # connect server to browserchannel through the options object, it can attach # a close listener for you automatically. options.server?.on 'close', middleware.close middleware # This will override the timer methods (`setInterval`, etc) with the testing # stub versions, which are way faster. browserChannel._setTimerMethods = (methods) -> {setInterval, clearInterval, setTimeout, clearTimeout, Date} = methods