Merge pull request #7 from keroserene/proxy

snowflake proxy
This commit is contained in:
Serene H 2016-01-10 13:03:46 -08:00
commit 93a9aabfd1
3 changed files with 473 additions and 563 deletions

471
proxy/snowflake.coffee Normal file
View file

@ -0,0 +1,471 @@
###
A Coffeescript WebRTC snowflake proxy
Using Copy-paste signaling for now.
Uses WebRTC from the client, and websocket to the server.
Assume that the webrtc client plugin is always the offerer, in which case
this must always act as the answerer.
TODO(keroserene): Complete the websocket + webrtc ProxyPair
###
Query =
###
Parse a URL query string or application/x-www-form-urlencoded body. The
return type is an object mapping string keys to string values. By design,
this function doesn't support multiple values for the same named parameter,
for example "a=1&a=2&a=3"; the first definition always wins. Returns null on
error.
Always decodes from UTF-8, not any other encoding.
http://dev.w3.org/html5/spec/Overview.html#url-encoded-form-data
###
parse: (qs) ->
result = {}
strings = []
strings = qs.split '&' if qs
return result if 0 == strings.length
for i in [1..strings.length]
string = strings[i]
j = string.indexOf '='
if j == -1
name = string
value = ''
else
name = string.substr(0, j)
value = string.substr(j + 1)
name = decodeURIComponent(name.replace(/\+/g, ' '))
value = decodeURIComponent(value.replace(/\+/g, ' '))
result[name] = value if !(name in result)
result
# params is a list of (key, value) 2-tuples.
buildString: (params) ->
parts = []
for param in params
parts.push encodeURIComponent(param[0]) + '=' +
encodeURIComponent(param[1])
parts.join '&'
Params =
getBool: (query, param, defaultValue) ->
val = query[param]
return defaultValue if undefined == val
return true if "true" == val || "1" == val || "" == val
return false if "false" == val || "0" == val
return null
# repr = (x) ->
# return 'null' if null == x
# return 'undefined' if 'undefined' == typeof x
# if 'object' == typeof x
# elems = []
# for k in x
# elems.push(maybe_quote(k) + ': ' + repr(x[k]));
# return "{ " + elems.join(", ") + " }";
# } else if (typeof x === "string") {
# return quote(x);
# } else {
# return x.toString();
# safe_repr = (s) -> SAFE_LOGGING ? "[scrubbed]" : repr(s)
safe_repr = (s) -> SAFE_LOGGING ? "[scrubbed]" : JSON.stringify(s)
# HEADLESS is true if we are running not in a browser with a DOM.
query = Query.parse(window.location.search.substr(1))
HEADLESS = "undefined" == typeof(document)
DEBUG = Params.getBool(query, "debug", false)
# TODO: Different ICE servers.
config = {
iceServers: [
{ urls: ["stun:stun.l.google.com:19302"] }
]
}
# DOM elements
$chatlog = null
$send = null
$input = null
window.PeerConnection = window.RTCPeerConnection ||
window.mozRTCPeerConnection ||
window.webkitRTCPeerConnection
window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate;
window.RTCSessionDescription = window.RTCSessionDescription ||
window.mozRTCSessionDescription
# TODO: Implement
class Badge
# Janky state machine
MODE =
INIT: 0
WEBRTC_CONNECTING: 1
WEBRTC_READY: 2
# Minimum viable snowflake for now - just 1 client.
class Snowflake
# PeerConnection
pc: null
rateLimit: 0
proxyPairs: []
badge: null
$badge: null
MAX_NUM_CLIENTS = 1
CONNECTIONS_PER_CLIENT = 1
state: MODE.INIT
constructor: ->
if HEADLESS
# No badge
else if DEBUG
@$badge = debug_div
else
@badge = new Badge()
@$badgem = @badge.elem
if (@$badge)
@$badge.setAttribute("id", "snowflake-badge")
# Initialize WebRTC PeerConnection
beginWebRTC: ->
log "Starting up Snowflake..."
@state = MODE.WEBRTC_CONNECTING
@pc = new PeerConnection(config, {
optional: [
{ DtlsSrtpKeyAgreement: true }
{ RtpDataChannels: false }
]
})
@pc.onicecandidate = (evt) =>
# Browser sends a null candidate once the ICE gathering completes.
# In this case, it makes sense to send one copy-paste blob.
if null == evt.candidate
log "Finished gathering ICE candidates."
Signalling.send @pc.localDescription
# OnDataChannel triggered remotely from the client when connection succeeds.
@pc.ondatachannel = (dc) =>
console.log dc;
channel = dc.channel
log "Data Channel established..."
@prepareDataChannel channel
prepareDataChannel: (channel) ->
channel.onopen = =>
log "Data channel opened!"
@state = MODE.WEBRTC_READY
# TODO: Prepare ProxyPair onw.
@beginProxy()
channel.onclose = =>
log "Data channel closed."
@state = MODE.INIT;
$chatlog.className = ""
channel.onerror = =>
log "Data channel error!"
channel.onmessage = (msg) =>
line = recv = msg.data
console.log(msg);
# Go sends only raw bytes...
if "[object ArrayBuffer]" == recv.toString()
bytes = new Uint8Array recv
line = String.fromCharCode.apply(null, bytes)
line = line.trim()
log "data: " + line
# Receive an SDP offer from client plugin.
receiveOffer: (desc) =>
sdp = new RTCSessionDescription desc
try
err = @pc.setRemoteDescription sdp
catch e
log "Invalid SDP message."
return false
log("SDP " + sdp.type + " successfully received.")
@sendAnswer() if 'offer' == sdp.type
true
sendAnswer: =>
next = (sdp) =>
log "webrtc: Answer ready."
@pc.setLocalDescription sdp
promise = @pc.createAnswer next
promise.then next if promise
# Poll facilitator when this snowflake can support more clients.
proxyMain: ->
if @proxyPairs.length >= @MAX_NUM_CLIENTS * @CONNECTIONS_PER_CLIENT
setTimeout(@proxyMain, @facilitator_poll_interval * 1000)
return
params = [["r", "1"]]
params.push ["transport", "websocket"]
params.push ["transport", "webrtc"]
beginProxy: (client, relay) ->
for i in [1..CONNECTIONS_PER_CLIENT]
@makeProxyPair client, relay
makeProxyPair: (client, relay) ->
pair = new ProxyPair(client, relay, @rate_limit);
@proxyPairs.push pair
pair.onCleanup = (event) =>
# Delete from the list of active proxy pairs.
@proxyPairs.splice(@proxy_pairs.indexOf(pair), 1)
@badge.endProxy() if @badge
try
pair.connectRelay()
catch err
log 'ERROR: ProxyPair exception while connecting.'
log err
return
@badge.beginProxy if @badge
cease: ->
while @proxyPairs.length > 0
@proxyPairs.pop().close()
disable: ->
log "Disabling Snowflake."
@cease()
@badge.disable() if @badge
die: ->
log "Snowflake died."
@cease()
@badge.die() if @badge
# Build an escaped URL string from unescaped components. Only scheme and host
# are required. See RFC 3986, section 3.
buildUrl = (scheme, host, port, path, params) ->
parts = []
parts.push(encodeURIComponent scheme)
parts.push '://'
# If it contains a colon but no square brackets, treat it as IPv6.
if host.match(/:/) && !host.match(/[[\]]/)
parts.push '['
parts.push host
parts.push ']'
else
parts.push(encodeURIComponent host)
if undefined != port && DEFAULT_PORTS[scheme] != port
parts.push ':'
parts.push(encodeURIComponent port.toString())
if undefined != path && '' != path
if !path.match(/^\//)
path = '/' + path
###
Slash is significant so we must protect it from encodeURIComponent, while
still encoding question mark and number sign. RFC 3986, section 3.3: "The
path is terminated by the first question mark ('?') or number sign ('#')
character, or by the end of the URI. ... A path consists of a sequence of
path segments separated by a slash ('/') character."
###
path = path.replace /[^\/]+/, (m) ->
encodeURIComponent m
parts.push path
if undefined != params
parts.push '?'
parts.push Query.buildString params
parts.join ''
makeWebsocket = (addr) ->
url = buildUrl 'ws', addr.host, addr.port, '/'
if have_websocket_binary_frames()
ws = new WebSocket url
else
ws = new WebSocket url 'base64'
###
"User agents can use this as a hint for how to handle incoming binary data: if
the attribute is set to 'blob', it is safe to spool it to disk, and if it is
set to 'arraybuffer', it is likely more efficient to keep the data in memory."
###
ws.binaryType = 'arraybuffer'
ws
# TODO: Implement
class ProxyPair
# TODO: Hardcoded for now, but should fetch from facilitator later.
relayAddr: null
constructor: (@clientAddr, @relayAddr, @rateLimit) ->
# Assumes WebRTC part is already connected.
# TODO: Put the webrtc stuff in ProxyPair, so that multiple webrtc connections
# can be established.
connectRelay: ->
log "Snowflake: connecting to relay"
@relay = makeWebsocket(@relayAddr);
@relay.label = 'Relay'
@relay.onopen = =>
log "Snowflake: " + ws.label + "connected"
@relay.onclose = @onClose
@relay.onerror = @onError
@relay.onmessage = @onRelayToClientMessage
onClientToRelayMessage: (event) ->
@c2r_schedule.push event.data
@flush()
onRelayToClientMessage: (event) ->
@r2c_schedule.push event.data
@flush()
onClose: (event) ->
ws = event.target
log(ws.label + ': closed.')
@flush()
@maybeCleanup()
onError: (event) ->
ws = event.target
log ws.label + ': error.'
this.close();
# we can't rely on onclose_callback to cleanup, since one common error
# case is when the client fails to connect and the relay never starts.
# in that case close() is a NOP and onclose_callback is never called.
@maybeCleanup()
isOpen: (ws) -> undefined != ws && WebSocket.OPEN == ws.readyState
isClosed: (ws) -> undefined == ws || WebSocket.CLOSED == ws.readyState
maybe_cleanup: ->
if @running && @isClosed(client) && @isClosed @relay
@running = false
@cleanup_callback()
true
false
# Send as much data as the rate limit currently allows.
###
flush: ->
clearTimeout @flush_timeout_id if @flush_timeout_id
@flush_timeout_id = null
busy = true
checkChunks = ->
busy = false
# if @isOpen @clientthis.client_s) &&
# this.client_s.bufferedAmount < MAX_BUFFER &&
# this.r2c_schedule.length > 0) {
# chunk = this.r2c_schedule.shift();
# this.rate_limit.update(chunk.length);
# this.client_s.send(chunk);
# busy = true;
#
if @isOpen @relay &&
@relay.bufferedAmount < MAX_BUFFER &&
@c2r_schedule.length > 0
chunk = @c2r_schedule.shift()
@rate_limit.update chunk.length
@relay.send chunk
busy = true
checkChunks() while busy && !@rate_limit.is_limited()
if @isClosed @relay &&
# !isClosed(this.client_s) &&
# @client_s.bufferedAmount === 0 &&
@r2c_schedule.length == 0
# log("Client: closing.");
# this.client_s.close();
# if (is_closed(this.client_s) &&
# !is_closed(this.relay_s) &&
# this.relay_s.bufferedAmount === 0 &&
# this.c2r_schedule.length === 0) {
# log("Relay: closing.");
# this.relay_s.close();
# }
while busy && !@rate_limit.is_limited()
if this.r2c_schedule.length > 0 ||
(@isOpen(@client) && @client.bufferedAmount > 0) ||
@c2r_schedule.length > 0 ||
(@isOpen(@relay) && @relay.bufferedAmount > 0)
@flush_timeout_id = setTimeout @flush, @rate_limit.when() * 1000
###
#
## -- DOM & Input Functionality -- ##
#
snowflake = null
welcome = ->
log "== snowflake browser proxy =="
log "Input offer from the snowflake client:"
# Log to the message window.
log = (msg) ->
$chatlog.value += msg + "\n"
console.log msg
# Scroll to latest
$chatlog.scrollTop = $chatlog.scrollHeight
Interface =
# Local input from keyboard into message window.
acceptInput: ->
msg = $input.value
switch snowflake.state
when MODE.WEBRTC_CONNECTING
Signalling.receive msg
when MODE.WEBRTC_READY
log "No input expected - WebRTC connected."
# data = msg
# log(data)
# channel.send(data)
else
log "ERROR: " + msg
$input.value = ""
$input.focus()
# Signalling channel - just tells user to copy paste to the peer.
# Eventually this should go over the facilitator.
Signalling =
send: (msg) ->
log "---- Please copy the below to peer ----\n"
log JSON.stringify(msg)
log "\n"
receive: (msg) ->
recv = ""
try
recv = JSON.parse msg
catch e
log "Invalid JSON."
return
desc = recv['sdp']
if !desc
log "Invalid SDP."
return false
snowflake.receiveOffer recv if desc
init = ->
$chatlog = document.getElementById('chatlog')
$chatlog.value = ""
$send = document.getElementById('send')
$send.onclick = Interface.acceptInput
$input = document.getElementById('input')
$input.focus()
$input.onkeydown = (e) =>
if 13 == e.keyCode # enter
$send.onclick()
snowflake = new Snowflake()
snowflake.beginWebRTC()
welcome()
window.onload = init

View file

@ -2,7 +2,8 @@
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<script src="snowflake.js"></script>
<script type="text/javascript" src="lib/coffee-script.js"></script>
<script type="text/coffeescript" src="snowflake.coffee"></script>
<style>
* {
box-sizing: border-box;

View file

@ -1,562 +0,0 @@
/*
JS WebRTC proxy
Copy-paste signaling.
*/
// DOM elements
var $chatlog, $input, $send, $name;
var config = {
iceServers: [
{ urls: ["stun:stun.l.google.com:19302"] }
]
}
// Chrome / Firefox compatibility
window.PeerConnection = window.RTCPeerConnection ||
window.mozRTCPeerConnection || window.webkitRTCPeerConnection;
window.RTCIceCandidate = window.RTCIceCandidate || window.mozRTCIceCandidate;
window.RTCSessionDescription = window.RTCSessionDescription || window.mozRTCSessionDescription;
var pc; // PeerConnection
var offer, answer;
var channel;
// Janky state machine
var MODE = {
INIT: 0,
CONNECTING: 1,
CHAT: 2
}
var currentMode = MODE.INIT;
var CONNECTIONS_PER_CLIENT = 2;
// Signalling channel - just tells user to copy paste to the peer.
// Eventually this should go over the facilitator.
var Signalling = {
send: function(msg) {
log("---- Please copy the below to peer ----\n");
log(JSON.stringify(msg));
log("\n");
},
receive: function(msg) {
var recv;
try {
recv = JSON.parse(msg);
} catch(e) {
log("Invalid JSON.");
return;
}
if (!pc) {
start(false);
}
var desc = recv['sdp']
var ice = recv['candidate']
if (!desc && ! ice) {
log("Invalid SDP.");
return false;
}
if (desc) { receiveDescription(recv); }
if (ice) { receiveICE(recv); }
}
}
function welcome() {
log("== snowflake JS proxy ==");
log("Input offer from the snowflake client:");
}
function start(initiator) {
log("Starting up RTCPeerConnection...");
pc = new PeerConnection(config, {
optional: [
{ DtlsSrtpKeyAgreement: true },
{ RtpDataChannels: false },
],
});
pc.onicecandidate = function(evt) {
var candidate = evt.candidate;
// Chrome sends a null candidate once the ICE gathering phase completes.
// In this case, it makes sense to send one copy-paste blob.
if (null == candidate) {
log("Finished gathering ICE candidates.");
Signalling.send(pc.localDescription);
return;
}
}
pc.onnegotiationneeded = function() {
sendOffer();
}
pc.ondatachannel = function(dc) {
console.log(dc);
channel = dc.channel;
log("Data Channel established... ");
prepareDataChannel(channel);
}
// Creating the first data channel triggers ICE negotiation.
if (initiator) {
channel = pc.createDataChannel("test");
prepareDataChannel(channel);
}
}
// Local input from keyboard into chat window.
function acceptInput(is) {
var msg = $input.value;
switch (currentMode) {
case MODE.INIT:
if (msg.startsWith("start")) {
start(true);
} else {
Signalling.receive(msg);
}
break;
case MODE.CONNECTING:
Signalling.receive(msg);
break;
case MODE.CHAT:
var data = msg;
log(data);
channel.send(data);
break;
default:
log("ERROR: " + msg);
}
$input.value = "";
$input.focus();
}
// Chrome uses callbacks while Firefox uses promises.
// Need to support both - same for createAnswer below.
function sendOffer() {
var next = function(sdp) {
log("webrtc: Created Offer");
offer = sdp;
pc.setLocalDescription(sdp);
}
var promise = pc.createOffer(next);
if (promise) {
promise.then(next);
}
}
function sendAnswer() {
var next = function (sdp) {
log("webrtc: Created Answer");
answer = sdp;
pc.setLocalDescription(sdp)
}
var promise = pc.createAnswer(next);
if (promise) {
promise.then(next);
}
}
function receiveDescription(desc) {
var sdp = new RTCSessionDescription(desc);
try {
err = pc.setRemoteDescription(sdp);
} catch (e) {
log("Invalid SDP message.");
return false;
}
log("SDP " + sdp.type + " successfully received.");
if ("offer" == sdp.type) {
sendAnswer();
}
return true;
}
function receiveICE(ice) {
var candidate = new RTCIceCandidate(ice);
try {
pc.addIceCandidate(candidate);
} catch (e) {
log("Could not add ICE candidate.");
return;
}
log("ICE candidate successfully received: " + ice.candidate);
}
function waitForSignals() {
currentMode = MODE.CONNECTING;
}
function prepareDataChannel(channel) {
channel.onopen = function() {
log("Data channel opened!");
startChat();
}
channel.onclose = function() {
log("Data channel closed.");
currentMode = MODE.INIT;
$chatlog.className = "";
log("------- chat disabled -------");
}
channel.onerror = function() {
log("Data channel error!!");
}
channel.onmessage = function(msg) {
var recv = msg.data;
console.log(msg);
// Go sends only raw bytes.
if ("[object ArrayBuffer]" == recv.toString()) {
var bytes = new Uint8Array(recv);
line = String.fromCharCode.apply(null, bytes);
} else {
line = recv;
}
line = line.trim();
log(line);
}
}
function startChat() {
currentMode = MODE.CHAT;
$chatlog.className = "active";
log("------- chat enabled! -------");
}
// Get DOM elements and setup interactions.
function init() {
console.log("loaded");
// Setup chatwindow.
$chatlog = document.getElementById('chatlog');
$chatlog.value = "";
$send = document.getElementById('send');
$send.onclick = acceptInput
$input = document.getElementById('input');
$input.focus();
$input.onkeydown = function(e) {
if (13 == e.keyCode) { // enter
$send.onclick();
}
}
welcome();
}
var log = function(msg) {
$chatlog.value += msg + "\n";
console.log(msg);
// Scroll to latest.
$chatlog.scrollTop = $chatlog.scrollHeight;
}
window.onload = init;
//
// some code sourced from flashproxy.js
// TODO: refactor / webrtc-afy it
//
/* Does the WebSocket implementation in this browser support binary frames? (RFC
6455 section 5.6.) If not, we have to use base64-encoded text frames. It is
assumed that the client and relay endpoints always support binary frames. */
function have_websocket_binary_frames() {
var BROWSERS = [
{ idString: "Chrome", verString: "Chrome", version: 16 },
{ idString: "Safari", verString: "Version", version: 6 },
{ idString: "Firefox", verString: "Firefox", version: 11 }
];
var ua;
ua = window.navigator.userAgent;
if (!ua)
return false;
for (var i = 0; i < BROWSERS.length; i++) {
var matches, reg;
reg = "\\b" + BROWSERS[i].idString + "\\b";
if (!ua.match(new RegExp(reg, "i")))
continue;
reg = "\\b" + BROWSERS[i].verString + "\\/(\\d+)";
matches = ua.match(new RegExp(reg, "i"));
return matches !== null && Number(matches[1]) >= BROWSERS[i].version;
}
return false;
}
function make_websocket(addr) {
var url;
var ws;
url = build_url("ws", addr.host, addr.port, "/");
if (have_websocket_binary_frames())
ws = new WebSocket(url);
else
ws = new WebSocket(url, "base64");
/* "User agents can use this as a hint for how to handle incoming binary
data: if the attribute is set to 'blob', it is safe to spool it to disk,
and if it is set to 'arraybuffer', it is likely more efficient to keep
the data in memory." */
ws.binaryType = "arraybuffer";
return ws;
}
function Snowflake() {
if (HEADLESS) {
/* No badge. */
} else if (DEBUG) {
this.badge_elem = debug_div;
} else {
this.badge = new Badge();
this.badge_elem = this.badge.elem;
}
if (this.badge_elem)
this.badge_elem.setAttribute("id", "flashproxy-badge");
this.proxy_pairs = [];
this.start = function() {
var client_addr;
var relay_addr;
var rate_limit_bytes;
log("Snowflake starting.")
// TODO: Facilitator interaction
};
this.proxy_main = function() {
var params;
var base_url, url;
var xhr;
if (this.proxy_pairs.length >= this.max_num_clients * CONNECTIONS_PER_CLIENT) {
setTimeout(this.proxy_main.bind(this), this.facilitator_poll_interval * 1000);
return;
}
params = [["r", "1"]];
params.push(["transport", "websocket"]);
params.push(["transport", "webrtc"]);
};
this.begin_proxy = function(client_addr, relay_addr) {
for (var i=0; i<CONNECTIONS_PER_CLIENT; i++) {
this.make_proxy_pair(client_addr, relay_addr);
}
};
this.make_proxy_pair = function(client_addr, relay_addr) {
var proxy_pair;
proxy_pair = new ProxyPair(client_addr, relay_addr, this.rate_limit);
this.proxy_pairs.push(proxy_pair);
proxy_pair.cleanup_callback = function(event) {
/* Delete from the list of active proxy pairs. */
this.proxy_pairs.splice(this.proxy_pairs.indexOf(proxy_pair), 1);
if (this.badge)
this.badge.proxy_end();
}.bind(this);
try {
proxy_pair.connect();
} catch (err) {
puts("ProxyPair: exception while connecting: " + safe_repr(err.message) + ".");
return;
}
if (this.badge)
this.badge.proxy_begin();
};
/* Cease all network operations and prevent any future ones. */
this.cease_operation = function() {
this.start = function() { };
this.proxy_main = function() { };
this.make_proxy_pair = function(client_addr, relay_addr) { };
while (this.proxy_pairs.length > 0)
this.proxy_pairs.pop().close();
};
this.disable = function() {
puts("Disabling.");
this.cease_operation();
if (this.badge)
this.badge.disable();
};
this.die = function() {
puts("Dying.");
this.cease_operation();
if (this.badge)
this.badge.die();
};
}
/* An instance of a client-relay connection. */
function ProxyPair(client_addr, relay_addr, rate_limit) {
var MAX_BUFFER = 10 * 1024 * 1024;
function log(s) {
if (!SAFE_LOGGING) {
s = format_addr(client_addr) + '|' + format_addr(relay_addr) + ' : ' + s
}
// puts(s)
window.log(s)
}
this.client_addr = client_addr;
this.relay_addr = relay_addr;
this.rate_limit = rate_limit;
this.c2r_schedule = [];
this.r2c_schedule = [];
this.running = true;
this.flush_timeout_id = null;
/* This callback function can be overridden by external callers. */
this.cleanup_callback = function() {
};
this.connect = function() {
log("Client: connecting.");
this.client_s = make_websocket(this.client_addr);
/* Try to connect to the client first (since that is more likely to
fail) and only after that try to connect to the relay. */
this.client_s.label = "Client";
this.client_s.onopen = this.client_onopen_callback;
this.client_s.onclose = this.onclose_callback;
this.client_s.onerror = this.onerror_callback;
this.client_s.onmessage = this.onmessage_client_to_relay;
};
this.client_onopen_callback = function(event) {
var ws = event.target;
log(ws.label + ": connected.");
log("Relay: connecting.");
this.relay_s = make_websocket(this.relay_addr);
this.relay_s.label = "Relay";
this.relay_s.onopen = this.relay_onopen_callback;
this.relay_s.onclose = this.onclose_callback;
this.relay_s.onerror = this.onerror_callback;
this.relay_s.onmessage = this.onmessage_relay_to_client;
}.bind(this);
this.relay_onopen_callback = function(event) {
var ws = event.target;
log(ws.label + ": connected.");
}.bind(this);
this.maybe_cleanup = function() {
if (this.running && is_closed(this.client_s) && is_closed(this.relay_s)) {
this.running = false;
this.cleanup_callback();
return true;
}
return false;
}
this.onclose_callback = function(event) {
var ws = event.target;
log(ws.label + ": closed.");
this.flush();
if (this.maybe_cleanup()) {
puts("Complete.");
}
}.bind(this);
this.onerror_callback = function(event) {
var ws = event.target;
log(ws.label + ": error.");
this.close();
// we can't rely on onclose_callback to cleanup, since one common error
// case is when the client fails to connect and the relay never starts.
// in that case close() is a NOP and onclose_callback is never called.
this.maybe_cleanup();
}.bind(this);
this.onmessage_client_to_relay = function(event) {
this.c2r_schedule.push(event.data);
this.flush();
}.bind(this);
this.onmessage_relay_to_client = function(event) {
this.r2c_schedule.push(event.data);
this.flush();
}.bind(this);
function is_open(ws) {
return ws !== undefined && ws.readyState === WebSocket.OPEN;
}
function is_closed(ws) {
return ws === undefined || ws.readyState === WebSocket.CLOSED;
}
this.close = function() {
if (!is_closed(this.client_s))
this.client_s.close();
if (!is_closed(this.relay_s))
this.relay_s.close();
};
/* Send as much data as the rate limit currently allows. */
this.flush = function() {
var busy;
if (this.flush_timeout_id)
clearTimeout(this.flush_timeout_id);
this.flush_timeout_id = null;
busy = true;
while (busy && !this.rate_limit.is_limited()) {
var chunk;
busy = false;
if (is_open(this.client_s) &&
this.client_s.bufferedAmount < MAX_BUFFER &&
this.r2c_schedule.length > 0) {
chunk = this.r2c_schedule.shift();
this.rate_limit.update(chunk.length);
this.client_s.send(chunk);
busy = true;
}
if (is_open(this.relay_s) &&
this.relay_s.bufferedAmount < MAX_BUFFER &&
this.c2r_schedule.length > 0) {
chunk = this.c2r_schedule.shift();
this.rate_limit.update(chunk.length);
this.relay_s.send(chunk);
busy = true;
}
}
if (is_closed(this.relay_s) &&
!is_closed(this.client_s) &&
this.client_s.bufferedAmount === 0 &&
this.r2c_schedule.length === 0) {
log("Client: closing.");
this.client_s.close();
}
if (is_closed(this.client_s) &&
!is_closed(this.relay_s) &&
this.relay_s.bufferedAmount === 0 &&
this.c2r_schedule.length === 0) {
log("Relay: closing.");
this.relay_s.close();
}
if (this.r2c_schedule.length > 0 ||
(is_open(this.client_s) && this.client_s.bufferedAmount > 0) ||
this.c2r_schedule.length > 0 ||
(is_open(this.relay_s) && this.relay_s.bufferedAmount > 0))
this.flush_timeout_id = setTimeout(
this.flush.bind(this), this.rate_limit.when() * 1000);
};
}