Skip to content

Commit 1ff5769

Browse files
committed
add support for webtorrent (websocket) trackers
1 parent d759456 commit 1ff5769

File tree

6 files changed

+283
-73
lines changed

6 files changed

+283
-73
lines changed

client.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ var once = require('once')
77
var url = require('url')
88

99
var common = require('./lib/common')
10-
var HTTPTracker = require('./lib/http-tracker')
11-
var UDPTracker = require('./lib/udp-tracker')
10+
var HTTPTracker = require('./lib/http-tracker') // empty object in browser
11+
var UDPTracker = require('./lib/udp-tracker') // empty object in browser
1212
var WebSocketTracker = require('./lib/websocket-tracker')
1313

1414
inherits(Client, EventEmitter)
@@ -47,24 +47,24 @@ function Client (peerId, port, torrent, opts) {
4747
debug('new client %s', self._infoHash.toString('hex'))
4848

4949
if (typeof torrent.announce === 'string') torrent.announce = [ torrent.announce ]
50+
if (torrent.announce == null) torrent.announce = []
5051

51-
self._trackers = (torrent.announce || [])
52-
.filter(function (announceUrl) {
53-
var protocol = url.parse(announceUrl).protocol
54-
return [ 'udp:', 'http:', 'https:', 'ws:', 'wss:' ].indexOf(protocol) !== -1
55-
})
52+
self._trackers = torrent.announce
5653
.map(function (announceUrl) {
5754
var trackerOpts = { interval: self._intervalMs }
5855
var protocol = url.parse(announceUrl).protocol
5956

60-
if (protocol === 'http:' || protocol === 'https:') {
57+
if ((protocol === 'http:' || protocol === 'https:') &&
58+
typeof HTTPTracker === 'function') {
6159
return new HTTPTracker(self, announceUrl, trackerOpts)
62-
} else if (protocol === 'udp:') {
60+
} else if (protocol === 'udp:' && typeof UDPTracker === 'function') {
6361
return new UDPTracker(self, announceUrl, trackerOpts)
6462
} else if (protocol === 'ws:' || protocol === 'wss:') {
6563
return new WebSocketTracker(self, announceUrl, trackerOpts)
6664
}
65+
return null
6766
})
67+
.filter(Boolean)
6868
}
6969

7070
/**

lib/common-node.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* Functions/constants needed by both the client and server (but only in node).
3+
* These are separate from common.js so they can be skipped when bundling for the browser.
4+
*/
5+
6+
var querystring = require('querystring')
7+
8+
exports.IPV4_RE = /^[\d\.]+$/
9+
exports.IPV6_RE = /^[\da-fA-F:]+$/
10+
11+
exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ])
12+
exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 }
13+
exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 }
14+
exports.EVENT_IDS = {
15+
0: 'update',
16+
1: 'completed',
17+
2: 'started',
18+
3: 'stopped'
19+
}
20+
exports.EVENT_NAMES = {
21+
update: 'update',
22+
completed: 'complete',
23+
started: 'start',
24+
stopped: 'stop'
25+
}
26+
27+
function toUInt32 (n) {
28+
var buf = new Buffer(4)
29+
buf.writeUInt32BE(n, 0)
30+
return buf
31+
}
32+
exports.toUInt32 = toUInt32
33+
34+
/**
35+
* `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent
36+
* clients send non-UTF8 querystrings
37+
* @param {string} q
38+
* @return {Object}
39+
*/
40+
exports.querystringParse = function (q) {
41+
var saved = querystring.unescape
42+
querystring.unescape = unescape // global
43+
var ret = querystring.parse(q)
44+
querystring.unescape = saved
45+
return ret
46+
}
47+
48+
/**
49+
* `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent
50+
* clients send non-UTF8 querystrings
51+
* @param {Object} obj
52+
* @return {string}
53+
*/
54+
exports.querystringStringify = function (obj) {
55+
var saved = querystring.escape
56+
querystring.escape = escape // global
57+
var ret = querystring.stringify(obj)
58+
ret = ret.replace(/[\@\*\/\+]/g, function (char) {
59+
// `escape` doesn't encode the characters @*/+ so we do it manually
60+
return '%' + char.charCodeAt(0).toString(16).toUpperCase()
61+
})
62+
querystring.escape = saved
63+
return ret
64+
}

lib/common.js

Lines changed: 3 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,13 @@
22
* Functions/constants needed by both the client and server.
33
*/
44

5-
var querystring = require('querystring')
6-
7-
exports.IPV4_RE = /^[\d\.]+$/
8-
exports.IPV6_RE = /^[\da-fA-F:]+$/
5+
var extend = require('xtend/mutable')
96

107
exports.DEFAULT_ANNOUNCE_INTERVAL = 30 * 60 * 1000 // 30 minutes
118

129
exports.DEFAULT_ANNOUNCE_PEERS = 50
1310
exports.MAX_ANNOUNCE_PEERS = 82
1411

15-
exports.CONNECTION_ID = Buffer.concat([ toUInt32(0x417), toUInt32(0x27101980) ])
16-
exports.ACTIONS = { CONNECT: 0, ANNOUNCE: 1, SCRAPE: 2, ERROR: 3 }
17-
exports.EVENTS = { update: 0, completed: 1, started: 2, stopped: 3 }
18-
exports.EVENT_IDS = {
19-
0: 'update',
20-
1: 'completed',
21-
2: 'started',
22-
3: 'stopped'
23-
}
24-
exports.EVENT_NAMES = {
25-
update: 'update',
26-
completed: 'complete',
27-
started: 'start',
28-
stopped: 'stop'
29-
}
30-
31-
function toUInt32 (n) {
32-
var buf = new Buffer(4)
33-
buf.writeUInt32BE(n, 0)
34-
return buf
35-
}
36-
exports.toUInt32 = toUInt32
37-
3812
exports.binaryToHex = function (str) {
3913
return new Buffer(str, 'binary').toString('hex')
4014
}
@@ -43,34 +17,5 @@ exports.hexToBinary = function (str) {
4317
return new Buffer(str, 'hex').toString('binary')
4418
}
4519

46-
/**
47-
* `querystring.parse` using `unescape` instead of decodeURIComponent, since bittorrent
48-
* clients send non-UTF8 querystrings
49-
* @param {string} q
50-
* @return {Object}
51-
*/
52-
exports.querystringParse = function (q) {
53-
var saved = querystring.unescape
54-
querystring.unescape = unescape // global
55-
var ret = querystring.parse(q)
56-
querystring.unescape = saved
57-
return ret
58-
}
59-
60-
/**
61-
* `querystring.stringify` using `escape` instead of encodeURIComponent, since bittorrent
62-
* clients send non-UTF8 querystrings
63-
* @param {Object} obj
64-
* @return {string}
65-
*/
66-
exports.querystringStringify = function (obj) {
67-
var saved = querystring.escape
68-
querystring.escape = escape // global
69-
var ret = querystring.stringify(obj)
70-
ret = ret.replace(/[\@\*\/\+]/g, function (char) {
71-
// `escape` doesn't encode the characters @*/+ so we do it manually
72-
return '%' + char.charCodeAt(0).toString(16).toUpperCase()
73-
})
74-
querystring.escape = saved
75-
return ret
76-
}
20+
var config = require('./common-node')
21+
extend(exports, config)

lib/http-tracker.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,14 @@ HTTPTracker.prototype._request = function (requestUrl, opts, cb) {
8888

8989
get.concat(u, function (err, data, res) {
9090
if (err) return self.client.emit('warning', err)
91-
if (res.statusCode !== 200) return self.client.emit('warning', new Error('Non-200 response code ' + res.statusCode + ' from ' + self.a))
92-
if (!data || data.length === 0) return
91+
if (res.statusCode !== 200) {
92+
return self.client.emit('warning', new Error('Non-200 response code ' +
93+
res.statusCode + ' from ' + self._announceUrl))
94+
}
95+
if (!data || data.length === 0) {
96+
return self.client.emit('warning', new Error('Invalid tracker response from' +
97+
self._announceUrl))
98+
}
9399

94100
try {
95101
data = bencode.decode(data)

lib/websocket-tracker.js

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// TODO: destroy the websocket
2+
3+
module.exports = WebSocketTracker
4+
5+
var debug = require('debug')('bittorrent-tracker:http-tracker')
6+
var EventEmitter = require('events').EventEmitter
7+
var hat = require('hat')
8+
var inherits = require('inherits')
9+
var Peer = require('simple-peer')
10+
var Socket = require('simple-websocket')
11+
12+
var common = require('./common')
13+
14+
// It turns out that you can't open multiple websockets to the same server within one
15+
// browser tab, so let's reuse them.
16+
var socketPool = {}
17+
18+
inherits(WebSocketTracker, EventEmitter)
19+
20+
function WebSocketTracker (client, announceUrl, opts) {
21+
var self = this
22+
EventEmitter.call(self)
23+
debug('new websocket tracker %s', announceUrl)
24+
25+
self.client = client
26+
27+
self._announceUrl = announceUrl
28+
self._peers = {} // peers (offer id -> peer)
29+
self._ready = false
30+
self._socket = null
31+
self._intervalMs = self.client._intervalMs // use client interval initially
32+
self._interval = null
33+
34+
if (socketPool[announceUrl]) self._socket = socketPool[announceUrl]
35+
else self._socket = socketPool[announceUrl] = new Socket(announceUrl)
36+
37+
self._socket.on('warning', self._onSocketWarning.bind(self))
38+
self._socket.on('error', self._onSocketWarning.bind(self)) // TODO: handle error
39+
self._socket.on('message', self._onSocketMessage.bind(self))
40+
}
41+
42+
WebSocketTracker.prototype.announce = function (opts) {
43+
var self = this
44+
if (!self._socket.ready) return self._socket.on('ready', self.announce.bind(self, opts))
45+
46+
opts.info_hash = self.client._infoHash.toString('binary')
47+
opts.peer_id = self.client._peerId.toString('binary')
48+
49+
self._generateOffers(opts.numWant, function (offers) {
50+
opts.offers = offers
51+
52+
if (self._trackerId) {
53+
opts.trackerid = self._trackerId
54+
}
55+
self._send(opts)
56+
})
57+
}
58+
59+
WebSocketTracker.prototype.scrape = function (opts) {
60+
var self = this
61+
self.client.emit('error', new Error('scrape not supported ' + self._announceUrl))
62+
return
63+
}
64+
65+
// TODO: Improve this interface
66+
WebSocketTracker.prototype.setInterval = function (intervalMs) {
67+
var self = this
68+
clearInterval(self._interval)
69+
70+
self._intervalMs = intervalMs
71+
if (intervalMs) {
72+
// HACK
73+
var update = self.announce.bind(self, self.client._defaultAnnounceOpts())
74+
self._interval = setInterval(update, self._intervalMs)
75+
}
76+
}
77+
78+
WebSocketTracker.prototype._onSocketWarning = function (err) {
79+
debug('tracker warning %s', err.message)
80+
}
81+
82+
WebSocketTracker.prototype._onSocketMessage = function (data) {
83+
var self = this
84+
85+
if (!(typeof data === 'object' && data !== null)) {
86+
return self.client.emit('warning', new Error('Invalid tracker response'))
87+
}
88+
89+
if (data.info_hash !== self.client._infoHash.toString('binary')) return
90+
91+
debug('received %s from %s', JSON.stringify(data), self._announceUrl)
92+
93+
var failure = data['failure reason']
94+
if (failure) return self.client.emit('warning', new Error(failure))
95+
96+
var warning = data['warning message']
97+
if (warning) self.client.emit('warning', new Error(warning))
98+
99+
var interval = data.interval || data['min interval']
100+
if (interval && !self._opts.interval && self._intervalMs !== 0) {
101+
// use the interval the tracker recommends, UNLESS the user manually specifies an
102+
// interval they want to use
103+
self.setInterval(interval * 1000)
104+
}
105+
106+
var trackerId = data['tracker id']
107+
if (trackerId) {
108+
// If absent, do not discard previous trackerId value
109+
self._trackerId = trackerId
110+
}
111+
112+
if (data.complete) {
113+
self.client.emit('update', {
114+
announce: self._announceUrl,
115+
complete: data.complete,
116+
incomplete: data.incomplete
117+
})
118+
}
119+
120+
var peer
121+
if (data.offer) {
122+
peer = new Peer({ trickle: false, config: self._opts.rtcConfig })
123+
peer.id = common.binaryToHex(data.peer_id)
124+
peer.once('signal', function (answer) {
125+
var opts = {
126+
info_hash: self.client._infoHash.toString('binary'),
127+
peer_id: self.client._peerId.toString('binary'),
128+
to_peer_id: data.peer_id,
129+
answer: answer,
130+
offer_id: data.offer_id
131+
}
132+
if (self._trackerId) opts.trackerid = self._trackerId
133+
self._send(opts)
134+
})
135+
peer.signal(data.offer)
136+
self.client.emit('peer', peer)
137+
}
138+
139+
if (data.answer) {
140+
peer = self._peers[data.offer_id]
141+
if (peer) {
142+
peer.id = common.binaryToHex(data.peer_id)
143+
peer.signal(data.answer)
144+
self.client.emit('peer', peer)
145+
} else {
146+
debug('got unexpected answer: ' + JSON.stringify(data.answer))
147+
}
148+
}
149+
}
150+
151+
WebSocketTracker.prototype._send = function (opts) {
152+
var self = this
153+
debug('send %s', JSON.stringify(opts))
154+
self._socket.send(opts)
155+
}
156+
157+
WebSocketTracker.prototype._generateOffers = function (numWant, cb) {
158+
var self = this
159+
var offers = []
160+
debug('generating %s offers', numWant)
161+
162+
// TODO: cleanup dead peers and peers that never get a return offer, from self._peers
163+
for (var i = 0; i < numWant; ++i) {
164+
generateOffer()
165+
}
166+
167+
function generateOffer () {
168+
var offerId = hat(160)
169+
var peer = self._peers[offerId] = new Peer({
170+
initiator: true,
171+
trickle: false,
172+
config: self._opts.rtcConfig
173+
})
174+
peer.once('signal', function (offer) {
175+
offers.push({
176+
offer: offer,
177+
offer_id: offerId
178+
})
179+
checkDone()
180+
})
181+
}
182+
183+
function checkDone () {
184+
if (offers.length === numWant) {
185+
debug('generated %s offers', numWant)
186+
cb(offers)
187+
}
188+
}
189+
}

0 commit comments

Comments
 (0)