-
Notifications
You must be signed in to change notification settings - Fork 47
/
server.coffee
1147 lines (995 loc) · 45.1 KB
/
server.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# # 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